84 Commits
Author SHA1 Message Date
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
Evan JarrettandClaude Opus 5 2580dcdb0f appview: stop a tag delete cascading into another repo's live image
io.atcr.manifest rkeys are the digest alone (digestToRKey), so a single
record backs every repository of a user holding identical content. Both
paths that cascade-delete that record checked for remaining tags scoped to
one repository, which asks the wrong question: a tag in another repo keeps
the shared record alive just as much as a tag in this one.

With me/a:v1 and me/b:v1 at the same digest, deleting me/a:v1 saw no
remaining tags in repo a, deleted the shared PDS record, and purged the
layers on the hold. me/b:v1 was left pointing at content that no longer
exists, and the firehose delete handler then cleared the rows for every
repo (DeleteManifest with an empty repository argument).

The collision predates this, but it was reachable only behind the opt-in
AutoRemoveUntagged profile flag. 1b91768 enabled OCI manifest DELETE and
made TagStore.Untag cascade unconditionally, which turned a latent metadata
collision into blob-level data loss on an ordinary skopeo/crane delete.

  - cleanupUntaggedManifest no longer filters candidate tags to
    rctx.Repository. The surrounding comment already noted that the tag
    collection is account-wide; the filter contradicted it.
  - ShouldCascadeDeleteManifest takes the tag question DID-wide via a new
    IsManifestTaggedAnyRepo, and drops its now-meaningless repository
    parameter.
  - IsManifestTagged stays repository-scoped and keeps its caller: the
    delete-manifest confirmation prompt is genuinely asking about the one
    repo whose tags the user is about to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:22:08 -05:00
Evan JarrettandClaude Opus 5 a7569a7717 registry: allow anonymous pull of public images
Credential-less pulls of public images. /auth/token issues a pull-only
token with an empty subject when no Basic auth is present; the
destination hold still enforces captain.Public, and push or delete always
challenges.

  - token.IsPullOnlyScope and AuthMethodAnonymous;
    Handler.issueAnonymousToken skips the authorizer gate and the
    service-auth pre-mint, since there is no identity to reconcile and no
    AppView-to-hold service token to bind. The token is still stamped
    with the resolved registry domain, so anonymous pull works on
    secondary front doors whose access controller demands their own
    audience.
  - auth.allow_anonymous_pull (default true) turns it fully off, restoring
    the previous always-challenge behavior. Mirrored into the deploy
    template, since the default means existing deploys pick this up.
  - RegistryContext.Anonymous is plumbed from the middleware.
  - ProxyBlobStore sends no Authorization header when the service token is
    empty, and returns 401 rather than 403 for anonymous denials so Docker
    prompts for credentials, including when a stale captain cache lets the
    request through and the hold says private.
  - BearerChallenge wraps the /v2/ subtree so a 401 raised deep in the
    stack via errcode.ServeJSON still carries WWW-Authenticate.
    Distribution's own scoped challenges are left alone.

IsPullOnlyScope allowlists the pull action instead of denylisting push and
delete. Distribution's actionSet.contains treats "*" as *every* action, so
a scope of `repository:victim/img:*` names neither denied string and would
have handed an unauthenticated caller a token valid for push and delete on
someone else's repository — clearing the authgate entirely, since anonymous
tokens deliberately skip it. Writes would still have failed further down
(no PDS credential), but the gate itself was bypassable. Now every
requested action must be exactly "pull". Covered by new claims tests.

Unresolvable identities return NAME_UNKNOWN instead of a bare error that
distribution renders as 500. This path was previously unreachable without
credentials; anonymous pull opens it to the internet, and a 5xx on
arbitrary input both misreports a bad request as a server fault and sends
clients that retry 5xx into a retry loop. That loop was real: in the auth
matrix, regclient spent 83s on a single case before this fix, and the
suite now runs in 5s.

Stat preserves an authorization verdict from getPresignedURL rather than
flattening it to ErrBlobUnknown. Distribution calls Stat before ServeBlob
on GET and HEAD, so without this an anonymous pull from a private hold
answered 404 and BearerChallenge had no 401 to annotate — the 401 path
above could never actually reach a client.

