458ba6a7ee
- customer CRUD wizard backend: per-system credential shapes validated via
connector construction, AES-256-GCM at rest, write-only over the API
- ticketing connectors (jira/azure_devops/youtrack/demo) with test-connection
- user management: roles, disable (revokes sessions), force password reset,
delete; self-demotion/disable/delete guards
- admin-editable runtime settings ({_id:app}) incl. atomizer URL override
- audit log written on every privileged mutation + filterable list API
- service status panel: mongo/atomizer/work-performer health + latency with
late-bound breaker, sync and jobs status providers
- tabbed admin UI (customers wizard dialog, users table, settings, status, audit)
- compose: mongo nofile ulimit 64000 (1024 default crashed WiredTiger)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
195 lines
6.1 KiB
Go
195 lines
6.1 KiB
Go
package httpx
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
|
|
"bountyboard/internal/store"
|
|
)
|
|
|
|
func (s *Server) routesAdmin(mux *http.ServeMux) {
|
|
adm := func(h http.HandlerFunc) http.Handler { return s.authedRole("admin", h) }
|
|
|
|
mux.Handle("GET /api/v1/admin/users", adm(s.handleAdminListUsers))
|
|
mux.Handle("PATCH /api/v1/admin/users/{id}", adm(s.handleAdminPatchUser))
|
|
mux.Handle("DELETE /api/v1/admin/users/{id}", adm(s.handleAdminDeleteUser))
|
|
|
|
mux.Handle("GET /api/v1/admin/customers", adm(s.handleAdminListCustomers))
|
|
mux.Handle("POST /api/v1/admin/customers", adm(s.handleAdminCreateCustomer))
|
|
mux.Handle("GET /api/v1/admin/customers/{id}", adm(s.handleAdminGetCustomer))
|
|
mux.Handle("PATCH /api/v1/admin/customers/{id}", adm(s.handleAdminPatchCustomer))
|
|
mux.Handle("DELETE /api/v1/admin/customers/{id}", adm(s.handleAdminDeleteCustomer))
|
|
mux.Handle("POST /api/v1/admin/customers/test-connection", adm(s.handleAdminTestConnectionUnsaved))
|
|
mux.Handle("POST /api/v1/admin/customers/{id}/test-connection", adm(s.handleAdminTestConnection))
|
|
mux.Handle("POST /api/v1/admin/customers/{id}/sync-now", adm(s.handleAdminSyncNow))
|
|
|
|
mux.Handle("GET /api/v1/admin/settings", adm(s.handleAdminGetSettings))
|
|
mux.Handle("PATCH /api/v1/admin/settings", adm(s.handleAdminPatchSettings))
|
|
mux.Handle("GET /api/v1/admin/audit-log", adm(s.handleAdminAuditLog))
|
|
mux.Handle("GET /api/v1/admin/service-status", adm(s.handleAdminServiceStatus))
|
|
}
|
|
|
|
// audit records a privileged mutation; failure is logged, never fatal.
|
|
func (s *Server) audit(r *http.Request, action, entity, entityID string, diff map[string]any) {
|
|
err := s.store.Audit(r.Context(), store.AuditEntry{
|
|
ActorID: CurrentUser(r.Context()).ID,
|
|
ActorIP: ClientIP(r.Context()).String(),
|
|
Action: action,
|
|
Entity: entity,
|
|
EntityID: entityID,
|
|
Diff: diff,
|
|
})
|
|
if err != nil {
|
|
s.log.Error("audit write failed", "action", action, "err", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
st, err := s.store.GetSettings(r.Context())
|
|
if err != nil {
|
|
s.internalError(w, r, "get settings", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"settings": st})
|
|
}
|
|
|
|
func (s *Server) handleAdminPatchSettings(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
BrandingName *string `json:"brandingName"`
|
|
AtomizerBaseURLOverride *string `json:"atomizerBaseUrlOverride"`
|
|
HideCompetingClaims *bool `json:"hideCompetingClaims"`
|
|
DefaultPollIntervalSec *int `json:"defaultPollIntervalSec"`
|
|
DefaultCustomerBudget *float64 `json:"defaultCustomerBudget"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
set := bson.M{}
|
|
if req.BrandingName != nil {
|
|
set["brandingName"] = *req.BrandingName
|
|
}
|
|
if req.AtomizerBaseURLOverride != nil {
|
|
set["atomizerBaseUrlOverride"] = *req.AtomizerBaseURLOverride
|
|
}
|
|
if req.HideCompetingClaims != nil {
|
|
set["hideCompetingClaims"] = *req.HideCompetingClaims
|
|
}
|
|
if req.DefaultPollIntervalSec != nil {
|
|
if *req.DefaultPollIntervalSec < 10 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "poll interval must be >= 10s")
|
|
return
|
|
}
|
|
set["defaultPollIntervalSec"] = *req.DefaultPollIntervalSec
|
|
}
|
|
if req.DefaultCustomerBudget != nil {
|
|
set["defaultCustomerBudget"] = *req.DefaultCustomerBudget
|
|
}
|
|
if len(set) == 0 {
|
|
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields")
|
|
return
|
|
}
|
|
st, err := s.store.PatchSettings(r.Context(), set)
|
|
if err != nil {
|
|
s.internalError(w, r, "patch settings", err)
|
|
return
|
|
}
|
|
s.audit(r, "settings.update", "settings", "app", map[string]any{"set": set})
|
|
writeJSON(w, http.StatusOK, map[string]any{"settings": st})
|
|
}
|
|
|
|
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"),
|
|
q.Get("cursor"), atoiDefault(q.Get("limit"), 50))
|
|
if err != nil {
|
|
s.internalError(w, r, "list audit", err)
|
|
return
|
|
}
|
|
next := ""
|
|
if len(entries) > 0 {
|
|
next = entries[len(entries)-1].ID
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next})
|
|
}
|
|
|
|
// serviceStatus is the §11.17 admin panel payload. Breaker and queue fields
|
|
// are filled in by the phases that introduce them.
|
|
type externalStatus struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Healthy bool `json:"healthy"`
|
|
LatencyMs int64 `json:"latencyMs"`
|
|
Error string `json:"error,omitempty"`
|
|
Breaker string `json:"breaker"`
|
|
}
|
|
|
|
func (s *Server) handleAdminServiceStatus(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
|
|
defer cancel()
|
|
|
|
atomizer := s.probeExternal(ctx, "atomizer", s.effectiveAtomizerBaseURL(ctx))
|
|
performer := s.probeExternal(ctx, "work-performer", s.cfg.WorkPerformerBaseURL)
|
|
if s.atomizer != nil {
|
|
atomizer.Breaker = s.atomizer.BreakerState()
|
|
}
|
|
if s.performer != nil {
|
|
performer.Breaker = s.performer.BreakerState()
|
|
}
|
|
|
|
mongoOK := true
|
|
mongoErr := ""
|
|
if err := s.store.Ping(ctx); err != nil {
|
|
mongoOK, mongoErr = false, err.Error()
|
|
}
|
|
|
|
out := map[string]any{
|
|
"mongo": map[string]any{"healthy": mongoOK, "error": mongoErr},
|
|
"atomizer": atomizer,
|
|
"workPerformer": performer,
|
|
"syncWorkers": s.syncStatuses(),
|
|
"jobs": s.jobStatuses(),
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) probeExternal(ctx context.Context, name, baseURL string) externalStatus {
|
|
st := externalStatus{Name: name, URL: baseURL, Breaker: "unknown"}
|
|
start := time.Now()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/healthz", nil)
|
|
if err != nil {
|
|
st.Error = err.Error()
|
|
return st
|
|
}
|
|
resp, err := healthProbeClient.Do(req)
|
|
st.LatencyMs = time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
st.Error = err.Error()
|
|
return st
|
|
}
|
|
resp.Body.Close()
|
|
st.Healthy = resp.StatusCode == http.StatusOK
|
|
if !st.Healthy {
|
|
st.Error = resp.Status
|
|
}
|
|
return st
|
|
}
|
|
|
|
var healthProbeClient = &http.Client{Timeout: 3 * time.Second}
|
|
|
|
func atoiDefault(s string, def int) int {
|
|
if s == "" {
|
|
return def
|
|
}
|
|
var n int
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return def
|
|
}
|
|
n = n*10 + int(c-'0')
|
|
}
|
|
return n
|
|
}
|