mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-10 04:06:06 +00:00
An unauthenticated token request for a repo on a private hold came back with a signed token granting pull, and /v2/ then 401ed that exact token. The authorization server and the resource server disagreed about the same request. The old behaviour was deliberate — "minting a pull-only token is not a grant", with the hold owning the decision via captain.Public — but a token spec expects the server to issue the subset it will authorize, so granting pull and then refusing it is the wrong shape. Adds an optional AnonymousAuthorizer, kept separate from Authorizer because the anonymous path has no DID and no auth method (three of Authorize's four arguments are meaningless) and because it must drop whole entries rather than narrow actions in place, where entries can belong to different owners. Denied entries are dropped; if nothing granting survives, the caller gets the standard 401 challenge rather than a token with an empty access list, so docker prompts for credentials instead of proceeding to a second 401. The scope-less /v2/ ping and the actionless entry NarrowToPullOnly preserves on purpose both bypass the gate entirely — no identity resolution, no hold lookup — since anonymous discovery depends on them. Fails open on any lookup error, matching the /v2/ check, which states the reason: the hold is the enforcing authority and a transient failure must not break anonymous pulls of public images. /v2/ still enforces; this is a correctness and UX fix, not a security fix, and nothing was exposed. Also closes the successor asymmetry documented under finding 3: /v2/ applies a single-hop migration redirect before checking read access, so judging the pre-migration identity here would have reintroduced the disagreement this gate removes. It was one extra local read of hold_captain_records. Tests pin both directions and prove the chain is not followed past one hop. Costs one directory-cached identity resolution plus two local SQL reads per granting entry, and no call to the hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
180 lines
6.7 KiB
Go
180 lines
6.7 KiB
Go
package authgate
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth"
|
|
"atcr.io/pkg/auth/token"
|
|
)
|
|
|
|
// AnonymousAuthorizer decides which repositories of a credential-less pull
|
|
// request /auth/token will actually sign a token for.
|
|
//
|
|
// It answers the same question the registry middleware asks before serving an
|
|
// anonymous request (CheckReadAccess against the owner's hold, which for an
|
|
// identity-less reader reduces to captain.Public), just earlier and from local
|
|
// state. Without it the two layers disagree: /auth/token would sign `pull` on a
|
|
// private-hold repository and /v2/ would refuse that very token.
|
|
//
|
|
// Every lookup is local. hold_captain_records is Jetstream-fed and already read
|
|
// sub-millisecond by the push gate; the one new dependency on this path is
|
|
// handle -> DID resolution, which the identity directory caches for 24h.
|
|
//
|
|
// Every failure mode returns "allow". /v2/ remains the enforcing layer, so
|
|
// failing open costs nothing but keeps a DNS blip or a cold captain cache from
|
|
// refusing tokens for public images.
|
|
type AnonymousAuthorizer struct {
|
|
holdResolver
|
|
|
|
// resolveOwnerDID maps a repository's identity component (a handle or a
|
|
// DID) to a DID. A field rather than a direct call so tests can drive the
|
|
// gate without a live identity directory; production wiring installs the
|
|
// cached atproto.ResolveIdentity in NewAnonymousAuthorizer.
|
|
resolveOwnerDID func(ctx context.Context, identity string) (string, error)
|
|
}
|
|
|
|
var _ token.AnonymousAuthorizer = (*AnonymousAuthorizer)(nil)
|
|
|
|
// NewAnonymousAuthorizer constructs the credential-less pull gate.
|
|
// defaultHoldDID is the AppView's fallback hold, used when the owner's cached
|
|
// sailor profile has not recorded one.
|
|
func NewAnonymousAuthorizer(db *sql.DB, defaultHoldDID string) *AnonymousAuthorizer {
|
|
return &AnonymousAuthorizer{
|
|
holdResolver: holdResolver{db: db, defaultHoldDID: defaultHoldDID},
|
|
resolveOwnerDID: func(ctx context.Context, identity string) (string, error) {
|
|
did, _, _, err := atproto.ResolveIdentity(ctx, identity)
|
|
return did, err
|
|
},
|
|
}
|
|
}
|
|
|
|
// AuthorizeAnonymous satisfies token.AnonymousAuthorizer: it returns the subset
|
|
// of access an anonymous reader may have. Entries are dropped, never widened
|
|
// and never edited, so what survives is exactly what was asked for.
|
|
//
|
|
// A request naming several repositories can name several owners and therefore
|
|
// several holds, so the verdict is per entry: the public ones are kept and the
|
|
// private ones dropped, rather than failing the whole request.
|
|
func (a *AnonymousAuthorizer) AuthorizeAnonymous(ctx context.Context, access []auth.AccessEntry) []auth.AccessEntry {
|
|
kept := make([]auth.AccessEntry, 0, len(access))
|
|
for _, entry := range access {
|
|
if a.allowEntry(ctx, entry) {
|
|
kept = append(kept, entry)
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// allowEntry reports whether one access entry survives the gate.
|
|
func (a *AnonymousAuthorizer) allowEntry(ctx context.Context, entry auth.AccessEntry) bool {
|
|
// Grants nothing, so there is nothing to deny. This is the actionless entry
|
|
// NarrowToPullOnly keeps on purpose; dropping it would break callers that
|
|
// rely on it surviving.
|
|
if len(entry.Actions) == 0 {
|
|
return true
|
|
}
|
|
|
|
// Only a repository scope names an owner whose hold can be checked.
|
|
// Anything else (registry:catalog:*, say) is not hold-gated, so this gate
|
|
// has no opinion and leaves it as it was.
|
|
if entry.Type != "repository" {
|
|
return true
|
|
}
|
|
|
|
identity, _, found := strings.Cut(entry.Name, "/")
|
|
if !found || identity == "" {
|
|
// Malformed name with no owner component. /v2/ answers NAME_INVALID for
|
|
// it; that is not this gate's verdict to pre-empt.
|
|
return true
|
|
}
|
|
|
|
// OCI reference grammar forbids colons in path components, so DIDs arrive
|
|
// hyphen-encoded — the same decode the registry middleware performs.
|
|
if decoded, ok := auth.DecodeDIDFromHyphens(identity); ok {
|
|
identity = decoded
|
|
}
|
|
|
|
ownerDID, err := a.resolveOwnerDID(ctx, identity)
|
|
if err != nil {
|
|
slog.Warn("anonymous gate: identity resolution failed, deferring to /v2/",
|
|
"identity", identity, "repository", entry.Name, "error", err)
|
|
return true
|
|
}
|
|
|
|
holdDID, err := a.resolveHoldDID(ctx, ownerDID)
|
|
if err != nil {
|
|
slog.Warn("anonymous gate: hold resolution failed, deferring to /v2/",
|
|
"did", ownerDID, "repository", entry.Name, "error", err)
|
|
return true
|
|
}
|
|
if holdDID == "" {
|
|
// No hold configured anywhere. /v2/ skips its own check in exactly this
|
|
// case (it requires a non-empty holdDID), so skip it here too.
|
|
return true
|
|
}
|
|
|
|
public, err := a.holdAllowsAnonymousRead(ctx, holdDID)
|
|
if err != nil {
|
|
slog.Warn("anonymous gate: captain lookup failed, deferring to /v2/",
|
|
"hold_did", holdDID, "repository", entry.Name, "error", err)
|
|
return true
|
|
}
|
|
if !public {
|
|
slog.Debug("anonymous gate: dropping scope, hold denies anonymous reads",
|
|
"hold_did", holdDID, "repository", entry.Name)
|
|
}
|
|
return public
|
|
}
|
|
|
|
// holdAllowsAnonymousRead reports whether holdDID admits a reader with no
|
|
// identity. For an empty user DID auth.CheckReadAccessWithCaptain reduces to
|
|
// captain.Public, so that single column is the whole decision.
|
|
//
|
|
// The successor hop matters: /v2/ resolves the hold, then applies a single-hop
|
|
// migration redirect (resolveSuccessor) before checking read access, so it
|
|
// judges the successor's captain record. Skipping that here would check a
|
|
// migrated hold under its old identity and reintroduce the disagreement this
|
|
// gate closes. Both reads hit the local Jetstream-fed table.
|
|
//
|
|
// A missing row is returned as an error, not as "private": an un-ingested hold
|
|
// is an unknown, and the contract on this path is to fail open.
|
|
func (a *AnonymousAuthorizer) holdAllowsAnonymousRead(ctx context.Context, holdDID string) (bool, error) {
|
|
public, successor, err := a.captainRow(ctx, holdDID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if successor == "" {
|
|
return public, nil
|
|
}
|
|
|
|
// Single hop only, matching resolveSuccessor: a successor's own successor
|
|
// is not followed.
|
|
successorPublic, _, err := a.captainRow(ctx, successor)
|
|
if err != nil {
|
|
return false, fmt.Errorf("successor of %s: %w", holdDID, err)
|
|
}
|
|
return successorPublic, nil
|
|
}
|
|
|
|
// captainRow reads the two fields of the cached captain record this gate needs.
|
|
func (a *AnonymousAuthorizer) captainRow(ctx context.Context, holdDID string) (bool, string, error) {
|
|
var public bool
|
|
var successor sql.NullString
|
|
err := a.db.QueryRowContext(ctx,
|
|
"SELECT public, successor FROM hold_captain_records WHERE hold_did = ?", holdDID,
|
|
).Scan(&public, &successor)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, "", fmt.Errorf("no cached captain record for hold %s", holdDID)
|
|
}
|
|
if err != nil {
|
|
return false, "", fmt.Errorf("look up hold captain %s: %w", holdDID, err)
|
|
}
|
|
return public, successor.String, nil
|
|
}
|