The auth matrix is updated to match: anonymous pull of the seeded public
repo now succeeds, anonymous push is denied against a real identity's
namespace (rather than an unresolvable one, which was testing name
resolution rather than authorization), and a new case pins the
NAME_UNKNOWN behavior for an unknown identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:14:58 -05:00
Evan JarrettandClaude Opus 5 1b917686b2 appview: support OCI manifest DELETE
DELETE /v2/<name>/manifests/<ref> answered UNSUPPORTED before ever
reaching the ATProto-backed stores: distribution v3.1.1's DeleteManifest
handler short-circuits unless app.deleteEnabled is set, which comes from
storage.delete.enabled. Set it (mirrored in the test harness). The
companion storage.EnableDelete option it appends only affects
distribution's built-in store, which RoutingRepository replaces, so it is
a no-op for us.

With the route reachable, make the stores do the right thing:

  - ManifestStore.Delete purges the hold's per-layer, scan and image
    config records on a detached context, since the DELETE handler
    returns immediately and cancels the request context.
  - TagStore.Untag resolves the digest before deleting the tag record,
    then deletes the manifest if that was its last tag and it is not a
    manifest list child, matching the web UI's delete-tag behavior so
    deleting an only-tagged image doesn't orphan the manifest.
  - cleanupUntaggedManifest becomes package-level over *RegistryContext
    so both stores share one implementation.
  - purgeOnHold moves out of handlers into pkg/appview/holdpurge so the
    storage layer can call it: handlers already depends on storage via
    middleware, so storage to handlers would be an import cycle.
  - ProxyBlobStore.Delete returns distribution.ErrUnsupported, so the
    always-registered blob DELETE route gives a clean OCI UNSUPPORTED
    error instead of a generic 500. Layer bytes are reclaimed by the
    hold's refcounted GC.

The cascade's still-tagged re-check pages through the tag records rather
than reading a single capped page. Tags for all of a user's repositories
share one collection, so one page is a per-account budget: past ~100 tags
a live tag fell off the end and the manifest was deleted while still
referenced. Incomplete enumeration now skips the delete, since an
orphaned manifest is recoverable and a deleted live one is not.

