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>
130 lines
3.9 KiB
Go
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)
|
|
}
|