package httpserver import ( "encoding/json" "io" "net/http" "github.com/krow/krow-backend/go-api/internal/authctx" "github.com/krow/krow-backend/go-api/internal/domain" "github.com/krow/krow-backend/go-api/internal/service" ) // maxBodyBytes bounds a request body. The largest thing the frontend sends is // an AI interview transcript; 4 MB is far above it and far below trouble. const maxBodyBytes = 4 << 20 // routeResources registers exactly the endpoints each resource supports. // // Only the declared operations are registered, so an unsupported one — DELETE // on a job posting, say — is answered by the mux with 405 rather than by a // handler that has to know it should refuse. The database having a table is // never a reason for an endpoint to exist. See api-contract.md §2. func (s *Server) routeResources(mux *http.ServeMux) int { count := 0 for _, svc := range s.api.All() { res := svc.Resource() base := "/api/v1/" + res.Path item := base + "/{id}" if res.Supports(domain.OpList) { mux.HandleFunc("GET "+base, s.handleList(svc)) count++ } if res.Supports(domain.OpCreate) { mux.HandleFunc("POST "+base, s.handleCreate(svc)) count++ } if res.Supports(domain.OpGet) { mux.HandleFunc("GET "+item, s.handleGet(svc)) count++ } if res.Supports(domain.OpUpdate) { mux.HandleFunc("PATCH "+item, s.handleUpdate(svc)) count++ } if res.Supports(domain.OpDelete) { mux.HandleFunc("DELETE "+item, s.handleDelete(svc)) count++ } } return count } // authorize is the role gate. It runs before any query. // // It answers 403 and nothing else — never 404, and never a message naming the // role required. Which rows the caller may then see is a separate question, // answered in SQL by the repository, and its refusal is a 404 so that existence // does not leak. Keeping the two apart is what makes "403 means your role, 404 // means not yours or not there" a rule a client can rely on. // // The role comes from the session-resolved identity. A role the API does not // recognise authorizes nothing. func (s *Server) authorize(w http.ResponseWriter, r *http.Request, svc *service.Service, op domain.Op) (authctx.Identity, bool) { ident, err := authctx.MustFrom(r.Context()) if err != nil { // Unreachable: the middleware refuses an unauthenticated request before // the router sees it. A missing identity here is a wiring bug, not a // client error. writeError(w, s.log, domain.Internal(err)) return authctx.Identity{}, false } role, known := domain.ParseRole(ident.Role) if !known || !svc.Resource().Policy.Allows(op, role) { s.log.Warn("authorization refused", "user_id", ident.UserID, "role", ident.Role, "resource", svc.Resource().Path, "method", r.Method, "path", r.URL.Path) writeError(w, s.log, domain.Forbidden()) return authctx.Identity{}, false } return ident, true } func (s *Server) handleList(svc *service.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ident, ok := s.authorize(w, r, svc, domain.OpList) if !ok { return } params, err := svc.ParseList(r.URL.Query()) if err != nil { writeError(w, s.log, err) return } page, err := svc.List(r.Context(), ident, params) if err != nil { writeError(w, s.log, err) return } writePage(w, page) } } func (s *Server) handleGet(svc *service.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ident, ok := s.authorize(w, r, svc, domain.OpGet) if !ok { return } rec, err := svc.Get(r.Context(), ident, r.PathValue("id")) if err != nil { writeError(w, s.log, err) return } writeRecord(w, http.StatusOK, rec) } } func (s *Server) handleCreate(svc *service.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ident, ok := s.authorize(w, r, svc, domain.OpCreate) if !ok { return } body, err := decodeBody(r) if err != nil { writeError(w, s.log, err) return } rec, err := svc.Create(r.Context(), ident, body) if err != nil { writeError(w, s.log, err) return } writeRecord(w, http.StatusCreated, rec) } } func (s *Server) handleUpdate(svc *service.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ident, ok := s.authorize(w, r, svc, domain.OpUpdate) if !ok { return } body, err := decodeBody(r) if err != nil { writeError(w, s.log, err) return } rec, err := svc.Update(r.Context(), ident, r.PathValue("id"), body) if err != nil { writeError(w, s.log, err) return } writeRecord(w, http.StatusOK, rec) } } func (s *Server) handleDelete(svc *service.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ident, ok := s.authorize(w, r, svc, domain.OpDelete) if !ok { return } rec, err := svc.Delete(r.Context(), ident, r.PathValue("id")) if err != nil { writeError(w, s.log, err) return } writeRecord(w, http.StatusOK, rec) } } // decodeBody reads a JSON object body. // // DisallowUnknownFields is not used — the target is a map, so every field is // "known" here. Unknown *columns* are rejected in the service, where the // resource's schema is available to say which those are. // decodeInto reads a JSON body into a typed struct. // // Beside decodeBody rather than replacing it: the resource handlers genuinely // want the open map, because a PATCH body is "whichever fields the caller sent" // and a struct cannot distinguish an absent field from a zero one. The auth // endpoints have a fixed, closed shape, and a struct says so. func decodeInto(r *http.Request, dst any) error { defer func() { _ = r.Body.Close() }() raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes)) if err != nil { return domain.Invalid("request body could not be read") } if len(raw) == 0 { return domain.Invalid("request body must be a JSON object") } if err := json.Unmarshal(raw, dst); err != nil { return domain.Invalid("request body must be a JSON object") } return nil } func decodeBody(r *http.Request) (domain.Record, error) { defer func() { _ = r.Body.Close() }() raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes)) if err != nil { return nil, domain.Invalid("request body could not be read") } if len(raw) == 0 { return domain.Record{}, nil } var body domain.Record if err := json.Unmarshal(raw, &body); err != nil { return nil, domain.Invalid("request body must be a JSON object") } if body == nil { return domain.Record{}, nil } return body, nil }