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 }