Files
brain/internal/authguard/handlers.go
T
Andrew S Erwin 4e421f6ea2
release gate / check (push) Failing after 6m43s
Address code review findings across API, web and deployment
High severity:
- wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup
- bucket public rate limits on effective client IP, add per-bookmark budget,
  and forward the signed browser identity from the share page
- disable first-run bootstrap in server deployments and document seeding
- serialize last-admin demotion in one advisory-locked transaction

Medium/low:
- cap and batch share recipient inserts
- add a shared outbound budget for preview/CardDAV/IMAP/signal work
- pin CardDAV dials to 443 and bound IMAP logout
- fix rightward kanban column drags and normalize cards on column delete
- classify backend outages in auth guards, add request timeouts and an
  error boundary, remove the dead login route, surface throttling
- enforce pinned/unpinned page reorder grouping server-side
- avoid preview work for stale bookmark versions; drop duplicate dialog fetch
- sequence-guard collection/tag/calendar/quick-find loads
- validate property config and fix MULTI_SELECT/DATE filter inputs
- document index maintenance; apply same-origin check to sign-out
- trim signup names, unique shared dates and kanban keys (migration 010),
  reset reference cache on identity changes, hide OIDC issuer, return 413
  for oversized direct bodies, escape Markdown link destinations
- remove dead template files and duplicate test helper

Verified with go build/vet, full PostgreSQL race suite, db convergence,
tsc, both ESLint configs and a Next production build.
2026-10-20 00:13:30 -05:00

121 lines
4.9 KiB
Go

package authguard
import (
"database/sql"
"encoding/json"
"errors"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/handlers"
"log"
"net/http"
"time"
)
// errLastAdmin is the transactional refusal to remove the final
// administrator (review H-4). It is raised inside the locked transaction so
// the count and the update cannot be interleaved by a concurrent demotion.
var errLastAdmin = errors.New("at least one administrator must remain")
func HandleAdminListUsers(w http.ResponseWriter, r *http.Request) {
// COALESCE(email,'') (review COR-8): NOT LIKE evaluates to NULL for the
// NULL emails OIDC-sourced accounts can carry, which hid them from the
// admin list entirely.
v, e := db.Many(r.Context(), db.FromContext(r.Context()), `SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'image',u.image,'role',u.role,'createdAt',u."createdAt",'_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "Bookmark" b WHERE b."ownerId"=u.id),'pages',(SELECT count(*) FROM "Page" p WHERE p."ownerId"=u.id),'tags',(SELECT count(*) FROM "Tag" t WHERE t."ownerId"=u.id))) FROM "User" u WHERE COALESCE(email,'') NOT LIKE '%@example.com' ORDER BY "createdAt"`)
if e != nil {
handlers.WriteJSON(w, 500, handlers.Error{Error: "Database unavailable"})
return
}
handlers.WriteJSON(w, 200, v)
}
func HandleAdminPatchUser(w http.ResponseWriter, r *http.Request) {
var b struct {
UserID string `json:"userId"`
Role string `json:"role"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&b) != nil || (b.Role != "USER" && b.Role != "ADMIN") {
handlers.WriteJSON(w, 400, handlers.Error{Error: "Invalid role"})
return
}
s, _ := auth.SessionFromContext(r.Context())
if b.UserID == s.RealID && b.Role != "ADMIN" {
handlers.WriteJSON(w, 400, handlers.Error{Error: "You cannot demote yourself"})
return
}
d := db.FromContext(r.Context())
repo := db.NewUserRepo(d)
target, e := repo.FindByID(r.Context(), b.UserID)
if e != nil {
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
return
}
// Review SEC-11/H-4: an installation must keep at least one administrator.
// Self-demotion is already refused; this closes the "demote the last
// other admin" path. The role re-read, count and update all run in ONE
// transaction serialized by an advisory lock, so two admins demoting
// each other concurrently can no longer both observe admins == 2 and
// leave zero. The pre-transaction role read above only picks the audit
// message; the decision uses the locked value.
if b.Role == "USER" && target.Role == "ADMIN" {
e = db.Transaction(r.Context(), d, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(r.Context(), `SELECT pg_advisory_xact_lock(927439)`); e != nil {
return e
}
var current string
if e := tx.QueryRowContext(r.Context(), `SELECT role FROM "User" WHERE id=$1`, b.UserID).Scan(&current); e != nil {
return e
}
if current == "ADMIN" {
var admins int
if e := tx.QueryRowContext(r.Context(), `SELECT count(*) FROM "User" WHERE role='ADMIN' AND COALESCE(email,'') NOT LIKE '%@example.com'`).Scan(&admins); e != nil {
return e
}
if admins <= 1 {
return errLastAdmin
}
}
_, e := tx.ExecContext(r.Context(), `UPDATE "User" SET role=$1,"updatedAt"=now() WHERE id=$2`, b.Role, b.UserID)
return e
})
} else {
e = repo.SetRole(r.Context(), b.UserID, b.Role)
}
if errors.Is(e, sql.ErrNoRows) {
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
return
}
if errors.Is(e, errLastAdmin) {
handlers.WriteJSON(w, 400, handlers.Error{Error: "At least one administrator must remain"})
return
}
if e != nil {
handlers.WriteJSON(w, 500, handlers.Error{Error: "Unable to change role"})
return
}
// Review SEC-11: role changes are audited. Ids and roles only.
log.Printf("audit: admin %s changed role of %s: %s -> %s", s.RealID, b.UserID, target.Role, b.Role)
handlers.WriteJSON(w, 200, handlers.OK{OK: true})
}
func HandleAdminViewAs(w http.ResponseWriter, r *http.Request) {
var b struct {
UserID *string `json:"userId"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&b) != nil {
handlers.WriteJSON(w, 400, handlers.Error{Error: "Invalid JSON"})
return
}
s, _ := auth.SessionFromContext(r.Context())
c := http.Cookie{Name: auth.ViewAsCookie, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: config.Load().CookieSecure(), MaxAge: -1}
if b.UserID != nil && *b.UserID != "" && *b.UserID != s.RealID {
if _, e := db.NewUserRepo(db.FromContext(r.Context())).FindByID(r.Context(), *b.UserID); e != nil {
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
return
}
c.Value = *b.UserID
c.MaxAge = int((8 * time.Hour).Seconds())
}
http.SetCookie(w, &c)
handlers.WriteJSON(w, 200, handlers.OK{OK: true})
}