You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#1370 narrowed scope hints from global to per host, closing #1362. The remaining narrowing asked for in #1348 — from per host to per registry scope (host + namespace) — is not covered by that PR and is tracked here.
Scope hints today are keyed at the host but valued at the repository. scopesForHostContextKey is a string of the host, so there is one bucket per host, while the strings inside it are already repository-granular (repository:namespace1/app:pull,push, via ScopeRepository). Registry-side authorization is therefore already namespace-granular. What is host-level is the bucket.
The token cache key — strings.Join(scopes, " ") (client.go:289 and :337).
For a registry that partitions by namespace — harbor.local/namespace1 and harbor.local/namespace2 threaded through one context — that means:
Cross-namespace disclosure. A token fetch during namespace2 work also asks for namespace1's repositories. Harbor issues a token for the subset it grants and drops the rest, so it is usually not an auth failure, but namespace1's repository names are disclosed on a request that has nothing to do with them. Same shape as auth: global scope hints are sent to every host contacted in the context #1362, narrowed to within-host.
Cache-key churn. Every append changes the key for every subsequent request to that host, so a token cached during namespace1 work misses once namespace2 scopes land. Extra token round trips, which is the opposite of what a hint is for.
Unbounded growth. Each AppendScopesForHost re-runs CleanScopes over the whole bucket, and DistributionTokenFetcher puts each scope in the query string, so a long-lived context spanning many namespaces heads toward URL-length limits.
In-tree this stays small — each repository method appends to a local context that dies with the call, and the widest case is two repositories on one host (blob mount appends repoRef then fromRef, registry/remote/repository.go:1197-1202). It bites the caller who threads one context across namespaces, which is exactly the oras login example.com/myspace shape #1348 is about.
Proposal
Key the scope-hint bucket on properties.Resource — the value type #1380 landed for namespace-aware credential resolution — rather than leaving this half host-only. If the credential key is namespace-aware and the hint key is not, the two halves of the auth path disagree about what identifies a resource.
funcWithScopesForResource(ctx context.Context, res properties.Resource, scopes...Scope) context.ContextfuncAppendScopesForResource(ctx context.Context, res properties.Resource, scopes...Scope) context.ContextfuncGetScopesForResource(ctx context.Context, res properties.Resource) []Scope
The ...Scope rather than ...string assumes #1451 lands first — see Sequencing below.
Design caution
Narrowing the key means Client.Do can no longer do the exact map hit that GetScopesForHost does. It needs a longest-prefix walk, the way GetAuthConfigHierarchical already does for credentials.
The URL-parsing half of this concern has since been solved. An earlier revision of this issue said Do only has originalReq.URL and cannot split a namespace out of /v2/<name>/... because <name> may contain slashes. #1398 has since landed registry/remote/auth/resource.go, and Do already derives a properties.Resource from the request via requestResource (client.go:278), splitting the path at the rightmost endpoint separator. The lookup therefore starts from a Resource{Registry, Path}, not from a raw URL.
What remains is narrower: the derived Path is the full repository (namespace1/app) while the registered key is a namespace prefix (namespace1), and nothing in the path marks the boundary between them. So the bucket still has to be selected by prefix-matching the derived resource against the keys the caller registered, rather than by an exact map hit.
Two details for whoever implements it:
repositoryFromPath returns "" for a path that does not address a repository (/v2/_catalog, /v2/), and deliberately degrades to "" rather than naming a bogus repository when ValidateRepository fails. The prefix walk needs a registry-level fallback for the empty-Path case, or catalog requests silently lose their hints.
requestResource stores the canonical registry name — a request dialed at registry-1.docker.io derives docker.io. Keys registered by callers and keys derived from requests line up only because both sides are canonical. Worth an explicit test, since a regression there fails open: every lookup misses, hints silently stop working, and nothing errors.
Both are breaking changes to the same call sites, and v3.0.0-rc.1 shipped on 2026-09-03. Getting both in before v3.0.0 GA avoids making callers migrate the same functions twice across the GA line.
Summary
#1370 narrowed scope hints from global to per host, closing #1362. The remaining narrowing asked for in #1348 — from per host to per registry scope (host + namespace) — is not covered by that PR and is tracked here.
Scope hints today are keyed at the host but valued at the repository.
scopesForHostContextKeyis astringof the host, so there is one bucket per host, while the strings inside it are already repository-granular (repository:namespace1/app:pull,push, viaScopeRepository). Registry-side authorization is therefore already namespace-granular. What is host-level is the bucket.Code references are pinned to 7f80a16.
Why the bucket granularity matters
The bucket decides two things:
q.Add("scope", scope)per entry in the distribution flow, space-joined into the form for OAuth2.strings.Join(scopes, " ")(client.go:289and:337).For a registry that partitions by namespace —
harbor.local/namespace1andharbor.local/namespace2threaded through one context — that means:AppendScopesForHostre-runsCleanScopesover the whole bucket, andDistributionTokenFetcherputs each scope in the query string, so a long-lived context spanning many namespaces heads toward URL-length limits.In-tree this stays small — each repository method appends to a local context that dies with the call, and the widest case is two repositories on one host (blob mount appends
repoRefthenfromRef,registry/remote/repository.go:1197-1202). It bites the caller who threads one context across namespaces, which is exactly theoras login example.com/myspaceshape #1348 is about.Proposal
Key the scope-hint bucket on
properties.Resource— the value type #1380 landed for namespace-aware credential resolution — rather than leaving this half host-only. If the credential key is namespace-aware and the hint key is not, the two halves of the auth path disagree about what identifies a resource.The
...Scoperather than...stringassumes #1451 lands first — see Sequencing below.Design caution
Narrowing the key means
Client.Docan no longer do the exact map hit thatGetScopesForHostdoes. It needs a longest-prefix walk, the wayGetAuthConfigHierarchicalalready does for credentials.The URL-parsing half of this concern has since been solved. An earlier revision of this issue said
Doonly hasoriginalReq.URLand cannot split a namespace out of/v2/<name>/...because<name>may contain slashes. #1398 has since landedregistry/remote/auth/resource.go, andDoalready derives aproperties.Resourcefrom the request viarequestResource(client.go:278), splitting the path at the rightmost endpoint separator. The lookup therefore starts from aResource{Registry, Path}, not from a raw URL.What remains is narrower: the derived
Pathis the full repository (namespace1/app) while the registered key is a namespace prefix (namespace1), and nothing in the path marks the boundary between them. So the bucket still has to be selected by prefix-matching the derived resource against the keys the caller registered, rather than by an exact map hit.Two details for whoever implements it:
repositoryFromPathreturns""for a path that does not address a repository (/v2/_catalog,/v2/), and deliberately degrades to""rather than naming a bogus repository whenValidateRepositoryfails. The prefix walk needs a registry-level fallback for the empty-Pathcase, or catalog requests silently lose their hints.requestResourcestores the canonical registry name — a request dialed atregistry-1.docker.ioderivesdocker.io. Keys registered by callers and keys derived from requests line up only because both sides are canonical. Worth an explicit test, since a regression there fails open: every lookup misses, hints silently stop working, and nothing errors.Notes
properties.Resource, so the key type this needs now exists.Sequencing
Land after #1451 (typed
Scope, which supersedes #1363). Both change the same four exported functions, so:scopes ...Scope, not...string. Writing this against the string API means redoing it.client.go:289and:337).v3.0.0-rc.1shipped on 2026-09-03. Getting both in beforev3.0.0GA avoids making callers migrate the same functions twice across the GA line.Related
Resourcevalue type used as the key here. Closed, landed.Scopeinstead of a string; supersedes auth: make Scope a typed value instead of a string, and fix CleanScopes de-duplication #1363. Sequence this issue after it.Originally raised as this comment on #1348.