release gate / check (push) Failing after 6m43s
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.
121 lines
4.9 KiB
Go
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(¤t); 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})
|
|
}
|