Apply organization scoping to module-level URL helpers

What does this MR do and why?

GitLab is rolling out organization-scoped URLs: every route also exists under /o/:organization_path. Routing::OrganizationsHelper::MappedHelpers makes URL helpers organization-aware by prepending an override module onto Rails' url_helpers, so that when a request has organization context, helpers like project_path return the scoped /o/<org>/... variant.

That prepend only reaches instance dispatch (controllers, views, presenters). Module-level calls such as Gitlab::Routing.url_helpers.foo_path and Rails.application.routes.url_helpers.foo_path (around 455 call sites) dispatch through the module's singleton class and bypassed the override, returning unscoped paths. A related gap: API requests are never nested under /o/, so REST/GraphQL responses stayed unscoped even after fixing the above, unlike HAML-rendered pages. This MR closes both gaps.

  • Prepend the override module onto url_helpers.singleton_class (and mirror the unscoped_* aliases there) so module-level helper calls get scoped too.
  • Fall back to resolving the organization from the X-GitLab-Organization-ID request header when the URL itself doesn't name one, since the frontend already sends this header on API calls.
  • Prepend the same override onto Rails' internal named-routes proxy modules so module-level url_for/polymorphic_url (e.g. WorkItemPresenter#web_path) are covered too.

All new behavior is behind the extended_organization_url_scoping derisk feature flag (disabled by default, rollout: #629134 (closed)). Instance dispatch — the pre-existing behavior — is not gated.

Implementation details

  • Commit 1 — module-level dispatch: MappedHelpers.install now also prepends the override to url_helpers.singleton_class, and copies the unscoped_* aliases (unscoped_root_path, etc.) onto the singleton so module-level callers keep the global-path escape hatch.
  • Commit 2 — header fallback:
    • MappedHelpers.scoped_path_for falls back to the organization resolved from X-GitLab-Organization-ID when the URL doesn't name one.
    • Guarded by Organization#scoped_paths? (false for the default organization), because the frontend sends the header on every request regardless of organization. An explicitly named /o/ URL is still echoed back even for the default organization (unchanged behavior).
    • Grape's set_current_organization now stores the resolver in Current.organization_resolver — previously it only lived in a local variable, so REST requests had no resolver available at all.
    • Gitlab::Current::Organization#from_headers is memoized, since URL helpers can invoke it many times per request.
  • Commit 3 — polymorphic helpers: the override is also prepended to named_routes.url_helpers_module and named_routes.path_helpers_module, covering Rails' internal proxy object used by module-level url_for/polymorphic_url.
  • Feature flag: the prepends happen once at boot and cannot be gated themselves, so the new dispatch paths (singleton, Rails proxy) get a gated variant of the override module that checks extended_organization_url_scoping per call — only after an organization context was found, so boot-time helper calls never trigger a Feature lookup. The header fallback is gated by the same flag. Feature.current_request is the actor, so one response never mixes scoped and unscoped URLs during percentage rollout.
  • Resulting precedence for choosing the organization path (order unchanged, header added last):
    1. Current.data_context, when it resolves to an isolated organization (cannot be overridden)
    2. explicit organization_path: keyword argument (nil forces the global path)
    3. the request's /o/:organization_path URL segment
    4. otherwise, the X-GitLab-Organization-ID header, only for organizations with scoped paths

References

Screenshots or screen recordings

Not applicable, backend URL generation only (no UI change).

How to set up and validate locally

We need an organization and a group in that organization.

This transfers group 'twitter' to a new organization:

Feature.enable(:ui_for_organizations)
Feature.enable(:extended_organization_url_scoping)
my_org = Organizations::Organization.find_or_create_by!(path: 'my-org') { |org| org.name = 'My Org' }
group = Group.find_by_path('twitter')
user = User.find_by_username('root')
Organizations::Transfer::GroupsService.new(group: group, new_organization: my_org, current_user: user).execute

Test some url's using master branch nad then switch to this branch:

URL Description master 608081-patch-singleton-url-helpers
/o/my-org/groups/twitter Link to Typehead project 🔴 ✅
/o/my-org/twitter/Typeahead.Js Links in the file browser 🔴 ✅
/o/my-org/twitter/Typeahead.Js/-/work_items Links to the work items 🔴 ✅
/o/my-org/twitter/Typeahead.Js/-/commits/master All the links to commits are unscoped 🔴 ✅

MR acceptance checklist

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 Rutger Wessels

Merge request reports

Loading
Loading