Manifest delete (DELETE /v2/<name>/manifests/<digest>): rejects tag references with 405 UNSUPPORTED per OCI spec, cascades to tags and manifest_blobs via ON DELETE CASCADE, returns 202 Accepted. Blob delete (DELETE /v2/<name>/blobs/<digest>): removes manifest_blobs associations only — blob row and file are preserved for GC to handle, since other repos may reference the same content-addressed blob. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
124 lines
3.7 KiB
Go
124 lines
3.7 KiB
Go
package oci
|
|
|
|
import (
|
|
"io"
|
|
"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
|
|
}
|
|
|
|
// NewHandler creates a new OCI handler.
|
|
func NewHandler(querier DBQuerier, blobs BlobStore, pol PolicyEval, auditFn AuditFunc) *Handler {
|
|
return &Handler{
|
|
db: querier,
|
|
blobs: blobs,
|
|
policy: pol,
|
|
auditFn: auditFn,
|
|
uploads: newUploadManager(),
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|