Files
at-container-registry/pkg/appview/storage/context.go
T
Evan JarrettandClaude Fable 5.1 9dbc53b670 appview: stop serving service tokens past their expiry, and challenge the client when the hold rejects one
Seen in production on 2026-09-11: three cold pulls of a 22-layer image failed
with BLOB_UNKNOWN for layers that exist. The hold had answered 403 "service
token authentication failed: token has expired", and the same blobs served
fine a minute later.

Three things lined up. The registry middleware's validation cache kept a
fetched service token for a flat 45 seconds regardless of its real remaining
life, so a token fetched with 12 seconds left was still handed to the hold
half a minute after it died. The registry JWT is stamped from the auth cache's
expiry, which trailed the real exp by only 10 seconds, while distribution
accepts a JWT for 60 seconds past its exp, so a client could hold an accepted
JWT for most of a minute after the credential behind it was gone. And the
hold's 403 was flattened to BLOB_UNKNOWN, so the client failed instead of
re-authenticating.

Now the validation cache bounds an entry by the token's exp minus a shared
ServiceTokenSafetyMargin of 60 seconds, the same margin the auth cache and the
JWT stamp use, chosen to equal distribution's leeway so the last instant a JWT
is accepted is the service token's real exp. A PDS that grants less than the
margin gets half its remaining life instead of an already-past deadline. When
the hold rejects the service token as expired or missing, the appview drops
both cached copies and returns a 401 challenge so Docker and crane re-run the
token dance and retry; a genuine permission denial stays a 403, and a hold
that is down still maps to blob unknown.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvFJr4Dwz8p2NDAeXmgmBt
2026-09-11 19:26:51 -05:00

98 lines
4.4 KiB
Go

package storage
import (
"context"
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
)
// PushWebhookDispatcher dispatches webhooks fired after a successful push.
// Today that covers the "push" trigger and the edge-triggered "quota" trigger
// (each user's storage usage is re-evaluated against per-webhook thresholds).
// Defined here (in storage) to avoid import cycles with the webhooks package.
type PushWebhookDispatcher interface {
DispatchForPush(ctx context.Context, event PushWebhookEvent)
DispatchForQuota(ctx context.Context, event QuotaWebhookEvent)
}
// QuotaWebhookEvent contains the data needed to evaluate and dispatch quota
// threshold webhooks for a user's storage on a hold.
type QuotaWebhookEvent struct {
UserDID string
UserHandle string
HoldDID string
HoldEndpoint string
}
// ManifestReferenceChecker checks if a manifest digest is referenced as a child
// of a manifest list (multi-arch image). Used to protect manifest list children
// from auto-removal when their parent list is still tagged.
type ManifestReferenceChecker interface {
IsManifestReferenced(did, digest string) (bool, error)
}
// PushWebhookEvent contains the data needed to dispatch a push webhook.
type PushWebhookEvent struct {
OwnerDID string
OwnerHandle string
PusherDID string
PusherHandle string
Repository string
Tag string
Digest string
MediaType string
HoldDID string
HoldEndpoint string
}
// HoldDIDLookup interface for querying and updating hold DIDs in manifests
type HoldDIDLookup interface {
GetLatestHoldDIDForRepo(did, repository string) (string, error)
UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (int64, error)
GetDistinctManifestHoldDIDs(did string) ([]string, error)
}
// RegistryContext bundles all the context needed for registry operations
// This includes both per-request data (DID, hold) and shared services
type RegistryContext struct {
// Per-request identity and routing information
// Owner = the user whose repository is being accessed
// Puller = the authenticated user making the request (from JWT Subject)
DID string // Owner's DID - whose repo is being accessed (e.g., "did:plc:abc123")
Handle string // Owner's handle (e.g., "alice.bsky.social")
HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io" or "did:plc:abc123")
HoldURL string // Resolved HTTP URL for the hold service
PDSEndpoint string // Owner's PDS endpoint URL
Repository string // Image repository name (e.g., "debian")
ServiceToken string // Service token for hold authentication (from puller's PDS)
ATProtoClient *atproto.Client // Authenticated ATProto client for the owner
AuthMethod string // Auth method used ("oauth" or "app_password")
PullerDID string // Puller's DID - who is making the request (from JWT Subject)
PullerPDSEndpoint string // Puller's PDS endpoint URL
HasPushScope bool // Whether the JWT token has push scope (used to filter pull stats)
Anonymous bool // Request carries no puller identity (anonymous pull); hold decides via captain.Public
// InvalidateServiceToken drops every process-level cache holding the
// puller's service token for this hold. Called when the hold rejects the
// token as expired or invalid: without it the client's retry (prompted by
// the 401 we return) would be handed the very same dead token and fail
// again. Set by the registry middleware, which owns the per-process
// validation cache; nil on requests that never fetched a token (anonymous
// pulls) and in tests.
InvalidateServiceToken func()
// Per-request user preferences
AutoRemoveUntagged bool // Whether to auto-delete untagged manifests on tag overwrite
// Shared services (same for all requests)
Database HoldDIDLookup // Database for hold DID lookups
Authorizer auth.HoldAuthorizer // Hold access authorization
Refresher *oauth.Refresher // OAuth session manager
ReadmeFetcher *readme.Fetcher // README fetcher for repo pages
WebhookDispatcher PushWebhookDispatcher // Push webhook dispatcher (nil if not configured)
ManifestRefChecker ManifestReferenceChecker // Checks if digest is a manifest list child (nil-safe)
}