Files
at-container-registry/pkg/appview/middleware/registry.go
T
Evan JarrettandClaude Opus 5 5aa13abdc2 auth: make anonymous pull work, and let the hold decide it
a7569a7 added credential-less pulls of public images. Three things about it
were wrong, all of them in how the appview handled the decision that belongs
to the hold.

**Scope handling was all-or-nothing.** IsPullOnlyScope required every
requested action to already be "pull", but clients routinely ask for more
than the operation needs — pull,push is common for a plain read, and some
ask for pull,push,delete up front. Those were rejected and challenged,
leaving a credential-less client no way to pull even a public image, which
is the entire feature. NarrowToPullOnly drops the write actions and issues a
token carrying "pull" and nothing else. Granting a subset is what the
distribution token spec expects. The allowlist property is preserved: "pull"
is the only action that survives, and "*" is deliberately not expanded into
it, since a wildcard request is not evidence the caller wants a read.

**The appview-side read gate was inert.** checkReadAccess passed p.ctx.DID,
the DID of the repository *owner*, not the requester. Any non-empty DID
satisfies a private hold's check, and the owner's is never empty, so it asked
"may the owner read their own hold", answered yes, and admitted everyone.
Worse, CheckReadAccessWithCaptain admitted any authenticated DID to a private
hold at all, on an explicitly-MVP assumption that holding a DID was close
enough to being a sailor. Every doc says otherwise (docs/hold.md:109 "Crew
with blob:read", CLAUDE.md:140, docs/BYOS.md:280) and so does the hold
(ValidateBlobReadAccess: owner, or crew carrying blob:read/blob:write). It
now takes isCrew and requires owner-or-crew, and callers only pay for the
crew lookup when it can change the answer — a public hold or an anonymous
caller is decided by the captain record alone. Nothing here loosens access;
it brings the local gate into agreement with the authority.

**Denials could not reach the client.** distribution's blobHandler.GetBlob
maps everything except ErrBlobUnknown to ErrorCodeUnknown, so a 401 raised
in the blob store left as a 500 — misreporting an auth failure as a server
fault, and giving BearerChallenge no 401 to attach WWW-Authenticate to, so
Docker was told "server error" instead of being prompted for credentials.
Clients that retry 5xx looped: 4.1s per case in the matrix, now 0.01s. The
check moves to Repository(), where an errcode.Error is passed through
verbatim by the registry app — the same mechanism a7569a7 used for
NAME_UNKNOWN. It fails open on a lookup error, since the hold is the
authority and a transient failure should not break public pulls.

Removes auth.allow_anonymous_pull. It could only ever withhold — captain.Public
is what grants — so it was a second flag for a decision the hold already owns,
and gating it appview-side was never the intent. Layer bytes 307 straight to
S3, so the appview is not even in the path whose cost might have justified an
operator-side lever.

Tests: TestAuthMatrix only ever ran against a public hold, and its pull cases
never fetched a layer — crane.Pull is lazy and img.Digest() needs only the
manifest, which ATCR serves from the user's PDS where it is world-readable, so
no pull row in the matrix touched blob authorization at all. Pulls now
materialize layer bytes, and testharness.WithPrivateHold plus
TestAuthMatrixPrivateHold cover public:false + allow_all_crew:true — the
production shape, where anyone with an account pulls and pushes and anonymous
gets nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:43:32 -05:00

771 lines
29 KiB
Go

package middleware
import (
"context"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/distribution/distribution/v3"
"github.com/distribution/distribution/v3/registry/api/errcode"
v2 "github.com/distribution/distribution/v3/registry/api/v2"
registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/reference"
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/auth/token"
)
// authMethodKey is the context key for storing auth method from JWT
const authMethodKey contextKey = "auth.method"
// pullerDIDKey is the context key for storing the authenticated user's DID from JWT
const pullerDIDKey contextKey = "puller.did"
// hasPushScopeKey is the context key for storing whether the JWT has push scope
const hasPushScopeKey contextKey = "token.has_push_scope"
// validationCacheEntry stores a validated service token with expiration
type validationCacheEntry struct {
serviceToken string
validUntil time.Time
err error // Cached error for fast-fail
mu sync.Mutex // Per-entry lock to serialize cache population
inFlight bool // True if another goroutine is fetching the token
done chan struct{} // Closed when fetch completes
}
// validationCache provides request-level caching for service tokens
// This prevents concurrent layer uploads from racing on OAuth/DPoP requests
type validationCache struct {
mu sync.RWMutex
entries map[string]*validationCacheEntry // key: "did:holdDID"
}
// newValidationCache creates a new validation cache
func newValidationCache() *validationCache {
return &validationCache{
entries: make(map[string]*validationCacheEntry),
}
}
// getOrFetch retrieves a service token from cache or fetches it
// Multiple concurrent requests for the same DID:holdDID will share the fetch operation
func (vc *validationCache) getOrFetch(ctx context.Context, cacheKey string, fetchFunc func() (string, error)) (string, error) {
// Fast path: check cache with read lock
vc.mu.RLock()
entry, exists := vc.entries[cacheKey]
vc.mu.RUnlock()
if exists {
// Entry exists, check if it's still valid
entry.mu.Lock()
// If another goroutine is fetching, wait for it
if entry.inFlight {
done := entry.done
entry.mu.Unlock()
select {
case <-done:
// Fetch completed, check result
entry.mu.Lock()
defer entry.mu.Unlock()
if entry.err != nil {
return "", entry.err
}
if time.Now().Before(entry.validUntil) {
return entry.serviceToken, nil
}
// Fall through to refetch
case <-ctx.Done():
return "", ctx.Err()
}
} else {
// Check if cached token is still valid
if entry.err != nil && time.Now().Before(entry.validUntil) {
// Return cached error (fast-fail)
entry.mu.Unlock()
return "", entry.err
}
if entry.err == nil && time.Now().Before(entry.validUntil) {
// Return cached token
token := entry.serviceToken
entry.mu.Unlock()
return token, nil
}
entry.mu.Unlock()
}
}
// Slow path: need to fetch token
vc.mu.Lock()
entry, exists = vc.entries[cacheKey]
if !exists {
// Create new entry
entry = &validationCacheEntry{
inFlight: true,
done: make(chan struct{}),
}
vc.entries[cacheKey] = entry
}
vc.mu.Unlock()
// Lock the entry to perform fetch
entry.mu.Lock()
// Double-check: another goroutine may have fetched while we waited
if !entry.inFlight {
if entry.err != nil && time.Now().Before(entry.validUntil) {
err := entry.err
entry.mu.Unlock()
return "", err
}
if entry.err == nil && time.Now().Before(entry.validUntil) {
token := entry.serviceToken
entry.mu.Unlock()
return token, nil
}
}
// Mark as in-flight and create fresh done channel for this fetch
// IMPORTANT: Always create a new channel - a closed channel is not nil
entry.done = make(chan struct{})
entry.inFlight = true
done := entry.done
entry.mu.Unlock()
// Perform the fetch (outside the lock to allow other operations)
serviceToken, err := fetchFunc()
// Update the entry with result
entry.mu.Lock()
entry.inFlight = false
if err != nil {
// Cache errors for 5 seconds (fast-fail for subsequent requests)
entry.err = err
entry.validUntil = time.Now().Add(5 * time.Second)
entry.serviceToken = ""
} else {
// Cache token for 45 seconds (covers typical Docker push operation)
entry.err = nil
entry.serviceToken = serviceToken
entry.validUntil = time.Now().Add(45 * time.Second)
}
// Signal completion to waiting goroutines
close(done)
entry.mu.Unlock()
return serviceToken, err
}
// LabelChecker checks whether content has been taken down via ATProto labels.
type LabelChecker interface {
IsTakenDown(did, repository string) (bool, error)
}
// Global variables for initialization only
// These are set by main.go during startup and copied into NamespaceResolver instances.
// After initialization, request handling uses the NamespaceResolver's instance fields.
var (
globalRefresher *oauth.Refresher
globalDatabase storage.HoldDIDLookup
globalAuthorizer auth.HoldAuthorizer
globalWebhookDispatcher storage.PushWebhookDispatcher
globalManifestRefChecker storage.ManifestReferenceChecker
globalLabelChecker LabelChecker
)
// SetGlobalRefresher sets the OAuth refresher instance during initialization
// Must be called before the registry starts serving requests
func SetGlobalRefresher(refresher *oauth.Refresher) {
globalRefresher = refresher
}
// SetGlobalDatabase sets the database instance during initialization
// Must be called before the registry starts serving requests
func SetGlobalDatabase(database storage.HoldDIDLookup) {
globalDatabase = database
}
// SetGlobalManifestRefChecker sets the manifest reference checker during initialization
func SetGlobalManifestRefChecker(checker storage.ManifestReferenceChecker) {
globalManifestRefChecker = checker
}
// SetGlobalLabelChecker sets the label checker instance during initialization
func SetGlobalLabelChecker(checker LabelChecker) {
globalLabelChecker = checker
}
// SetGlobalAuthorizer sets the authorizer instance during initialization
// Must be called before the registry starts serving requests
func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) {
globalAuthorizer = authorizer
}
// SetGlobalWebhookDispatcher sets the push webhook dispatcher during initialization
// Must be called before the registry starts serving requests
func SetGlobalWebhookDispatcher(dispatcher storage.PushWebhookDispatcher) {
globalWebhookDispatcher = dispatcher
}
// GetGlobalAuthorizer returns the global authorizer instance
// Used by components that need to clear denial cache (e.g., EnsureCrewMembership)
func GetGlobalAuthorizer() auth.HoldAuthorizer {
return globalAuthorizer
}
func init() {
// Register the name resolution middleware
if err := registrymw.Register("atproto-resolver", initATProtoResolver); err != nil {
panic("failed to register atproto-resolver middleware: " + err.Error())
}
}
// NamespaceResolver wraps a namespace and resolves names
type NamespaceResolver struct {
distribution.Namespace
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
testMode bool // If true, fallback to default hold when user's hold is unreachable
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init)
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
webhookDispatcher storage.PushWebhookDispatcher // Push webhook dispatcher (copied from global on init)
manifestRefChecker storage.ManifestReferenceChecker // Manifest reference checker (copied from global on init)
validationCache *validationCache // Request-level service token cache
readmeFetcher *readme.Fetcher // README fetcher for repo pages
}
// initATProtoResolver initializes the name resolution middleware
func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]any) (distribution.Namespace, error) {
// Get default hold DID from config (required)
// Expected format: "did:web:hold01.atcr.io"
defaultHoldDID := ""
if holdDID, ok := options["default_hold_did"].(string); ok {
defaultHoldDID = holdDID
}
// Get base URL from config (for error messages)
baseURL := ""
if url, ok := options["base_url"].(string); ok {
baseURL = url
}
// Check test mode from options (passed via env var)
testMode := false
if tm, ok := options["test_mode"].(bool); ok {
testMode = tm
}
// Copy shared services from globals into the instance
// This avoids accessing globals during request handling
return &NamespaceResolver{
Namespace: ns,
defaultHoldDID: defaultHoldDID,
baseURL: baseURL,
testMode: testMode,
refresher: globalRefresher,
database: globalDatabase,
authorizer: globalAuthorizer,
webhookDispatcher: globalWebhookDispatcher,
manifestRefChecker: globalManifestRefChecker,
validationCache: newValidationCache(),
readmeFetcher: readme.NewFetcher(),
}, nil
}
// authErrorMessage creates a user-friendly auth error with login URL
func (nr *NamespaceResolver) authErrorMessage(message string) error {
loginURL := fmt.Sprintf("%s/auth/oauth/login", nr.baseURL)
fullMessage := fmt.Sprintf("%s - please re-authenticate at %s", message, loginURL)
return errcode.ErrorCodeUnauthorized.WithMessage(fullMessage)
}
// Repository resolves the repository name and delegates to underlying namespace
// Handles names like:
// - atcr.io/alice/myimage → resolve alice to DID
// - atcr.io/did:plc:xyz123/myimage → use DID directly
func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Named) (distribution.Repository, error) {
// Extract the first part of the name (username or DID)
repoPath := name.Name()
parts := strings.SplitN(repoPath, "/", 2)
if len(parts) < 2 {
// No user specified, use default or return error
return nil, fmt.Errorf("repository name must include user: %s", repoPath)
}
identityStr := parts[0]
imageName := parts[1]
// Support hyphen-encoded DIDs in image paths (e.g., did-plc-abc123/repo:tag)
// OCI reference grammar doesn't allow colons in path components, so DIDs must
// be encoded with hyphens instead: did:plc:abc123 → did-plc-abc123
if decoded, ok := auth.DecodeDIDFromHyphens(identityStr); ok {
identityStr = decoded
}
// Resolve identity to DID, handle, and PDS endpoint.
//
// A bare error here reaches distribution as ErrorCodeUnknown and becomes a
// 500. That is wrong on its own terms — an unresolvable name is a bad request,
// not a server fault — and anonymous pull makes it reachable by any
// unauthenticated caller, so `/v2/<garbage>/x/manifests/y` would answer 500 to
// the internet and send clients that retry 5xx into a retry loop. NAME_UNKNOWN
// is the OCI-correct answer and terminates the client immediately.
did, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, identityStr)
if err != nil {
slog.Debug("Identity resolution failed",
"component", "registry/middleware", "identity", identityStr, "error", err)
return nil, errcode.Error{
Code: v2.ErrorCodeNameUnknown,
Message: fmt.Sprintf("repository name not known to registry: %s", identityStr),
}
}
slog.Debug("Resolved identity", "component", "registry/middleware", "did", did, "pds", pdsEndpoint, "handle", handle)
// Check for takedown labels before proceeding
if globalLabelChecker != nil {
if taken, _ := globalLabelChecker.IsTakenDown(did, imageName); taken {
return nil, errcode.Error{
Code: errcode.ErrorCodeDenied,
Message: "this repository has been removed due to a policy violation",
}
}
}
// Query for hold DID - either user's hold or default hold service
// Also returns the sailor profile so we can read preferences (e.g. AutoRemoveUntagged)
holdDID, sailorProfile := nr.findHoldDIDAndProfile(ctx, did, pdsEndpoint)
if holdDID == "" {
// This is a fatal configuration error - registry cannot function without a hold service
return nil, fmt.Errorf("no hold DID configured: ensure default_hold_did is set in middleware config")
}
// Single-hop hold migration: check if this hold has declared a successor
holdDID = nr.resolveSuccessor(ctx, holdDID)
// Resolve hold DID to HTTP URL via identity directory (cached 24h)
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
if err != nil {
return nil, fmt.Errorf("failed to resolve hold URL for %s: %w", holdDID, err)
}
// Crew reconciliation moved to the auth-phase push gate
// (pkg/appview/authgate). The JWT carries authorization for its short
// lifetime, so /v2/* doesn't need to re-check membership per blob. The
// hold-side requireBlobWriteAccess middleware remains as the eventual
// stopgap if reconciliation hasn't propagated yet.
// Get service token for hold authentication (only if authenticated)
// Use validation cache to prevent concurrent requests from racing on OAuth/DPoP
// Route based on auth method from JWT token
// IMPORTANT: Use PULLER's DID/PDS for service token, not owner's!
// The puller (authenticated user) needs to authenticate to the hold service.
var serviceToken string
authMethod, _ := ctx.Value(authMethodKey).(string)
pullerDID, _ := ctx.Value(pullerDIDKey).(string)
hasPushScope, _ := ctx.Value(hasPushScopeKey).(bool)
var pullerPDSEndpoint string
// Only fetch service token if user is authenticated
// Unauthenticated requests (like /v2/ ping) should not trigger token fetching
if authMethod != "" && pullerDID != "" {
// Resolve puller's PDS endpoint for service token request
_, _, pullerPDSEndpoint, err = atproto.ResolveIdentity(ctx, pullerDID)
if err != nil {
slog.Warn("Failed to resolve puller's PDS, falling back to anonymous access",
"component", "registry/middleware",
"pullerDID", pullerDID,
"error", err)
// Continue without service token - hold will decide if anonymous access is allowed
} else {
// Create cache key: "pullerDID:holdDID"
cacheKey := fmt.Sprintf("%s:%s", pullerDID, holdDID)
// Fetch service token through validation cache
// This ensures only ONE request per pullerDID:holdDID pair fetches the token
// Concurrent requests will wait for the first request to complete
var fetchErr error
serviceToken, fetchErr = nr.validationCache.getOrFetch(ctx, cacheKey, func() (string, error) {
if authMethod == token.AuthMethodAppPassword {
// App-password flow: use Bearer token authentication
slog.Debug("Using app-password flow for service token",
"component", "registry/middleware",
"pullerDID", pullerDID,
"cacheKey", cacheKey)
token, err := auth.GetOrFetchServiceTokenWithAppPassword(ctx, pullerDID, holdDID, pullerPDSEndpoint)
if err != nil {
slog.Error("Failed to get service token with app-password",
"component", "registry/middleware",
"pullerDID", pullerDID,
"holdDID", holdDID,
"pullerPDSEndpoint", pullerPDSEndpoint,
"denial_reason", "service_token_app_password_failed",
"error", err)
return "", err
}
return token, nil
} else if nr.refresher != nil {
// OAuth flow: use DPoP authentication
slog.Debug("Using OAuth flow for service token",
"component", "registry/middleware",
"pullerDID", pullerDID,
"cacheKey", cacheKey)
token, err := auth.GetOrFetchServiceToken(ctx, nr.refresher, pullerDID, holdDID, pullerPDSEndpoint)
if err != nil {
slog.Error("Failed to get service token with OAuth",
"component", "registry/middleware",
"pullerDID", pullerDID,
"holdDID", holdDID,
"pullerPDSEndpoint", pullerPDSEndpoint,
"denial_reason", "service_token_oauth_failed",
"error", err)
return "", err
}
return token, nil
}
return "", fmt.Errorf("no authentication method available")
})
// Handle errors from cached fetch
if fetchErr != nil {
errMsg := fetchErr.Error()
// Check for app-password specific errors
if authMethod == token.AuthMethodAppPassword {
if strings.Contains(errMsg, "expired or invalid") || strings.Contains(errMsg, "no app-password") {
return nil, nr.authErrorMessage("App-password authentication failed. Please re-authenticate with: docker login")
}
}
// Check for OAuth specific errors
if strings.Contains(errMsg, "OAuth session") || strings.Contains(errMsg, "OAuth validation") {
return nil, nr.authErrorMessage("OAuth session expired or invalidated by PDS. Your session has been cleared")
}
// Generic service token error
return nil, nr.authErrorMessage(fmt.Sprintf("Failed to obtain storage credentials: %v", fetchErr))
}
}
} else {
slog.Debug("Skipping service token fetch for unauthenticated request",
"component", "registry/middleware",
"ownerDID", did)
}
// Create a new reference with identity/image format
// Use the resolved handle (not raw DID) to ensure the name is valid per OCI reference grammar.
// DIDs contain colons which are illegal in reference path components.
// This transforms: did-plc-abc123/myimage -> alice.bsky.social/myimage
canonicalName := fmt.Sprintf("%s/%s", handle, imageName)
ref, err := reference.ParseNamed(canonicalName)
if err != nil {
return nil, fmt.Errorf("invalid image name %s: %w", imageName, err)
}
// Delegate to underlying namespace with modified name
repo, err := nr.Namespace.Repository(ctx, ref)
if err != nil {
return nil, err
}
// Create ATProto client for manifest/tag operations
// Pulls: ATProto records are public, no auth needed
// Pushes: Need auth, but puller must be owner anyway
var atprotoClient *atproto.Client
if pullerDID == did {
// Puller is owner - may need auth for pushes
if authMethod == token.AuthMethodOAuth && nr.refresher != nil {
atprotoClient = atproto.NewClientWithSessionProvider(pdsEndpoint, did, nr.refresher)
} else if authMethod == token.AuthMethodAppPassword {
accessToken, _ := auth.GetGlobalTokenCache().Get(did)
atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken)
} else {
atprotoClient = atproto.NewClient(pdsEndpoint, did, "")
}
} else {
// Puller != owner - reads only, no auth needed
atprotoClient = atproto.NewClient(pdsEndpoint, did, "")
}
// IMPORTANT: Use only the image name (not identity/image) for ATProto storage
// ATProto records are scoped to the user's DID, so we don't need the identity prefix
// Example: "evan.jarrett.net/debian" -> store as "debian"
repositoryName := imageName
// Default auth method to OAuth if not already set (backward compatibility with old tokens)
if authMethod == "" {
authMethod = token.AuthMethodOAuth
}
// Anonymous request to a private hold: refuse here rather than deeper in
// the stack.
//
// captain.Public is the only thing that admits a reader with no identity.
// The hold enforces that too, but a denial raised from the blob store
// cannot reach the client intact: distribution's blobHandler.GetBlob maps
// everything except ErrBlobUnknown to ErrorCodeUnknown, so a 401 leaves
// here as a 500 — misreporting an auth failure as a server fault and
// sending clients that retry 5xx into a loop. An errcode.Error returned
// from Repository() is passed through verbatim by the registry app, so the
// client gets a real 401 and BearerChallenge can attach WWW-Authenticate,
// which is what makes Docker prompt for credentials.
//
// Fail open on a lookup error: the hold is the enforcing authority, and a
// transient failure here should not break anonymous pulls of public
// images.
if pullerDID == "" && nr.authorizer != nil && holdDID != "" {
allowed, authErr := nr.authorizer.CheckReadAccess(ctx, holdDID, "")
if authErr != nil {
slog.Warn("Anonymous read check failed, deferring to hold",
"holdDID", holdDID, "repository", repositoryName, "error", authErr)
} else if !allowed {
slog.Debug("Anonymous read denied: hold is not public",
"holdDID", holdDID, "repository", repositoryName)
return nil, errcode.ErrorCodeUnauthorized.WithMessage("authentication required")
}
}
// Create routing repository - routes manifests to ATProto, blobs to hold service
// The registry is stateless - no local storage is used
// Bundle all context into a single RegistryContext struct
//
// NOTE: We create a fresh RoutingRepository on every request (no caching) because:
// 1. Each layer upload is a separate HTTP request (possibly different process)
// 2. OAuth sessions can be refreshed/invalidated between requests
// 3. The refresher already caches sessions efficiently (in-memory + DB)
// 4. Caching the repository with a stale ATProtoClient causes refresh token errors
registryCtx := &storage.RegistryContext{
DID: did,
Handle: handle,
HoldDID: holdDID,
HoldURL: holdURL,
PDSEndpoint: pdsEndpoint,
Repository: repositoryName,
ServiceToken: serviceToken, // Cached service token from puller's PDS
ATProtoClient: atprotoClient,
AuthMethod: authMethod, // Auth method from JWT token
PullerDID: pullerDID, // Authenticated user making the request
PullerPDSEndpoint: pullerPDSEndpoint, // Puller's PDS for service token refresh
HasPushScope: hasPushScope, // Whether JWT has push scope (for pull stats filtering)
Anonymous: pullerDID == "", // No puller identity: hold decides via captain.Public
AutoRemoveUntagged: sailorProfile != nil && sailorProfile.AutoRemoveUntagged,
Database: nr.database,
Authorizer: nr.authorizer,
Refresher: nr.refresher,
ReadmeFetcher: nr.readmeFetcher,
WebhookDispatcher: nr.webhookDispatcher,
ManifestRefChecker: nr.manifestRefChecker,
}
return storage.NewRoutingRepository(repo, registryCtx), nil
}
// Repositories delegates to underlying namespace
func (nr *NamespaceResolver) Repositories(ctx context.Context, repos []string, last string) (int, error) {
return nr.Namespace.Repositories(ctx, repos, last)
}
// Blobs delegates to underlying namespace
func (nr *NamespaceResolver) Blobs() distribution.BlobEnumerator {
return nr.Namespace.Blobs()
}
// BlobStatter delegates to underlying namespace
func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter {
return nr.Namespace.BlobStatter()
}
// findHoldDIDAndProfile determines which hold DID to use for blob storage and
// returns the user's sailor profile (if available) for reading preferences like
// AutoRemoveUntagged without an extra PDS call.
// Priority order:
// 1. User's sailor profile defaultHold (if set)
// 2. AppView's default hold DID
// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured
func (nr *NamespaceResolver) findHoldDIDAndProfile(ctx context.Context, did, pdsEndpoint string) (string, *atproto.SailorProfileRecord) {
// Create ATProto client (without auth - reading public records)
client := atproto.NewClient(pdsEndpoint, did, "")
// Check for sailor profile
profile, err := storage.GetProfile(ctx, client)
if err != nil {
// Error reading profile (not a 404) - log and continue
slog.Warn("Failed to read profile", "did", did, "error", err)
}
if profile != nil && profile.DefaultHold != "" {
// Profile exists with defaultHold set
// In test mode, verify it's reachable before using it
if nr.testMode {
if nr.isHoldReachable(ctx, profile.DefaultHold) {
return profile.DefaultHold, profile
}
slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", profile.DefaultHold)
return nr.defaultHoldDID, profile
}
return profile.DefaultHold, profile
}
// No profile defaultHold - use AppView default
return nr.defaultHoldDID, profile
}
// resolveSuccessor checks if a hold has declared a successor and returns it.
// Single-hop only — does not follow chains. Returns the original holdDID if
// no successor is set or if the captain record can't be fetched.
func (nr *NamespaceResolver) resolveSuccessor(ctx context.Context, holdDID string) string {
if nr.authorizer == nil {
return holdDID
}
captain, err := nr.authorizer.GetCaptainRecord(ctx, holdDID)
if err != nil {
return holdDID
}
if captain != nil && captain.Successor != "" {
slog.Info("Hold successor redirect",
"component", "registry/middleware",
"from", holdDID,
"to", captain.Successor)
return captain.Successor
}
return holdDID
}
// isHoldReachable checks if a hold service is reachable
// Used in test mode to fallback to default hold when user's hold is unavailable
func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string) bool {
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
if err != nil {
slog.Debug("Cannot resolve hold URL for reachability check", "component", "registry/middleware", "holdDID", holdDID, "error", err)
return false
}
testURL := holdURL + "/.well-known/did.json"
client := atproto.NewClient("", "", "")
_, err = client.FetchDIDDocument(ctx, testURL)
return err == nil
}
// ExtractAuthMethod is an HTTP middleware that extracts the auth method and puller DID from the JWT Authorization header
// and stores them in the request context for later use by the registry middleware.
// Also stores the HTTP method for routing decisions (GET/HEAD = pull, PUT/POST = push).
func ExtractAuthMethod(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Store HTTP method in context for routing decisions
// This is used by routing_repository.go to distinguish pull (GET/HEAD) from push (PUT/POST)
ctx = context.WithValue(ctx, storage.HTTPRequestMethod, r.Method)
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
// Parse "Bearer <token>" format
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
tokenString := parts[1]
// Extract auth method from JWT (does not validate - just parses)
authMethod := token.ExtractAuthMethod(tokenString)
if authMethod != "" {
// Store in context for registry middleware
ctx = context.WithValue(ctx, authMethodKey, authMethod)
}
// Extract puller DID (Subject) from JWT
// This is the authenticated user's DID, used for service token requests
pullerDID := token.ExtractSubject(tokenString)
if pullerDID != "" {
ctx = context.WithValue(ctx, pullerDIDKey, pullerDID)
}
// Extract access scopes from JWT to detect push-scoped tokens
// Used to distinguish real pulls from manifest GETs during push/imagetools flows
access := token.ExtractAccess(tokenString)
if token.HasPushScope(access) {
ctx = context.WithValue(ctx, hasPushScopeKey, true)
}
slog.Debug("Extracted auth info from JWT",
"component", "registry/middleware",
"authMethod", authMethod,
"pullerDID", pullerDID,
"hasPushScope", token.HasPushScope(access),
"httpMethod", r.Method)
}
}
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
// retryAfterResponseWriter wraps http.ResponseWriter and, on the first
// WriteHeader call, injects a Retry-After header if the status is 429 and
// a retry-after duration was recorded in the request context.
type retryAfterResponseWriter struct {
http.ResponseWriter
carrier *storage.RetryAfterCarrier
wroteHeader bool
}
func (w *retryAfterResponseWriter) WriteHeader(code int) {
if !w.wroteHeader {
w.wroteHeader = true
if code == http.StatusTooManyRequests {
if d := w.carrier.Duration(); d > 0 {
// Round up to whole seconds; minimum of 1 to avoid 0-second hints.
secs := int64(d.Seconds())
if d%time.Second != 0 {
secs++
}
if secs < 1 {
secs = 1
}
w.Header().Set("Retry-After", fmt.Sprintf("%d", secs))
}
}
}
w.ResponseWriter.WriteHeader(code)
}
func (w *retryAfterResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
// Implicit 200 — still fire WriteHeader so flag flips.
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
// RetryAfterMiddleware installs a per-request RetryAfterCarrier in the
// request context and wraps the response writer so deeper handlers (e.g.,
// the manifest store, when an upstream PDS returns 429) can cause a
// Retry-After header to be emitted on 429 responses.
func RetryAfterMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
carrier := storage.NewRetryAfterCarrier()
ctx := context.WithValue(r.Context(), storage.RetryAfterContextKey, carrier)
wrapped := &retryAfterResponseWriter{ResponseWriter: w, carrier: carrier}
next.ServeHTTP(wrapped, r.WithContext(ctx))
})
}