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,376 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"bountyboard/internal/crypto"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/store"
|
||||
syncpkg "bountyboard/internal/sync"
|
||||
)
|
||||
|
||||
// customerPayload is the create/update body. Credentials are write-only:
|
||||
// they are encrypted at rest and never returned.
|
||||
type customerPayload struct {
|
||||
Name *string `json:"name"`
|
||||
Type *domain.TicketingType `json:"type"`
|
||||
BaseURL *string `json:"baseUrl"`
|
||||
ProjectKey *string `json:"projectKey"`
|
||||
Credentials *syncpkg.Credentials `json:"credentials"`
|
||||
PollInterval *int `json:"pollIntervalSec"`
|
||||
ConsultantIDs *[]string `json:"consultantIds"`
|
||||
DefaultBudget *float64 `json:"defaultBudget"`
|
||||
Archived *bool `json:"archived"`
|
||||
Version *int64 `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Server) sealCredentials(creds syncpkg.Credentials) (string, error) {
|
||||
plain, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return crypto.Seal(s.cfg.CredentialsEncKey, plain)
|
||||
}
|
||||
|
||||
func (s *Server) openCredentials(c *domain.Customer) (syncpkg.Credentials, error) {
|
||||
if c.Ticketing.CredentialsEnc == "" {
|
||||
return syncpkg.Credentials{}, nil
|
||||
}
|
||||
plain, err := crypto.Open(s.cfg.CredentialsEncKey, c.Ticketing.CredentialsEnc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var creds syncpkg.Credentials
|
||||
if err := json.Unmarshal(plain, &creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// customerJSON augments the customer with a hasCredentials flag (the
|
||||
// ciphertext itself is never serialized).
|
||||
func customerJSON(c *domain.Customer) map[string]any {
|
||||
return map[string]any{
|
||||
"id": c.ID, "name": c.Name,
|
||||
"ticketing": map[string]any{
|
||||
"type": c.Ticketing.Type, "baseUrl": c.Ticketing.BaseURL,
|
||||
"projectKey": c.Ticketing.ProjectKey, "pollIntervalSec": c.Ticketing.PollIntervalSec,
|
||||
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
|
||||
"lastSyncError": c.Ticketing.LastSyncError,
|
||||
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
||||
},
|
||||
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
|
||||
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
|
||||
"version": c.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
include := r.URL.Query().Get("includeArchived") == "true"
|
||||
customers, err := s.store.ListCustomers(r.Context(), "", include)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list customers", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, len(customers))
|
||||
for i := range customers {
|
||||
out[i] = customerJSON(&customers[i])
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"customers": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := s.store.CustomerByID(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "customer not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "get customer", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)})
|
||||
}
|
||||
|
||||
func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
|
||||
for _, id := range ids {
|
||||
u, err := s.store.UserByID(ctx, id)
|
||||
if err != nil || !u.Roles.Consultant {
|
||||
return id, false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
var req customerPayload
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Name == nil || strings.TrimSpace(*req.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_name", "customer name is required")
|
||||
return
|
||||
}
|
||||
if req.Type == nil || !req.Type.Valid() {
|
||||
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack or demo")
|
||||
return
|
||||
}
|
||||
settings, err := s.store.GetSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internalError(w, r, "get settings", err)
|
||||
return
|
||||
}
|
||||
|
||||
c := &domain.Customer{
|
||||
Name: strings.TrimSpace(*req.Name),
|
||||
Ticketing: domain.Ticketing{
|
||||
Type: *req.Type,
|
||||
PollIntervalSec: settings.DefaultPollIntervalSec,
|
||||
},
|
||||
DefaultBudget: settings.DefaultCustomerBudget,
|
||||
ConsultantIDs: []string{},
|
||||
}
|
||||
if req.BaseURL != nil {
|
||||
c.Ticketing.BaseURL = strings.TrimRight(strings.TrimSpace(*req.BaseURL), "/")
|
||||
}
|
||||
if req.ProjectKey != nil {
|
||||
c.Ticketing.ProjectKey = strings.TrimSpace(*req.ProjectKey)
|
||||
}
|
||||
if req.PollInterval != nil && *req.PollInterval >= 10 {
|
||||
c.Ticketing.PollIntervalSec = *req.PollInterval
|
||||
}
|
||||
if req.DefaultBudget != nil {
|
||||
c.DefaultBudget = *req.DefaultBudget
|
||||
}
|
||||
if req.ConsultantIDs != nil {
|
||||
if bad, ok := s.validateConsultants(r.Context(), *req.ConsultantIDs); !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid_consultant", "not a consultant: "+bad)
|
||||
return
|
||||
}
|
||||
c.ConsultantIDs = *req.ConsultantIDs
|
||||
}
|
||||
creds := syncpkg.Credentials{}
|
||||
if req.Credentials != nil {
|
||||
creds = *req.Credentials
|
||||
}
|
||||
// Validate credential shape by constructing the connector.
|
||||
if _, err := syncpkg.NewConnector(c.Ticketing.Type, c.Ticketing.BaseURL, c.Ticketing.ProjectKey, creds); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_credentials", err.Error())
|
||||
return
|
||||
}
|
||||
if c.Ticketing.CredentialsEnc, err = s.sealCredentials(creds); err != nil {
|
||||
s.internalError(w, r, "seal credentials", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.CreateCustomer(r.Context(), c); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
writeError(w, http.StatusConflict, "name_taken", "a customer with this name exists")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "create customer", err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "customer.create", "customer", c.ID, map[string]any{"name": c.Name, "type": c.Ticketing.Type})
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"customer": customerJSON(c)})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req customerPayload
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
c, err := s.store.CustomerByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "customer not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "get customer", err)
|
||||
return
|
||||
}
|
||||
|
||||
set := bson.M{}
|
||||
diff := map[string]any{}
|
||||
if req.Name != nil && strings.TrimSpace(*req.Name) != "" {
|
||||
set["name"] = strings.TrimSpace(*req.Name)
|
||||
diff["name"] = set["name"]
|
||||
}
|
||||
if req.Type != nil {
|
||||
if !req.Type.Valid() {
|
||||
writeError(w, http.StatusBadRequest, "invalid_type", "bad ticketing type")
|
||||
return
|
||||
}
|
||||
set["ticketing.type"] = *req.Type
|
||||
diff["type"] = *req.Type
|
||||
}
|
||||
if req.BaseURL != nil {
|
||||
set["ticketing.baseUrl"] = strings.TrimRight(strings.TrimSpace(*req.BaseURL), "/")
|
||||
diff["baseUrl"] = set["ticketing.baseUrl"]
|
||||
}
|
||||
if req.ProjectKey != nil {
|
||||
set["ticketing.projectKey"] = strings.TrimSpace(*req.ProjectKey)
|
||||
diff["projectKey"] = set["ticketing.projectKey"]
|
||||
}
|
||||
if req.PollInterval != nil {
|
||||
if *req.PollInterval < 10 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "poll interval must be >= 10s")
|
||||
return
|
||||
}
|
||||
set["ticketing.pollIntervalSec"] = *req.PollInterval
|
||||
diff["pollIntervalSec"] = *req.PollInterval
|
||||
}
|
||||
if req.ConsultantIDs != nil {
|
||||
if bad, ok := s.validateConsultants(r.Context(), *req.ConsultantIDs); !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid_consultant", "not a consultant: "+bad)
|
||||
return
|
||||
}
|
||||
set["consultantIds"] = *req.ConsultantIDs
|
||||
diff["consultantIds"] = *req.ConsultantIDs
|
||||
}
|
||||
if req.DefaultBudget != nil {
|
||||
set["defaultBudget"] = *req.DefaultBudget
|
||||
diff["defaultBudget"] = *req.DefaultBudget
|
||||
}
|
||||
if req.Archived != nil {
|
||||
set["archived"] = *req.Archived
|
||||
diff["archived"] = *req.Archived
|
||||
}
|
||||
if req.Credentials != nil {
|
||||
enc, err := s.sealCredentials(*req.Credentials)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "seal credentials", err)
|
||||
return
|
||||
}
|
||||
set["ticketing.credentialsEnc"] = enc
|
||||
diff["credentials"] = "rotated"
|
||||
}
|
||||
if len(set) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields")
|
||||
return
|
||||
}
|
||||
version := c.Version
|
||||
if req.Version != nil {
|
||||
version = *req.Version
|
||||
}
|
||||
if err := s.store.UpdateCustomer(r.Context(), id, version, set); err != nil {
|
||||
if errors.Is(err, store.ErrVersionConflict) {
|
||||
writeError(w, http.StatusConflict, "conflict", "customer was modified concurrently; reload")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "update customer", err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "customer.update", "customer", id, diff)
|
||||
fresh, err := s.store.CustomerByID(r.Context(), id)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "reload customer", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(fresh)})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
n, err := s.store.CountTasksForCustomer(r.Context(), id)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "count tasks", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
writeError(w, http.StatusConflict, "has_tasks",
|
||||
"customer has imported tasks; archive it instead of deleting")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteCustomer(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "customer not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "delete customer", err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "customer.delete", "customer", id, nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleAdminTestConnection tests the stored configuration of an existing
|
||||
// customer.
|
||||
func (s *Server) handleAdminTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := s.store.CustomerByID(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "customer not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "get customer", err)
|
||||
return
|
||||
}
|
||||
creds, err := s.openCredentials(c)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "decrypt credentials", err)
|
||||
return
|
||||
}
|
||||
s.runConnectionTest(w, r, c.Ticketing.Type, c.Ticketing.BaseURL, c.Ticketing.ProjectKey, creds)
|
||||
}
|
||||
|
||||
// handleAdminTestConnectionUnsaved tests credentials supplied in the wizard
|
||||
// before the customer is saved.
|
||||
func (s *Server) handleAdminTestConnectionUnsaved(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Type domain.TicketingType `json:"type"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
ProjectKey string `json:"projectKey"`
|
||||
Credentials syncpkg.Credentials `json:"credentials"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.runConnectionTest(w, r, req.Type, strings.TrimRight(req.BaseURL, "/"), req.ProjectKey, req.Credentials)
|
||||
}
|
||||
|
||||
func (s *Server) runConnectionTest(w http.ResponseWriter, r *http.Request,
|
||||
t domain.TicketingType, baseURL, projectKey string, creds syncpkg.Credentials) {
|
||||
conn, err := syncpkg.NewConnector(t, baseURL, projectKey, creds)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if err := conn.TestConnection(ctx); err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error(),
|
||||
"latencyMs": time.Since(start).Milliseconds()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true,
|
||||
"latencyMs": time.Since(start).Milliseconds()})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := s.store.CustomerByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "customer not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "get customer", err)
|
||||
return
|
||||
}
|
||||
if s.syncTrigger == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "sync_unavailable", "sync manager is not running")
|
||||
return
|
||||
}
|
||||
s.syncTrigger(id)
|
||||
s.audit(r, "customer.sync-now", "customer", id, nil)
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued"})
|
||||
}
|
||||
Reference in New Issue
Block a user