package governance import ( "encoding/json" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Handler serves the AEDT governance API under the tenant group. type Handler struct{ svc *Service } // NewHandler builds the handler. func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // Routes mounts the governance endpoints with their capabilities. func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapComplianceRead)).Get("/aedt/model-versions", h.modelVersions) r.With(security.RequireCapability(security.CapComplianceRead)).Get("/aedt/inventory", h.listInventory) r.With(security.RequireCapability(security.CapComplianceRead)).Post("/aedt/inventory", h.upsertInventory) r.With(security.RequireCapability(security.CapComplianceRead)).Post("/compliance/retention/run", h.runRetention) r.With(security.RequireCapability(security.CapAuditRead)).Get("/audit", h.audit) r.With(security.RequireCapability(security.CapCandidateWrite)).Post("/candidates/{candidateID}/notice", h.recordNotice) } func (h *Handler) modelVersions(w http.ResponseWriter, r *http.Request) { out, err := h.svc.ListModelVersions(r.Context()) writeOrErr(w, out, err) } func (h *Handler) listInventory(w http.ResponseWriter, r *http.Request) { out, err := h.svc.ListInventory(r.Context()) writeOrErr(w, out, err) } func (h *Handler) upsertInventory(w http.ResponseWriter, r *http.Request) { var in InventoryInput if !decode(w, r, &in) { return } out, err := h.svc.UpsertInventory(r.Context(), in) writeOrErr(w, out, err) } func (h *Handler) runRetention(w http.ResponseWriter, r *http.Request) { n, err := h.svc.RunRetention(r.Context()) if err != nil { writeErr(w, err) return } writeJSON(w, http.StatusOK, map[string]int{"anonymized": n}) } func (h *Handler) audit(w http.ResponseWriter, r *http.Request) { limit, offset := int32(100), int32(0) if s := r.URL.Query().Get("limit"); s != "" { v, err := strconv.Atoi(s) if err != nil || v <= 0 || v > 500 { writeErr(w, ErrBadRequest) return } limit = int32(v) } if s := r.URL.Query().Get("offset"); s != "" { v, err := strconv.Atoi(s) if err != nil || v < 0 || v > 1_000_000_000 { writeErr(w, ErrBadRequest) return } offset = int32(v) } out, err := h.svc.ListAudit(r.Context(), limit, offset) writeOrErr(w, out, err) } func (h *Handler) recordNotice(w http.ResponseWriter, r *http.Request) { candidateID, err := uuid.Parse(chi.URLParam(r, "candidateID")) if err != nil { writeErr(w, ErrBadRequest) return } var in NoticeInput if !decode(w, r, &in) { return } out, err := h.svc.RecordNotice(r.Context(), candidateID, in) writeOrErr(w, out, err) } // ErrBadRequest maps to 400. var ErrBadRequest = errors.New("governance: bad request") func writeOrErr(w http.ResponseWriter, v any, err error) { if err != nil { writeErr(w, err) return } writeJSON(w, http.StatusOK, v) } func writeErr(w http.ResponseWriter, err error) { status, msg := http.StatusInternalServerError, "internal error" switch { case errors.Is(err, tenant.ErrNoTenant): status, msg = http.StatusForbidden, "no validated tenant" case errors.Is(err, ErrNoDatabase): status, msg = http.StatusServiceUnavailable, "database not configured" case errors.Is(err, ErrBadRequest): status, msg = http.StatusBadRequest, "bad request" } writeJSON(w, status, map[string]string{"error": msg}) } func decode(w http.ResponseWriter, r *http.Request, dst any) bool { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return false } return true } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }