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:
etalon
2026-06-12 18:57:10 +02:00
parent 34bf5b5ac2
commit 458ba6a7ee
22 changed files with 2609 additions and 1 deletions
+194
View File
@@ -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
}