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}) }