added signed artifacts and SBOM generation capabilities

This commit is contained in:
2026-05-12 21:31:43 +02:00
parent ab94775162
commit 822f723ff1
16 changed files with 1615 additions and 12 deletions
+101 -8
View File
@@ -1,29 +1,33 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/domain/signing"
"github.com/forgeo/forgebucket/internal/models"
)
type ArtifactHandler struct {
db *xorm.Engine
artifactRoot string
keys *signing.KeyStore
}
func NewArtifactHandler(db *xorm.Engine, artifactRoot string) *ArtifactHandler {
return &ArtifactHandler{db: db, artifactRoot: artifactRoot}
func NewArtifactHandler(db *xorm.Engine, artifactRoot string, keys *signing.KeyStore) *ArtifactHandler {
return &ArtifactHandler{db: db, artifactRoot: artifactRoot, keys: keys}
}
// ListArtifacts returns all artifacts for a pipeline run.
// List returns all artifacts for a pipeline run.
func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
repoID, runID, ok := h.resolveRunIDs(w, r)
if !ok {
@@ -40,8 +44,8 @@ func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
jsonOK(w, artifacts)
}
// Upload accepts a multipart file upload and stores it as an artifact.
// Callers must provide a valid Bearer access token with write scope (runner auth).
// Upload accepts a multipart file upload, stores it as an artifact, and
// immediately signs it using the server's signing key.
func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
repoID, runID, ok := h.resolveRunIDs(w, r)
if !ok {
@@ -87,8 +91,13 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
}
defer dst.Close()
size, err := io.Copy(dst, file)
// Read into memory so we can both write to disk and sign.
content, err := io.ReadAll(file)
if err != nil {
jsonError(w, "could not read upload", http.StatusInternalServerError)
return
}
if _, err := dst.Write(content); err != nil {
jsonError(w, "could not write file", http.StatusInternalServerError)
return
}
@@ -106,7 +115,7 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
RepoID: repoID,
Name: name,
StoragePath: relPath,
Size: size,
Size: int64(len(content)),
ContentType: ct,
}
if _, err := h.db.Insert(artifact); err != nil {
@@ -114,10 +123,38 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
return
}
// Sign the artifact and persist the bundle.
go h.signArtifact(artifact.ID, name, content)
w.WriteHeader(http.StatusCreated)
jsonOK(w, artifact)
}
// signArtifact is called in a goroutine after a successful upload.
func (h *ArtifactHandler) signArtifact(artifactID int64, name string, content []byte) {
bundle, err := h.keys.Sign(artifactID, name, content)
if err != nil {
fmt.Printf("signing: failed to sign artifact %d: %v\n", artifactID, err)
return
}
bundleJSON, err := json.Marshal(bundle)
if err != nil {
fmt.Printf("signing: failed to marshal bundle for artifact %d: %v\n", artifactID, err)
return
}
sig := &models.ArtifactSignature{
ArtifactID: artifactID,
KeyID: bundle.KeyID,
Algorithm: "ecdsa-p256-sha256",
Digest: bundle.Payload.Digest,
BundleJSON: string(bundleJSON),
SignedAt: time.Now().UTC(),
}
if _, err := h.db.Insert(sig); err != nil {
fmt.Printf("signing: failed to store signature for artifact %d: %v\n", artifactID, err)
}
}
// Download streams the artifact file to the client.
func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
@@ -132,7 +169,6 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
}
fullPath := filepath.Join(h.artifactRoot, filepath.FromSlash(artifact.StoragePath))
// Ensure the resolved path stays within artifactRoot (traversal guard).
if !isUnder(h.artifactRoot, fullPath) {
jsonError(w, "forbidden", http.StatusForbidden)
return
@@ -155,6 +191,63 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
io.Copy(w, f) //nolint:errcheck
}
// GetSignature returns the full signature bundle JSON for an artifact.
// GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/signature
func (h *ArtifactHandler) GetSignature(w http.ResponseWriter, r *http.Request) {
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
if err != nil {
jsonError(w, "invalid artifact ID", http.StatusBadRequest)
return
}
var sig models.ArtifactSignature
found, err := h.db.Where("artifact_id = ?", artifactID).Get(&sig)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if !found {
jsonError(w, "signature not found — artifact may still be pending signing", http.StatusNotFound)
return
}
// Return the raw bundle JSON so clients can verify independently.
w.Header().Set("Content-Type", "application/vnd.forgebucket.signature.bundle+json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(sig.BundleJSON)) //nolint:errcheck
}
// VerifySignature verifies the stored signature bundle for an artifact.
// GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/verify
func (h *ArtifactHandler) VerifySignature(w http.ResponseWriter, r *http.Request) {
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
if err != nil {
jsonError(w, "invalid artifact ID", http.StatusBadRequest)
return
}
var sig models.ArtifactSignature
found, err := h.db.Where("artifact_id = ?", artifactID).Get(&sig)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if !found {
jsonError(w, "signature not found", http.StatusNotFound)
return
}
result, err := h.keys.Verify([]byte(sig.BundleJSON))
if err != nil {
jsonError(w, fmt.Sprintf("verification error: %v", err), http.StatusUnprocessableEntity)
return
}
jsonOK(w, result)
}
// ─── helpers ─────────────────────────────────────────────────────────────────
func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) {
rID, ok := resolveRepoID(h.db, w, r)
if !ok {