Skip to content

auth: scope hints are keyed per host, so namespaces on the same registry share one hint bucket #1376

Description

@TerryHowe

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. 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.

Code references are pinned to 7f80a16.

Why the bucket granularity matters

The bucket decides two things:

  1. Which hints ride along on a request. The whole bucket goes into a single token request — q.Add("scope", scope) per entry in the distribution flow, space-joined into the form for OAuth2.
  2. 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.

func WithScopesForResource(ctx context.Context, res properties.Resource, scopes ...Scope) context.Context
func AppendScopesForResource(ctx context.Context, res properties.Resource, scopes ...Scope) context.Context
func GetScopesForResource(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.

Notes

Sequencing

Land after #1451 (typed Scope, which supersedes #1363). Both change the same four exported functions, so:

  • Implement against the typed signature — scopes ...Scope, not ...string. Writing this against the string API means redoing it.
  • auth: make Scope a typed value instead of a string, and fix CleanScopes de-duplication (decided) #1451 consolidates the bearer cache key into a single helper. This change alters what goes into that key, which would otherwise have to be edited in two places (client.go:289 and :337).
  • 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.

Related

Originally raised as this comment on #1348.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedNeed contributors to helpv3Things belongs to version 3.x

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions