Files
mcr/internal/oci/handler.go
Kyle Isom ef39152f4e Add structured error logging to OCI handlers
Every 500 response in the OCI package silently discarded the actual
error, making production debugging impossible. Add slog.Error before
each 500 response with the error and relevant context (repo, digest,
tag, uuid). Add slog.Info for state-mutating successes (manifest push,
blob upload complete, deletions).

Logger is injected into the OCI Handler via constructor, falling back
to slog.Default() if nil.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:47:44 -07:00

130 lines
3.9 KiB
Go

package oci
import (
"io"
"log/slog"
"net/http"
"strings"
"git.wntrmute.dev/kyle/mcr/internal/auth"
"git.wntrmute.dev/kyle/mcr/internal/db"
"git.wntrmute.dev/kyle/mcr/internal/policy"
"git.wntrmute.dev/kyle/mcr/internal/storage"
)
// DBQuerier provides the database operations needed by OCI handlers.
type DBQuerier interface {
GetRepositoryByName(name string) (int64, error)
GetManifestByTag(repoID int64, tag string) (*db.ManifestRow, error)
GetManifestByDigest(repoID int64, digest string) (*db.ManifestRow, error)
BlobExistsInRepo(repoID int64, digest string) (bool, error)
ListTags(repoID int64, after string, limit int) ([]string, error)
ListRepositoryNames(after string, limit int) ([]string, error)
// Push operations
GetOrCreateRepository(name string) (int64, error)
BlobExists(digest string) (bool, error)
InsertBlob(digest string, size int64) error
PushManifest(p db.PushManifestParams) error
// Upload operations
CreateUpload(uuid string, repoID int64) error
GetUpload(uuid string) (*db.UploadRow, error)
UpdateUploadOffset(uuid string, offset int64) error
DeleteUpload(uuid string) error
// Delete operations
DeleteManifest(repoID int64, digest string) error
DeleteBlobFromRepo(repoID int64, digest string) error
}
// BlobStore provides read and write access to blob storage.
type BlobStore interface {
Open(digest string) (io.ReadCloser, error)
Stat(digest string) (int64, error)
StartUpload(uuid string) (*storage.BlobWriter, error)
}
// PolicyEval evaluates access control policies.
type PolicyEval interface {
Evaluate(input policy.PolicyInput) (policy.Effect, *policy.Rule)
}
// AuditFunc records audit events. Follows the same signature pattern as
// db.WriteAuditEvent but without an error return — audit failures should
// not block request processing.
type AuditFunc func(eventType, actorID, repository, digest, ip string, details map[string]string)
// Handler serves OCI Distribution Spec endpoints.
type Handler struct {
db DBQuerier
blobs BlobStore
policy PolicyEval
auditFn AuditFunc
uploads *uploadManager
log *slog.Logger
}
// NewHandler creates a new OCI handler. If logger is nil, slog.Default() is used.
func NewHandler(querier DBQuerier, blobs BlobStore, pol PolicyEval, auditFn AuditFunc, logger *slog.Logger) *Handler {
if logger == nil {
logger = slog.Default()
}
return &Handler{
db: querier,
blobs: blobs,
policy: pol,
auditFn: auditFn,
uploads: newUploadManager(),
log: logger.With("component", "oci"),
}
}
// isDigest returns true if the reference looks like a digest (sha256:...).
func isDigest(ref string) bool {
return strings.HasPrefix(ref, "sha256:")
}
// checkPolicy evaluates the policy for the given action and repository.
// Returns true if access is allowed, false if denied (and writes the OCI error).
func (h *Handler) checkPolicy(w http.ResponseWriter, r *http.Request, action policy.Action, repo string) bool {
claims := auth.ClaimsFromContext(r.Context())
if claims == nil {
writeOCIError(w, "UNAUTHORIZED", http.StatusUnauthorized, "authentication required")
return false
}
input := policy.PolicyInput{
Subject: claims.Subject,
AccountType: claims.AccountType,
Roles: claims.Roles,
Action: action,
Repository: repo,
}
effect, _ := h.policy.Evaluate(input)
if effect == policy.Deny {
if h.auditFn != nil {
h.auditFn("policy_deny", claims.Subject, repo, "", r.RemoteAddr, map[string]string{
"action": string(action),
})
}
writeOCIError(w, "DENIED", http.StatusForbidden, "access denied by policy")
return false
}
return true
}
// audit records an audit event if an audit function is configured.
func (h *Handler) audit(r *http.Request, eventType, repo, digest string) {
if h.auditFn == nil {
return
}
claims := auth.ClaimsFromContext(r.Context())
actorID := ""
if claims != nil {
actorID = claims.Subject
}
h.auditFn(eventType, actorID, repo, digest, r.RemoteAddr, nil)
}