Phases 5, 6, 8: OCI pull/push paths and admin REST API
Phase 5 (OCI pull): internal/oci/ package with manifest GET/HEAD by tag/digest, blob GET/HEAD with repo membership check, tag listing with OCI pagination, catalog listing. Multi-segment repo names via parseOCIPath() right-split routing. DB query layer in internal/db/repository.go. Phase 6 (OCI push): blob uploads (monolithic and chunked) with uploadManager tracking in-progress BlobWriters, manifest push implementing full ARCHITECTURE.md §5 flow in a single SQLite transaction (create repo, upsert manifest, populate manifest_blobs, atomic tag move). Digest verification on both blob commit and manifest push-by-digest. Phase 8 (admin REST): /v1 endpoints for auth (login/logout/health), repository management (list/detail/delete), policy CRUD with engine reload, audit log listing with filters, GC trigger/status stubs. RequireAdmin middleware, platform-standard error format. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
119
internal/oci/handler.go
Normal file
119
internal/oci/handler.go
Normal file
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user