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) // Registering an AEDT tool and running retention are governance WRITES, gated on compliance.write // (owner, admin), not the read capability. r.With(security.RequireCapability(security.CapComplianceWrite)).Post("/aedt/inventory", h.upsertInventory) r.With(security.RequireCapability(security.CapComplianceWrite)).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) // READING the notice status is candidate.read, not candidate.write: a viewer looking at a record should be // able to see whether the disclosure exists, and gating the read at the write tier would mean the people // most likely to notice a missing notice are the ones who cannot see that it is missing. r.With(security.RequireCapability(security.CapCandidateRead)).Get("/candidates/{candidateID}/notice", h.noticeStatus) } 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) } // NoticeStatus is what a candidate screen needs in order to say, truthfully, whether an automated decision // notice exists for this person. It is a READ that never existed: the write route has been mounted since the // governance module shipped and there was no way to ask the question it answers, which is most of the reason // nothing ever called it. type NoticeStatus struct { OnFile bool `json:"on_file"` DisclosedAt string `json:"disclosed_at,omitempty"` NoticeVersion string `json:"notice_version,omitempty"` Categories []string `json:"data_categories,omitempty"` // Count is how many notices this candidate has. More than one is normal and correct: a new disclosure // version means a new notice, and the history is the evidence. Count int `json:"count"` } func (h *Handler) noticeStatus(w http.ResponseWriter, r *http.Request) { candidateID, err := uuid.Parse(chi.URLParam(r, "candidateID")) if err != nil { writeErr(w, ErrBadRequest) return } out, err := h.svc.NoticeStatus(r.Context(), candidateID) writeOrErr(w, out, err) }