Allow token authentication for design management images

What does this MR do and why?

Design management images (/-/design_management/designs/:id/(:sha/)raw_image and …/resized_image/:size) are currently only accessible to session-authenticated (browser) requests. API clients can read all design metadata — including the image URLs — through GraphQL (DesignManagement::Design.image / imageV432x230), but then have no way to actually fetch those images on non-public projects:

  • the routes are not part of the REST or GraphQL API (the REST Design entity's image_url points back at this same web route), and
  • web requests only accept PAT/OAuth tokens for an allowlist of request formats — RSS/ICS, /api, repository archives, release downloads (Gitlab::Auth::AuthFinders#valid_web_access_format?) — which design images are not part of.

So a token that can read everything about a design cannot download it: clients (mobile apps, integrations, exporters) get a sign-in redirect instead of the image.

This MR adds a :design sessionless authentication format, valid only on design management image paths, and authenticates it in Projects::DesignManagement::DesignsController show actions — the same mechanism (and motivation) previously used for repository archives (#28978 (closed)) and release downloads.

Notes for reviewers:

  • Authorization is unchanged. authorize_read_design! (can?(current_user, :read_design, design)) still runs on every request; this only adds an authentication transport for a read-only endpoint.
  • Legacy PAT/OAuth tokens must carry the api or read_api scope: the dedicated :design branch in RequestAuthenticator#find_sessionless_user calls find_user_from_web_access_token(:design, scopes: [:api, :read_api]) directly and deliberately bypasses the broader find_user_from_any_authentication_method chain, so feed, static object, and CI job tokens can never authenticate design images. Insufficient scopes keep behaving as unauthenticated.
  • Granular (fine-grained) PATs skip the coarse scope check by design; they are gated by authorize_granular_token! on the read_design permission instead, which is bundled into Work Item: Read. On public projects, granular tokens without an explicit scope keep working through Authz::BoundaryPolicy's anonymous fallback (anonymous_can_read_design).
  • Tokens are accepted from headers only (PRIVATE-TOKEN or Authorization: Bearer). Requests carrying a token in the query string (private_token, access_token, or bearer_token) fail closed, per the access token guardrails.
  • Project/group access tokens and service accounts (bot users) are intentionally not added to RequestAuthenticator#can_sign_in_bot?, matching release downloads. Enabling them would deliberately widen bot sessionless sign-in, so it is planned as a separate follow-up with its own authentication and AppSec review.

What changed since the first review round

  • The :design format got a dedicated branch in find_sessionless_user ("Narrow design request auth to web access tokens"), so only web access tokens are ever consulted.
  • The temporary outright denial of granular tokens was replaced by real granular support, folded in from !246945 (merged) (merged into this branch rather than retargeted to master): it defines the read_design raw permission, grants it through the existing Work Item: Read assignable permission — matching how design metadata and image URLs are already exposed to granular tokens in GraphQL — and maps design: :read_design in GRANULAR_FORMAT_PERMISSIONS.
  • Query string tokens are rejected ("Reject query string tokens on design image endpoints"): the endpoints are header-only from day one, so the parameter never has to be deprecated later.
  • Documentation was added: a task section on the design management page and a row in the fine-grained token operations inventory.

How to set up and validate locally

  1. On a private project, add a design to an issue (issue → Designs → upload), and create a personal access token with the read_api scope.

  2. Get the design's image URL via GraphQL:

    { project(fullPath: "<path>") { issue(iid: "<iid>") { designCollection { designs(first: 1) { nodes { image } } } } } }
  3. Without this change: curl -i -H "PRIVATE-TOKEN: <token>" "<image url>" redirects to /users/sign_in.

  4. With this change: the same request returns the image (200, Content-Type: image/…). A token with an insufficient scope (for example read_user) still gets the sign-in redirect, a token whose user cannot read the design still gets a 404, and passing the token as ?private_token= instead of a header gets the sign-in redirect.

MR acceptance checklist

Please evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Marcel van Remmerden

Merge request reports

Loading
Loading