Files
pad/internal/server/handlers_admin_users.go
T
xarmian 0fd5d0cdfb fix: green up Go (PostgreSQL) CI (BUG-842) (#275)
* fix(store): swap plainto_tsquery → websearch_to_tsquery for PG FTS (BUG-842)

`TestListItems_FTS_HyphenatedSearchTerm/task-five` has been failing on
every Go (PostgreSQL) CI run because `plainto_tsquery('english',
'task-five')` doesn't match the asciihword lexeme(s) the english parser
produces for an indexed `task-five-distinctive`. The result is that
every PG full-text search for hyphenated terms returns zero rows.

`websearch_to_tsquery` (Postgres 11+) is purpose-built for arbitrary
user input and tokenizes hyphenated terms the same way `to_tsvector`
does for the indexed document, so the query intersects the index
correctly. Swapped in three spots in the postgres dialect — FTSMatch,
FTSSnippet, FTSRank — and updated the caller-side comments that
referenced plainto_tsquery. SQLite path is unchanged: it goes through
items_fts MATCH with sanitizeFTSQuery, never through these methods.

* fix(server): drain background goroutines on Stop() (BUG-842)

`TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly` (and
other server tests) have been flaking on the Go (PostgreSQL) CI runner
with `TempDir RemoveAll cleanup: directory not empty`. Root cause:
several request handlers spawned bare `go func() { ... }()` goroutines
that touched the SQLite WAL DB after the test function returned.
testServer's t.Cleanup closed the store but had no way to drain those
goroutines first, so a fire-and-forget WAL write could re-create the
`-wal`/`-shm` files between Close() and t.TempDir's RemoveAll.

Add a Server.bg sync.WaitGroup, a Server.goAsync helper that wraps a
WaitGroup-tracked goroutine, and a Server.Stop() that blocks until
every goAsync closure has finished. Convert the four known
fire-and-forget sites to goAsync:

- middleware_auth.go (TouchUserActivity)
- handlers_auth.go   (password reset email)
- handlers_cloud.go  (stripe_processed_events pruning)
- handlers_members.go (workspace invitation email)

Wire `srv.Stop()` into both testServer (server_test.go) and
newMetricsTestServer (metrics_auth_test.go) so cleanup order is
Stop → Close → TempDir RemoveAll. Add
TestServer_Stop_DrainsBackgroundGoroutines to pin the contract: a
goAsync goroutine must block Stop until it returns.

* fix(store): correct PG FTS hyphenation via OR-combined plainto_tsquery (BUG-842)

The previous attempt swapped plainto_tsquery → websearch_to_tsquery,
which was wrong: websearch_to_tsquery treats `-` as a NEGATION operator
(Google-style), so `task-five` becomes `task & !five` and the search
returns 0 rows for the same reason as before. This commit reverts the
swap and applies the actual fix.

PG's english parser indexes `task-five-distinctive` as
`{task-five-distinct, task, five, distinct}` — the asciihword AND its
parts. plainto_tsquery applied to the partial query `task-five`
produces `task-fiv & task & five`: the stemmed asciihword for the
PARTIAL query (`task-fiv`) is NOT in the vector, so the AND fails.

Replacing the hyphen with a space makes plainto emit `task & five`,
which DOES match — but doing that unconditionally breaks `BUG-842`-
style queries: PG indexes the `-842` suffix as a negative-number
lexeme, so `plainto_tsquery('BUG-842')` matches via `-842`, while
`plainto_tsquery('BUG 842')` searches for `842` and misses.

The fix ORs the two query variants together so the search vector is
matched against either the raw user query OR its hyphen-as-space form.
Both `task-five` (against `task-five-distinctive`) and `BUG-842`
(against `BUG-842 fix the cleanup race`) hit. Verified locally against
postgres:17-alpine via PAD_TEST_POSTGRES_URL — both 10x stress and
race-detector runs are green.

Surfaces:
  - dialect.go: FTSMatch / FTSSnippet / FTSRank now consume TWO
    placeholders each in the PG dialect.
  - items.go: listItemsFTS PG branch + SearchItems PG branch update
    args to pass (raw, sanitized) for every PG `?` placeholder.
  - search.go: SearchItems main / count / facets PG branches updated
    likewise. New sanitizePGFTSQuery helper alongside sanitizeFTSQuery.
  - documents.go: ListDocuments PG branch updated.

Tests:
  - TestListItems_FTS_HyphenatedSearchTerm extended with a `BUG-842`
    case to pin the OR-combined logic — naive hyphen-stripping would
    silently regress this.
  - New TestSanitizePGFTSQuery unit test.

* chore: gofmt 11 files with import-order issues (BUG-842 PR cleanup)

The Go (SQLite) CI job has been failing on `main` (and every PR built
against it) because golangci-lint flags 11 files whose third-party
imports are intermixed with internal imports — the import-grouping
rule that gofmt enforces. None of these were introduced by the
BUG-842 PR; they're pre-existing on main. The PR can't go green
without this cleanup, though, so it's bundled here.

Pure mechanical change — `gofmt -w <files>` only re-orders import
groups; no logic changes. Files touched:

  cmd/pad/configure.go
  cmd/pad/main.go
  internal/cli/format.go
  internal/server/handlers_admin_invitations.go
  internal/server/handlers_admin_users.go
  internal/server/handlers_grants.go
  internal/server/handlers_share_links.go
  internal/server/handlers_stars.go
  internal/server/middleware_auth.go
  internal/store/store.go
  internal/store/store_test.go

After this commit `gofmt -l ./cmd ./internal` returns clean.
2026-04-28 16:21:43 -04:00

594 lines
16 KiB
Go

package server
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/go-chi/chi/v5"
)
// --- Admin User Management (TASK-502) ---
// handleAdminListUsers returns a paginated list of users with plan info.
// GET /api/v1/admin/users?q=search&plan=free&offset=0&limit=50
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
limit := 50
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
offset := 0
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
result, err := s.store.SearchUsers(store.AdminUserSearchParams{
Query: r.URL.Query().Get("q"),
Plan: r.URL.Query().Get("plan"),
Limit: limit,
Offset: offset,
})
if err != nil {
writeInternalError(w, err)
return
}
type adminUser struct {
ID string `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
Name string `json:"name"`
Role string `json:"role"`
Plan string `json:"plan"`
PlanExpiresAt string `json:"plan_expires_at,omitempty"`
PlanOverrides string `json:"plan_overrides,omitempty"`
TOTPEnabled bool `json:"totp_enabled"`
DisabledAt string `json:"disabled_at,omitempty"`
LastActiveAt string `json:"last_active_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
users := make([]adminUser, 0, len(result.Users))
for _, u := range result.Users {
users = append(users, adminUser{
ID: u.ID,
Email: u.Email,
Username: u.Username,
Name: u.Name,
Role: u.Role,
Plan: u.Plan,
PlanExpiresAt: u.PlanExpiresAt,
PlanOverrides: u.PlanOverrides,
TOTPEnabled: u.TOTPEnabled,
DisabledAt: u.DisabledAt,
LastActiveAt: u.LastActiveAt,
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: u.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"users": users,
"total": result.Total,
})
}
// handleAdminGetUser returns a single user with full detail.
// GET /api/v1/admin/users/{userID}
func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
userID := chi.URLParam(r, "userID")
user, err := s.store.GetUser(userID)
if err != nil {
writeInternalError(w, err)
return
}
if user == nil {
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
// Get workspace count for this user
workspaces, err := s.store.GetUserWorkspaces(user.ID)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": user.ID,
"email": user.Email,
"username": user.Username,
"name": user.Name,
"role": user.Role,
"plan": user.Plan,
"plan_expires_at": user.PlanExpiresAt,
"plan_overrides": user.PlanOverrides,
"totp_enabled": user.TOTPEnabled,
"disabled_at": user.DisabledAt,
"last_active_at": user.LastActiveAt,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
"workspace_count": len(workspaces),
})
}
// handleAdminGetUserWorkspaces returns workspace memberships for a user.
// GET /api/v1/admin/users/{userID}/workspaces
func (s *Server) handleAdminGetUserWorkspaces(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
userID := chi.URLParam(r, "userID")
memberships, err := s.store.GetUserWorkspaceMemberships(userID)
if err != nil {
writeInternalError(w, err)
return
}
if memberships == nil {
memberships = []store.AdminUserWorkspace{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"workspaces": memberships,
})
}
// handleAdminUpdateUser updates a user's plan, overrides, or role.
// PATCH /api/v1/admin/users/{userID}
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
userID := chi.URLParam(r, "userID")
user, err := s.store.GetUser(userID)
if err != nil {
writeInternalError(w, err)
return
}
if user == nil {
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
var input struct {
Role *string `json:"role"`
Plan *string `json:"plan"`
PlanExpiresAt *string `json:"plan_expires_at"`
PlanOverrides *string `json:"plan_overrides"`
}
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if input.Role != nil {
validRoles := map[string]bool{"admin": true, "member": true}
if !validRoles[*input.Role] {
writeError(w, http.StatusBadRequest, "bad_request", "role must be 'admin' or 'member'")
return
}
// Guard: cannot demote yourself
caller := currentUser(r)
if caller != nil && caller.ID == userID {
writeError(w, http.StatusBadRequest, "bad_request", "Cannot change your own role")
return
}
// SetUserRole atomically guards against demoting the last admin.
if err := s.store.SetUserRole(userID, *input.Role); err != nil {
if err == store.ErrLastAdmin {
writeError(w, http.StatusBadRequest, "bad_request", "Cannot demote the last admin")
return
}
writeInternalError(w, err)
return
}
s.logAuditEvent(models.ActionRoleChanged, r, auditMeta(map[string]string{
"target_user_id": userID,
"old_role": user.Role,
"new_role": *input.Role,
}))
}
if input.Plan != nil {
validPlans := map[string]bool{"free": true, "pro": true, "self-hosted": true}
if !validPlans[*input.Plan] {
writeError(w, http.StatusBadRequest, "bad_request", "plan must be 'free', 'pro', or 'self-hosted'")
return
}
expiresAt := ""
if input.PlanExpiresAt != nil {
expiresAt = *input.PlanExpiresAt
}
if err := s.store.SetUserPlan(userID, *input.Plan, expiresAt); err != nil {
writeInternalError(w, err)
return
}
s.logAuditEvent(models.ActionPlanChanged, r, auditMeta(map[string]string{
"target_user_id": userID,
"old_plan": user.Plan,
"new_plan": *input.Plan,
}))
}
if input.PlanOverrides != nil {
// Validate JSON
if *input.PlanOverrides != "" {
var overrides map[string]int
if err := json.Unmarshal([]byte(*input.PlanOverrides), &overrides); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "plan_overrides must be valid JSON (map of feature → limit)")
return
}
}
if err := s.store.SetUserPlanOverrides(userID, *input.PlanOverrides); err != nil {
writeInternalError(w, err)
return
}
}
// Return updated user
updated, err := s.store.GetUser(userID)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": updated.ID,
"email": updated.Email,
"username": updated.Username,
"name": updated.Name,
"role": updated.Role,
"plan": updated.Plan,
"plan_overrides": updated.PlanOverrides,
"plan_expires_at": updated.PlanExpiresAt,
"totp_enabled": updated.TOTPEnabled,
"created_at": updated.CreatedAt,
"updated_at": updated.UpdatedAt,
"ok": true,
})
}
// handleAdminResetPassword force-resets a user's password.
// If email is configured, sends a reset link. Otherwise returns a temporary password.
// POST /api/v1/admin/users/{userID}/reset-password
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
userID := chi.URLParam(r, "userID")
user, err := s.store.GetUser(userID)
if err != nil {
writeInternalError(w, err)
return
}
if user == nil {
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
if s.email != nil && s.baseURL != "" {
// Email configured: generate reset token and send link
token, err := s.store.CreatePasswordReset(user.ID)
if err != nil {
writeInternalError(w, err)
return
}
resetURL := s.baseURL + "/reset-password/" + token
if err := s.email.SendPasswordReset(r.Context(), user.Email, user.Name, resetURL); err != nil {
writeError(w, http.StatusInternalServerError, "email_failed", "Failed to send password reset email")
return
}
s.logAuditEvent(models.ActionPasswordResetByAdmin, r, auditMeta(map[string]string{
"target_user_id": userID,
"method": "email",
}))
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"method": "email",
"message": "Password reset email sent to " + user.Email,
})
return
}
// No email: generate a temporary password
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
writeInternalError(w, err)
return
}
tempPassword := hex.EncodeToString(raw)
pwd := tempPassword
if _, err := s.store.UpdateUser(userID, models.UserUpdate{Password: &pwd}); err != nil {
writeInternalError(w, err)
return
}
// Invalidate all existing sessions so the user must log in with the new password
if err := s.store.DeleteUserSessions(userID); err != nil {
writeInternalError(w, err)
return
}
s.logAuditEvent(models.ActionPasswordResetByAdmin, r, auditMeta(map[string]string{
"target_user_id": userID,
"method": "temporary_password",
}))
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"method": "temporary_password",
"temp_password": tempPassword,
"message": "Temporary password generated. The user's existing sessions have been invalidated.",
})
}
// handleAdminDisableUser soft-disables a user account.
// POST /api/v1/admin/users/{userID}/disable
func (s *Server) handleAdminDisableUser(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
userID := chi.URLParam(r, "userID")
// Guard: cannot disable yourself
caller := currentUser(r)
if caller != nil && caller.ID == userID {
writeError(w, http.StatusBadRequest, "bad_request", "Cannot disable your own account")
return
}
user, err := s.store.GetUser(userID)
if err != nil {
writeInternalError(w, err)
return
}
if user == nil {
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
if err := s.store.DisableUser(userID); err != nil {
writeInternalError(w, err)
return
}
// Always invalidate sessions (also handles retry after partial failure)
if err := s.store.DeleteUserSessions(userID); err != nil {
writeInternalError(w, err)
return
}
s.logAuditEvent(models.ActionUserDisabled, r, auditMeta(map[string]string{
"target_user_id": userID,
}))
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"message": "User disabled and sessions invalidated",
})
}
// handleAdminEnableUser re-enables a disabled user account.
// POST /api/v1/admin/users/{userID}/enable
func (s *Server) handleAdminEnableUser(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
userID := chi.URLParam(r, "userID")
user, err := s.store.GetUser(userID)
if err != nil {
writeInternalError(w, err)
return
}
if user == nil {
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
if !user.IsDisabled() {
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "message": "User is already enabled"})
return
}
if err := s.store.EnableUser(userID); err != nil {
writeInternalError(w, err)
return
}
s.logAuditEvent(models.ActionUserEnabled, r, auditMeta(map[string]string{
"target_user_id": userID,
}))
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"message": "User re-enabled",
})
}
// --- Admin Limits Management ---
// handleAdminGetLimits returns the current default plan limits.
// GET /api/v1/admin/limits
func (s *Server) handleAdminGetLimits(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
features := []string{
"workspaces", "items_per_workspace", "members_per_workspace",
"api_tokens", "storage_bytes", "webhooks", "automated_backups",
}
plans := []string{"free", "pro"}
defaults := map[string]store.PlanLimits{
"free": store.DefaultFreeLimits,
"pro": store.DefaultProLimits,
}
result := make(map[string]map[string]int)
for _, plan := range plans {
result[plan] = make(map[string]int)
for _, feature := range features {
key := "plan_limits_" + plan + "_" + feature
val, err := s.store.GetPlatformSetting(key)
if err != nil || val == "" {
// Fall back to hardcoded default for this plan+feature
result[plan][feature] = planLimitDefault(defaults[plan], feature)
continue
}
v, _ := strconv.Atoi(val)
result[plan][feature] = v
}
}
writeJSON(w, http.StatusOK, result)
}
// handleAdminUpdateLimits updates default plan limits.
// PATCH /api/v1/admin/limits
// Body: {"free": {"workspaces": 10, ...}, "pro": {"workspaces": -1, ...}}
func (s *Server) handleAdminUpdateLimits(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
var input map[string]map[string]int
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
validPlans := map[string]bool{"free": true, "pro": true}
validFeatures := map[string]bool{
"workspaces": true, "items_per_workspace": true, "members_per_workspace": true,
"api_tokens": true, "storage_bytes": true, "webhooks": true, "automated_backups": true,
}
for plan, features := range input {
if !validPlans[plan] {
continue
}
for feature, value := range features {
if !validFeatures[feature] {
continue
}
key := "plan_limits_" + plan + "_" + feature
if err := s.store.SetPlatformSetting(key, strconv.Itoa(value)); err != nil {
writeInternalError(w, err)
return
}
}
}
s.logAuditEvent(models.ActionSettingsChanged, r, auditMeta(map[string]string{"scope": "plan_limits"}))
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
// --- Admin Platform Stats ---
// handleAdminStats returns platform-level statistics.
// GET /api/v1/admin/stats
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
userCount, err := s.store.UserCount()
if err != nil {
writeInternalError(w, err)
return
}
// Count users by plan
users, err := s.store.ListUsers()
if err != nil {
writeInternalError(w, err)
return
}
planCounts := map[string]int{}
for _, u := range users {
plan := u.Plan
if plan == "" {
plan = "free"
}
planCounts[plan]++
}
workspaces, err := s.store.ListWorkspaces()
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"users": userCount,
"users_by_plan": planCounts,
"workspaces": len(workspaces),
"cloud_mode": s.cloudMode,
})
}
// --- Helpers ---
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
user := currentUser(r)
if user == nil || user.Role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
return false
}
return true
}
// planLimitDefault returns the hardcoded default for a plan+feature pair.
func planLimitDefault(limits store.PlanLimits, feature string) int {
switch feature {
case "workspaces":
return limits.Workspaces
case "items_per_workspace":
return limits.ItemsPerWorkspace
case "members_per_workspace":
return limits.MembersPerWorkspace
case "api_tokens":
return limits.APITokens
case "storage_bytes":
return limits.StorageBytes
case "webhooks":
return limits.Webhooks
case "automated_backups":
return limits.AutomatedBackups
default:
return 0
}
}
// auditMeta is defined in handlers_documents.go