feat: admin area with customers, users, settings, audit log, service status (phase 5)
- 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>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user