This also makes the over-quota delete grant added in 6e426dc load-bearing:
it hands out pull,delete tokens, which could not do anything while
distribution rejected every DELETE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:54:50 -05:00
Evan Jarrett efabb677e4 clean up some functions to use indigo helpers. make repomgr more sync1.1 compliant 2026-05-26 20:26:29 -05:00
Evan Jarrett 63909aaca0 add webhook for storage quota percentage 2026-05-17 19:57:09 -05:00
Evan Jarrett 902fba4553 convert alert to modal. go fix the codebase 2026-05-16 11:59:47 -05:00
Evan Jarrett a0cc862798 lots of new work for authenticating between appview -> hold. fixed quota handling, improve integration tests for round-trip push/pull/auth/quota checking 2026-05-11 09:20:55 -05:00
Evan Jarrett 98a2cfea59 improve UI around credential helper authorization. have the hold requestCrawl on restart. Update comments that relay_endpoints must suport listreposbycollection 2026-05-08 20:44:04 -05:00
Evan Jarrett 3533f07ecb minor bug fixes, add ability see starred repos 2026-05-06 21:55:47 -05:00
Evan Jarrett b2d6842bb7 clean up old migration code. minor bug fixes with appview ui 2026-05-04 21:52:28 -05:00
Evan Jarrett f9ba8ff62b pass through 429 retry-after from pds 2026-04-29 08:50:54 -05:00
Evan Jarrett 23484645c0 fix tag store implementation for paginating tags new in distribution 3.1 2026-04-19 21:59:27 -05:00
Evan Jarrett 564019d1c3 general appview bugfixes 2026-04-09 10:31:19 -05:00
Evan Jarrett f20170f595 digest page improvements 2026-03-29 13:01:40 -07:00
Evan Jarrett 22cf108a5f fix quirks on repo and digest pages. fix ips not showing in server logs. add basic spam blocking to LB. add setting to configure your oci (docker) client. 2026-03-24 22:24:51 -05:00
Evan Jarrett 23db9be665 add repo page editor. fix deleting all untagged actually deleting all untagged 2026-03-23 21:16:13 -05:00
Evan Jarrett e886192aeb update seamark theme, add 'delete all untagged' option on record page. add garbage collection flag for untagged 2026-03-16 20:26:56 -05:00
Evan Jarrett 7d74e76772 more billing/settings/webhook tweaks 2026-02-28 14:42:35 -06:00
Evan Jarrett 2df5377541 more did:plc fixes, more vulnerability scanner fixes 2026-02-15 22:28:36 -06:00
Evan Jarrett abefcfd1ed let appview work with did:plc based storage servers 2026-02-15 14:20:02 -06:00
Evan Jarrett 92c31835e2 implement the ability to promote a hold as a successor as a way to migrate users to a new storage server 2026-02-12 20:14:19 -06:00
Evan Jarrett 34f342f637 lots of refactor and cleanup to allow for branding overrides 2026-02-02 22:42:15 -06:00
Evan Jarrett 57593a8683 remove the filesystem and buffered upload ability on the holds. going forward the only supported storage is s3. adds extra mocks and tests around uploading 2026-01-19 16:59:03 -06:00
Evan Jarrett 055b34af71 varies fixes for indigo xrpc calls, avatars broken on bsku profile change, opengraph card fixes, other ui improvements 2026-01-14 23:14:43 -06:00
Evan Jarrett 56dd522218 more lint cleanup 2026-01-06 23:08:37 -06:00
Evan Jarrett a448e8257b fix bug creating layer records on non-tagged pushes 2026-01-05 12:08:04 -06:00
Evan Jarrett af815fbc7d use for range and wg.Go 2026-01-04 22:39:48 -06:00
Evan Jarrett efef46b15a various linting fixes 2026-01-04 22:02:01 -06:00
Evan Jarrett fbcaf56fce fixup unused functions/vars 2026-01-04 21:16:02 -06:00
Evan Jarrett 53e196a261 start researching quotas based on layer size per DID 2026-01-04 15:53:44 -06:00
Evan Jarrett f74bc3018a fix issue where soft 404 pages were being rendered in readme content. always update content on push 2026-01-03 23:11:48 -06:00
Evan Jarrett 6dd612e157 add helm badge to tags 2026-01-03 20:01:55 -06:00
Evan Jarrett e6bd4c122e fix sql migration bug. add better error logs for auth failures. fix showing incorrect pull commands with helm charts 2026-01-03 17:26:25 -06:00
Evan Jarrett 647c33e164 fix backoff not clearing correctly. add better logging to find out why someone is denied access (backoff, pds issue, missing record etc) 2026-01-02 14:45:55 -06:00
Evan Jarrett f19dfa2716 move download stats to the hold account so it can persist across different appviews 2025-12-31 11:04:15 -06:00
Evan Jarrett 7f2d780b0a move packages out of token that are not related to docker jwt token 2025-12-29 16:57:14 -06:00
Evan Jarrett 8956568ed2 remove unused filestore. replace it with memstore for tests 2025-12-29 16:51:08 -06:00
Evan Jarrett 24d6b49481 clean up unused locks 2025-12-26 09:48:25 -06:00
Evan Jarrett 363c12e6bf remove unused function 2025-12-26 09:37:57 -06:00
Evan Jarrett 2a60a47fd5 fix issues pulling other users images. fix labels taking priority over annotations. fix various auth errors 2025-12-23 16:20:52 -06:00
Evan Jarrett d11356cd18 more improvements on repo page rendering. allow for repo avatar image uploads (requires new scopes) 2025-12-21 21:51:44 -06:00
Evan Jarrett 79d1126726 better handling for io.atcr.repo.page 2025-12-20 21:50:09 -06:00
Evan Jarrett b18e4c3996 implement io.atcr.repo.page. try and fetch from github,gitlab,tangled README.md files if source exists. 2025-12-20 16:32:41 -06:00
Evan Jarrett 509a1c0306 some lexicon json cleanup. code formatting 2025-12-20 10:46:40 -06:00
Evan Jarrett 4063544cdf cleanup view around attestations. credential helper self upgrades. better oauth support 2025-12-18 09:33:31 -06:00
Evan Jarrett 5dff759064 fix pushing images when the historical hold does not match the default hold in the account 2025-12-09 11:38:26 -06:00
Evan Jarrett 5f1eb05a96 try and provide more helpful reponses when oauth expires and when pushing manifest lists 2025-11-25 09:25:38 -06:00
Evan Jarrett ecf84ed8bc type-ahead login api. fix app-passwords not working without oauth 2025-11-09 21:57:28 -06:00
Evan Jarrett 92d794415a don't use in-memory for holddid caching, just reference from db 2025-11-04 22:48:42 -06:00