154 lines
4.4 KiB
Go
154 lines
4.4 KiB
Go
|
|
// Package httpapi exposes the verification-service's single endpoint:
|
||
|
|
// POST /api/v1/verify/chain, per DEV-612.
|
||
|
|
//
|
||
|
|
// Request:
|
||
|
|
//
|
||
|
|
// {
|
||
|
|
// "decision_id": "<caller-chosen opaque id>",
|
||
|
|
// "approval_ref": "sha256:<64-hex>",
|
||
|
|
// "transform_ref": "sha256:<64-hex>",
|
||
|
|
// "payload_hash": "sha256:<64-hex>",
|
||
|
|
// "sink_target": "sink:dummy-sink"
|
||
|
|
// }
|
||
|
|
//
|
||
|
|
// Response:
|
||
|
|
//
|
||
|
|
// {
|
||
|
|
// "valid": true|false,
|
||
|
|
// "replay": true|false,
|
||
|
|
// "decision_id": "<echoed>",
|
||
|
|
// "actors": ["<approval key_id>", "<transform key_id>"],
|
||
|
|
// "approval_ref": "sha256:…",
|
||
|
|
// "transform_ref": "sha256:…",
|
||
|
|
// "reasons": ["<reason code>", …]
|
||
|
|
// }
|
||
|
|
//
|
||
|
|
// Status codes:
|
||
|
|
// - 200 OK, valid=true — chain passed, decision_id reserved.
|
||
|
|
// - 200 OK, valid=false — chain failed; `reasons` lists the
|
||
|
|
// specific guards that fired. The sink maps `replay: true` to 409
|
||
|
|
// and the rest to 403; verification-service itself never signals
|
||
|
|
// "forbidden" via a status code because a 403 would occlude the
|
||
|
|
// structured reasons the sink needs.
|
||
|
|
// - 400 Bad Request — malformed body.
|
||
|
|
// - 502 Bad Gateway — TLog unavailable (transport failure).
|
||
|
|
// - 500 Internal Server Error — unexpected internal error.
|
||
|
|
package httpapi
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"io"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"basicstack.de/proofedmesh/services/verification-service/internal/chain"
|
||
|
|
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Server wires the chain verifier to the HTTP transport.
|
||
|
|
type Server struct {
|
||
|
|
Verifier *chain.Verifier
|
||
|
|
Logger *slog.Logger
|
||
|
|
MaxBody int64 // 0 → default 1 MiB
|
||
|
|
}
|
||
|
|
|
||
|
|
// Mount registers the endpoints on mux.
|
||
|
|
func (s *Server) Mount(mux *http.ServeMux) {
|
||
|
|
mux.HandleFunc("POST /api/v1/verify/chain", s.handleVerifyChain)
|
||
|
|
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||
|
|
}
|
||
|
|
|
||
|
|
type verifyRequest struct {
|
||
|
|
DecisionID string `json:"decision_id"`
|
||
|
|
ApprovalRef string `json:"approval_ref"`
|
||
|
|
TransformRef string `json:"transform_ref"`
|
||
|
|
PayloadHash string `json:"payload_hash"`
|
||
|
|
// SinkTarget is the canonical field. Historical siblings (dummy-sink's
|
||
|
|
// verify.Client) send `sink_target`; the DEV-612 scope description
|
||
|
|
// used `target_sink`; we accept either on the wire for compatibility
|
||
|
|
// and prefer `sink_target` when both are present.
|
||
|
|
SinkTarget string `json:"sink_target,omitempty"`
|
||
|
|
TargetSink string `json:"target_sink,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleVerifyChain(w http.ResponseWriter, r *http.Request) {
|
||
|
|
logger := s.logger()
|
||
|
|
max := s.MaxBody
|
||
|
|
if max <= 0 {
|
||
|
|
max = 1 << 20
|
||
|
|
}
|
||
|
|
ct := r.Header.Get("Content-Type")
|
||
|
|
if ct != "" && !strings.HasPrefix(ct, "application/json") {
|
||
|
|
writeErr(w, http.StatusBadRequest, "content-type must be application/json")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
raw, err := io.ReadAll(io.LimitReader(r.Body, max+1))
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if int64(len(raw)) > max {
|
||
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "body too large")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var req verifyRequest
|
||
|
|
if err := json.Unmarshal(raw, &req); err != nil {
|
||
|
|
writeErr(w, http.StatusBadRequest, "decode: "+err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
sink := req.SinkTarget
|
||
|
|
if sink == "" {
|
||
|
|
sink = req.TargetSink
|
||
|
|
}
|
||
|
|
|
||
|
|
verdict, err := s.Verifier.Verify(r.Context(), chain.Request{
|
||
|
|
DecisionID: req.DecisionID,
|
||
|
|
ApprovalRef: req.ApprovalRef,
|
||
|
|
TransformRef: req.TransformRef,
|
||
|
|
PayloadHash: req.PayloadHash,
|
||
|
|
SinkTarget: sink,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, tlog.ErrTLogUnavailable) {
|
||
|
|
logger.Warn("tlog unavailable", "err", err)
|
||
|
|
writeErr(w, http.StatusBadGateway, "transparency-log unavailable")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
logger.Error("chain verify failed", "err", err)
|
||
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
writeJSON(w, http.StatusOK, verdict)
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(status)
|
||
|
|
_ = json.NewEncoder(w).Encode(v)
|
||
|
|
}
|
||
|
|
|
||
|
|
type errBody struct {
|
||
|
|
Error string `json:"error"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||
|
|
writeJSON(w, status, errBody{Error: msg})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) logger() *slog.Logger {
|
||
|
|
if s.Logger != nil {
|
||
|
|
return s.Logger
|
||
|
|
}
|
||
|
|
return slog.Default()
|
||
|
|
}
|