mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
a7569a7added 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 mechanisma7569a7used 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>
138 lines
4.9 KiB
Go
138 lines
4.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
)
|
|
|
|
// HoldAuthorizer checks if a DID has read/write access to a hold
|
|
// Implementations can query local PDS (hold service) or remote XRPC (appview)
|
|
type HoldAuthorizer interface {
|
|
// CheckReadAccess checks if userDID can read from holdDID
|
|
// Returns: (allowed bool, error)
|
|
CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error)
|
|
|
|
// CheckWriteAccess checks if userDID can write to holdDID
|
|
// Returns: (allowed bool, error)
|
|
CheckWriteAccess(ctx context.Context, holdDID, userDID string) (bool, error)
|
|
|
|
// GetCaptainRecord retrieves the captain record for a hold
|
|
// Used to check public flag and allowAllCrew settings
|
|
GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error)
|
|
|
|
// IsCrewMember checks if userDID is a crew member of holdDID
|
|
IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error)
|
|
|
|
// ClearCrewDenial removes any cached denial for a user/hold pair
|
|
// Called when user successfully becomes a crew member to ensure immediate access
|
|
// Returns nil if no denial cache exists or invalidation succeeds
|
|
ClearCrewDenial(ctx context.Context, holdDID, userDID string) error
|
|
|
|
// IsCachedCrewMember returns true only if there is a non-expired approval
|
|
// in the cache. It MUST NOT make any network calls. Cache miss returns (false, nil).
|
|
IsCachedCrewMember(ctx context.Context, holdDID, userDID string) (bool, error)
|
|
|
|
// RecordCrewApproval writes an approval to the cache with the implementation's
|
|
// standard TTL. Used to warm the cache after an out-of-band confirmation of crew
|
|
// membership (e.g. a successful requestCrew POST). No-op for implementations
|
|
// without a cache.
|
|
RecordCrewApproval(ctx context.Context, holdDID, userDID string) error
|
|
}
|
|
|
|
// CheckReadAccessWithCaptain implements the standard read authorization logic
|
|
// This is shared across all HoldAuthorizer implementations
|
|
// Read access rules:
|
|
// - Public hold: allow anyone (even anonymous)
|
|
// - Private hold: hold owner or crew member only
|
|
//
|
|
// The two settings on a captain record are orthogonal and this is the read
|
|
// half: public decides who may pull (anyone, or crew only), while
|
|
// allowAllCrew decides who may become crew and therefore who may push. An
|
|
// anonymous reader has no identity to be crew with, so public is the only
|
|
// thing that can admit one.
|
|
//
|
|
// This previously admitted any authenticated DID to a private hold, on an
|
|
// explicitly-MVP assumption that holding a DID was close enough to being a
|
|
// sailor. It is not: "private" means crew-only, and every authenticated user
|
|
// on the network has a DID. The hold has always enforced the correct rule
|
|
// (ValidateBlobReadAccess: owner, or crew carrying blob:read/blob:write), so
|
|
// this brings the appview's local gate into agreement with the authority
|
|
// rather than loosening anything.
|
|
func CheckReadAccessWithCaptain(captain *atproto.CaptainRecord, userDID string, isCrew bool) bool {
|
|
if captain.Public {
|
|
// Public hold - allow anyone (even anonymous)
|
|
return true
|
|
}
|
|
|
|
// Private hold - require authentication
|
|
if userDID == "" {
|
|
// Anonymous user trying to access private hold
|
|
slog.Debug("Read access denied",
|
|
"denial_reason", "anonymous_on_private_hold",
|
|
"message", "anonymous reads require a public hold")
|
|
return false
|
|
}
|
|
|
|
// Owner always has read access to their own hold
|
|
if userDID == captain.Owner {
|
|
return true
|
|
}
|
|
|
|
if !isCrew {
|
|
slog.Debug("Read access denied",
|
|
"userDID", userDID,
|
|
"owner", captain.Owner,
|
|
"denial_reason", "not_owner_or_crew",
|
|
"message", "private hold reads require crew membership")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// CheckWriteAccessWithCaptain implements the standard write authorization logic
|
|
// This is shared across all HoldAuthorizer implementations
|
|
// Write access rules:
|
|
// - Must be authenticated
|
|
// - Must be hold owner OR crew member
|
|
func CheckWriteAccessWithCaptain(captain *atproto.CaptainRecord, userDID string, isCrew bool) bool {
|
|
slog.Debug("Checking write access", "userDID", userDID, "owner", captain.Owner, "isCrew", isCrew)
|
|
|
|
if userDID == "" {
|
|
// Anonymous writes not allowed
|
|
slog.Debug("Write access denied",
|
|
"userDID", userDID,
|
|
"denial_reason", "anonymous_user",
|
|
"message", "anonymous writes not allowed")
|
|
return false
|
|
}
|
|
|
|
// Check if DID is the hold owner
|
|
if userDID == captain.Owner {
|
|
// Owner always has write access
|
|
slog.Debug("Write access allowed: user is hold owner")
|
|
return true
|
|
}
|
|
|
|
// Check if DID is a crew member
|
|
if isCrew {
|
|
slog.Debug("Write access allowed: user is crew member")
|
|
} else {
|
|
slog.Debug("Write access denied",
|
|
"userDID", userDID,
|
|
"owner", captain.Owner,
|
|
"denial_reason", "not_owner_or_crew",
|
|
"message", "user is not owner or crew member")
|
|
}
|
|
return isCrew
|
|
}
|
|
|
|
// ErrHoldNotFound is returned when a hold's captain record cannot be found
|
|
var ErrHoldNotFound = fmt.Errorf("hold not found")
|
|
|
|
// ErrUnauthorized is returned when access is denied
|
|
var ErrUnauthorized = fmt.Errorf("unauthorized")
|