Support additional required scopes for granular tokens
What this MR does
Adds additional_scopes: to the granular (fine-grained) personal access token authorization framework, for both the REST API and GraphQL. This is framework-only groundwork. No endpoint declares the new key yet, so this MR introduces no behavior change.
The problem
Granular token authorization checks one boundary per endpoint, derived from the URL on REST or a path argument on GraphQL. Some endpoints also act on a second container named in the request body or in another argument. For example: moving an issue to another project (to_project_id), creating a merge request into another project (target_project_id), sharing a project with a group (group_id), and adding a project to a CI job token allowlist (target_project_id).
For those endpoints, the framework checks only the first boundary. A token scoped to the source project alone can still write to the target.
The existing boundaries: array cannot express this requirement. Its entries are alternatives, the framework authorizes if any one of them matches. It exists for resources that can live in different container types, such as a runner that belongs to a project, a group, or the instance. Declaring the target project there loosens enforcement instead of tightening it. That is worth keeping in mind during review, since it is the natural first instinct when fixing one of these endpoints.
The solution
additional_scopes: takes entries that are cumulative. Every entry must authorize, in addition to the primary boundary. Each entry carries its own permissions:, because the permission needed on a move target (create) differs from the permission needed on the source (move).
REST example:
route_setting :authorization, permissions: :move_issue, boundary_type: :project,
additional_scopes: [
{ permissions: :create_work_item, boundary_type: :project, boundary_param: :to_project_id }
]
post ':id/issues/:issue_iid/move' do
endGraphQL example:
authorize_granular_token permissions: :move_issue,
boundary_argument: :project_path, boundary_type: :project,
additional_scopes: [
{ permissions: :create_work_item, boundary_argument: :target_project_path, boundary_type: :project }
]How it is implemented
Authz::Tokens::AuthorizeGranularScopesServicestays unchanged. The REST helper runs it once per requirement and stops at the first denial. This keeps the diff small and reuses the existing handling of unresolved boundaries, hidden boundaries, the feature flag, and legacy enforcement.- On GraphQL,
Directives::Authz::GranularScopegains arequirement_groupargument. The directive is repeatable, so alternatives were already multiple directives with no way to tell them apart from cumulative ones. Directives that share a group are alternatives, and every group must be authorized. Primary directives carry no group, so existing declarations produce identical directives. BoundaryExtractor#extract_groupsreturns boundaries keyed by group.GranularScopeAuthorizationauthorizes and caches each group separately.- The
:granular_patfactory gains anadditional_scopes:transient, so a spec can build a token scoped to several boundaries.
Behavior semantics worth documenting for reviewers
- When an additional scope's boundary cannot be resolved from the request, the response is
404 Not Foundrather than skipping the requirement. This avoids revealing whether the target exists. - When the user cannot see the additional boundary, the response is also
404 Not Found. - When the user can see it but the token lacks the permission there, the response is the normal
403granular scope denial.
Follow-up work
Declare additional_scopes: on the affected endpoints. Roughly 40 endpoints across 8 operation families are affected.