// Package httpapi exposes the signing-service's single endpoint: // POST /api/v1/sign/dsse per ADR-0005. package httpapi import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "time" "basicstack.de/proofedmesh/services/signing-service/internal/policy" "basicstack.de/proofedmesh/services/signing-service/internal/registry" "basicstack.de/proofedmesh/services/signing-service/internal/signer" "basicstack.de/proofedmesh/services/signing-service/internal/token" ) // Server composes the per-request pipeline: // // 1. Parse the Pocket-ID JWT from Authorization and resolve `sub`. // 2. If a TokenReviewer is configured, pull X-Workload-Token and resolve // the authenticated (ns, sa). Omitted TokenReviewer disables the K8s // side of the double-identity check — only useful for dev/CI. // 3. Resolve both identities against the agent registry; feed both rows // to the structured policy. If no structured policy is wired, the // legacy subject+keyRef Decider is used instead. // 4. Sign. // // Fields are plumbed through the struct so main.go can decide at startup // which backends to install. type Server struct { Signer signer.Signer Validator token.Validator // TokenReviewer is OPTIONAL. When nil, the double-identity path is // skipped and the policy input carries only the Pocket-ID side (the // legacy Decider path is used instead). TokenReviewer token.TokenReviewer // Registry is OPTIONAL (and only consulted when both TokenReviewer // and StructuredPolicy are present). The structured policy evaluates // the two lookups it receives via Input.Registry. Registry registry.Resolver // StructuredPolicy is OPTIONAL. When present AND Registry/TokenReviewer // are present, the handler uses the full double-identity flow. When // nil, the handler falls back to the subject+keyRef Policy below. StructuredPolicy policy.StructuredDecider // Policy is the legacy subject+keyRef decider (AllowAll in dev, // StructuredAdapter for prod wiring). Policy policy.Decider Logger *slog.Logger Now func() time.Time } func (s Server) Mount(mux *http.ServeMux) { mux.HandleFunc("POST /api/v1/sign/dsse", s.handleSign) mux.HandleFunc("GET /healthz", s.handleHealth) } type signRequest struct { KeyRef string `json:"key_ref"` Statement signer.Statement `json:"statement"` } type signResponse struct { Envelope signer.Envelope `json:"envelope"` ContentHash string `json:"content_hash"` SignedAt time.Time `json:"signed_at"` } type errResponse struct { Error errBody `json:"error"` } type errBody struct { Code string `json:"code"` Message string `json:"message"` } func (s Server) handleHealth(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok"}`)) } func (s Server) handleSign(w http.ResponseWriter, r *http.Request) { ctx := r.Context() bearer, err := token.FromAuthHeader(r) if err != nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "missing or malformed Authorization header") return } subject, err := s.Validator.Validate(ctx, bearer) if err != nil { writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error()) return } var req signRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("decode body: %v", err)) return } if req.KeyRef == "" { writeErr(w, http.StatusBadRequest, "bad_request", "key_ref is required") return } if req.Statement == nil { writeErr(w, http.StatusBadRequest, "bad_request", "statement is required") return } if t, _ := req.Statement["_type"].(string); t != "https://in-toto.io/Statement/v1" { writeErr(w, http.StatusBadRequest, "bad_request", `statement._type must be "https://in-toto.io/Statement/v1"`) return } if pt, _ := req.Statement["predicateType"].(string); pt == "" { writeErr(w, http.StatusBadRequest, "bad_request", "statement.predicateType is required") return } // Decide: structured double-identity path, or legacy subject+keyRef. if s.useStructuredPath() { dec, err := s.decideStructured(ctx, subject, req.KeyRef, r) if err != nil { var statusErr *httpStatusError if errors.As(err, &statusErr) { writeErr(w, statusErr.Status, statusErr.Code, statusErr.Message) return } writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error()) return } if !dec.Allow { writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason) return } } else { dec, err := s.Policy.Allow(ctx, subject, req.KeyRef) if err != nil { writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error()) return } if !dec.Allow { writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason) return } } signed, err := signer.SignStatement(ctx, s.Signer, req.KeyRef, req.Statement) if err != nil { if errors.Is(err, signer.ErrUnknownKey) { writeErr(w, http.StatusBadRequest, "unknown_key", err.Error()) return } if errors.Is(err, signer.ErrNotWired) { writeErr(w, http.StatusServiceUnavailable, "signer_unavailable", err.Error()) return } writeErr(w, http.StatusInternalServerError, "sign_failed", err.Error()) return } if s.Logger != nil { s.Logger.Info("signed", "subject", subject, "key_ref", req.KeyRef, "content_hash", signed.ContentHash, ) } now := time.Now().UTC() if s.Now != nil { now = s.Now() } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(signResponse{ Envelope: signed.Envelope, ContentHash: signed.ContentHash, SignedAt: now, }) } func (s Server) useStructuredPath() bool { return s.StructuredPolicy != nil && s.TokenReviewer != nil && s.Registry != nil } // decideStructured runs the full double-identity flow and hands the two // registry rows + both identities to the structured policy. func (s Server) decideStructured(ctx context.Context, pocketIDSub, keyRef string, r *http.Request) (policy.Decision, error) { workloadToken, err := token.FromWorkloadHeader(r) if err != nil { return policy.Decision{}, &httpStatusError{ Status: http.StatusUnauthorized, Code: "unauthorized", Message: "missing X-Workload-Token header", } } k8sID, err := s.TokenReviewer.Review(ctx, workloadToken) if err != nil { if errors.Is(err, token.ErrWorkloadAuthFailed) || errors.Is(err, token.ErrNoWorkloadToken) { return policy.Decision{}, &httpStatusError{ Status: http.StatusUnauthorized, Code: "unauthorized", Message: fmt.Sprintf("workload token: %v", err), } } return policy.Decision{}, fmt.Errorf("tokenreview: %w", err) } input := policy.Input{ PocketIDClient: pocketIDSub, K8s: policy.InputK8s{ Namespace: k8sID.Namespace, ServiceAccount: k8sID.ServiceAccount, }, KeyRef: keyRef, Registry: policy.InputRegistry{}, } if byP, err := s.Registry.ByPocketID(ctx, pocketIDSub); err == nil { input.Registry.ByPocketID = policy.FromAgent(byP) } else if !errors.Is(err, registry.ErrNotFound) { return policy.Decision{}, fmt.Errorf("registry.ByPocketID: %w", err) } if byK, err := s.Registry.ByK8sIdentity(ctx, k8sID.Namespace, k8sID.ServiceAccount); err == nil { input.Registry.ByK8s = policy.FromAgent(byK) } else if !errors.Is(err, registry.ErrNotFound) { return policy.Decision{}, fmt.Errorf("registry.ByK8sIdentity: %w", err) } return s.StructuredPolicy.Decide(ctx, input) } // httpStatusError lets decideStructured return a 401 (not 500) when the // workload-identity side of the double-check fails. type httpStatusError struct { Status int Code string Message string } func (e *httpStatusError) Error() string { return e.Message } func writeErr(w http.ResponseWriter, status int, code, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(errResponse{Error: errBody{Code: code, Message: message}}) }