From 056260a0135bd8a49fb7a6a1cb278f6d56629055 Mon Sep 17 00:00:00 2001 From: Alex Buijs Date: Mon, 3 Nov 2025 23:02:50 +0100 Subject: [PATCH 1/5] Add GraphQL granular token authorization Add support for authorizing granular tokens for GraphQL queries and mutations. --- app/controllers/graphql_controller.rb | 3 +- .../directives/authz/granular_scope.rb | 22 + .../authz/granular_scope_permission_enum.rb | 14 + app/graphql/types/base_field.rb | 1 + .../graphql_granular_token_authorization.md | 582 ++++++++++++++++++ .../graphql/authz/boundary_extractor.rb | 148 +++++ lib/gitlab/graphql/authz/directive_finder.rb | 61 ++ .../authz/granular_token_authorization.rb | 60 ++ lib/gitlab/graphql/authz/skip_rules.rb | 42 ++ lib/gitlab/graphql/authz/type_unwrapper.rb | 27 + rubocop/cop/gitlab/bounded_contexts.rb | 1 + spec/controllers/graphql_controller_spec.rb | 7 +- .../granular_scope_permission_enum_spec.rb | 12 + .../graphql/authz/boundary_extractor_spec.rb | 224 +++++++ .../graphql/authz/directive_finder_spec.rb | 88 +++ .../granular_token_authorization_spec.rb | 116 ++++ .../gitlab/graphql/authz/skip_rules_spec.rb | 59 ++ .../graphql/authz/type_unwrapper_spec.rb | 72 +++ .../merge_requests/set_assignees_spec.rb | 2 +- .../granular_token_authorization_helper.rb | 32 + 20 files changed, 1570 insertions(+), 3 deletions(-) create mode 100644 app/graphql/directives/authz/granular_scope.rb create mode 100644 app/graphql/types/authz/granular_scope_permission_enum.rb create mode 100644 doc/development/permissions/graphql_granular_token_authorization.md create mode 100644 lib/gitlab/graphql/authz/boundary_extractor.rb create mode 100644 lib/gitlab/graphql/authz/directive_finder.rb create mode 100644 lib/gitlab/graphql/authz/granular_token_authorization.rb create mode 100644 lib/gitlab/graphql/authz/skip_rules.rb create mode 100644 lib/gitlab/graphql/authz/type_unwrapper.rb create mode 100644 spec/graphql/types/authz/granular_scope_permission_enum_spec.rb create mode 100644 spec/lib/gitlab/graphql/authz/boundary_extractor_spec.rb create mode 100644 spec/lib/gitlab/graphql/authz/directive_finder_spec.rb create mode 100644 spec/lib/gitlab/graphql/authz/granular_token_authorization_spec.rb create mode 100644 spec/lib/gitlab/graphql/authz/skip_rules_spec.rb create mode 100644 spec/lib/gitlab/graphql/authz/type_unwrapper_spec.rb create mode 100644 spec/support/helpers/authz/granular_token_authorization_helper.rb diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 7bb87c4849edda..66ccac1014c086 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -289,7 +289,8 @@ def context current_organization: Current.organization, request: request, scope_validator: ::Gitlab::Auth::ScopeValidator.new(api_user, request_authenticator), - remove_deprecated: Gitlab::Utils.to_boolean(permitted_params[:remove_deprecated], default: false) + remove_deprecated: Gitlab::Utils.to_boolean(permitted_params[:remove_deprecated], default: false), + access_token: access_token } end diff --git a/app/graphql/directives/authz/granular_scope.rb b/app/graphql/directives/authz/granular_scope.rb new file mode 100644 index 00000000000000..a0f7848e1c6f80 --- /dev/null +++ b/app/graphql/directives/authz/granular_scope.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Directives + module Authz + class GranularScope < GraphQL::Schema::Directive + argument :permissions, [Types::Authz::GranularScopePermissionEnum], + description: 'Granular scope permissions required to access the field or type.' + + argument :boundary, GraphQL::Types::String, + required: false, + description: 'Method name to call on the resolved object to extract the authorization boundary ' \ + '(Project/Group). Use when the object is already resolved (fields on types, nested fields).' + + argument :boundary_argument, GraphQL::Types::String, + required: false, + description: 'Argument name containing the authorization boundary (path or GlobalID). ' \ + 'Use for mutations and query fields where the boundary is passed as an argument.' + + locations FIELD_DEFINITION, OBJECT + end + end +end diff --git a/app/graphql/types/authz/granular_scope_permission_enum.rb b/app/graphql/types/authz/granular_scope_permission_enum.rb new file mode 100644 index 00000000000000..6cbee5701ed857 --- /dev/null +++ b/app/graphql/types/authz/granular_scope_permission_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Authz + class GranularScopePermissionEnum < Types::BaseEnum + graphql_name 'GranularScopePermission' + description 'Granular scope permission for granular token authorization' + + ::Authz::Permission.all_for_tokens.each do |permission| + value permission.name.upcase, value: permission.name, description: permission.description + end + end + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 2f6849e02b5124..629c8ab11270f4 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -32,6 +32,7 @@ def initialize(**kwargs, &block) extension ::Gitlab::Graphql::CallsGitaly::FieldExtension if Gitlab.dev_or_test_env? extension ::Gitlab::Graphql::Present::FieldExtension extension ::Gitlab::Graphql::Authorize::FieldExtension + extension ::Gitlab::Graphql::Authz::GranularTokenAuthorization after_connection_extensions.each { extension _1 } if after_connection_extensions.any? end diff --git a/doc/development/permissions/graphql_granular_token_authorization.md b/doc/development/permissions/graphql_granular_token_authorization.md new file mode 100644 index 00000000000000..8e59596f9fb0a3 --- /dev/null +++ b/doc/development/permissions/graphql_granular_token_authorization.md @@ -0,0 +1,582 @@ +--- +stage: Software Supply Chain Security +group: Authorization +info: Any user with at least the Maintainer role can merge updates to this content. For details, see https://docs.gitlab.com/development/development_processes/#development-guidelines-review. +title: GraphQL Granular Token Authorization +--- + +This document explains how the `GranularTokenAuthorization` field extension works to enforce granular Personal Access Token (PAT) permissions on GraphQL queries and mutations. + +## Overview + +The granular token authorization system adds fine-grained permission checks to GraphQL fields based on directives applied to types, fields, and mutations. It ensures that granular PATs can only access resources they have explicit permissions for within specific project or group boundaries. + +## Architecture Components + +### 1. Field Extension + +- **Location**: `lib/gitlab/graphql/authz/granular_token_authorization.rb` +- **Purpose**: Intercepts field resolution to perform authorization checks +- **Applied to**: All GraphQL fields via `Types::BaseField` + +### 2. Directive + +- **Location**: `app/graphql/directives/authz/granular_scope.rb` +- **Purpose**: Declares required permissions and boundary extraction strategy +- **Arguments**: + - `permissions`: Array of required permission strings (e.g., `['READ_ISSUE']`) + - `boundary`: Method name to extract boundary from resolved object + - `boundary_argument`: Argument name containing the boundary + +### 3. Directive Finder + +- **Location**: `lib/gitlab/graphql/authz/directive_finder.rb` +- **Purpose**: Locates applicable directives by checking field, owner type, implementing type, and return type +- **Includes**: `TypeUnwrapper` module for unwrapping GraphQL type wrappers + +### 4. Boundary Extractor + +- **Location**: `lib/gitlab/graphql/authz/boundary_extractor.rb` +- **Purpose**: Extracts the authorization boundary (Project/Group) from various sources + +### 5. Type Unwrapper + +- **Location**: `lib/gitlab/graphql/authz/type_unwrapper.rb` +- **Purpose**: Shared module for unwrapping GraphQL type wrappers (List, NonNull, Connection) +- **Used by**: DirectiveFinder and SkipRules + +## Request Flow Timeline + +### Phase 1: Request Initiation + +```plaintext +1. GraphQL request arrives (query or mutation) +2. GraphQL Ruby begins parsing and validation +3. Execution begins with root fields +``` + +### Phase 2: Field Resolution (per field) + +For each field being resolved: + +```plaintext +1. GraphQL Ruby calls field extensions in order + ├─ CallsGitaly::FieldExtension (dev/test only) + ├─ Present::FieldExtension + ├─ Authorize::FieldExtension + └─ GranularTokenAuthorization ← WE ARE HERE +``` + +### Phase 3: Authorization Check + +**Step 1: Early Exit Conditions** + +```ruby +def authorize_field(object, arguments, context) + return unless context[:access_token].try(:granular?) # Only authorize granular PATs + return if SkipRules.new(@field).should_skip? # Skip certain fields +``` + +- If not using a granular PAT, granular scope authorization is skipped (legacy PATs use existing scope authorization) +- Certain fields are automatically skipped: + - **Mutation response fields** (e.g., `createIssue.issue`) - Authorization happens on the mutation itself, not the response wrapper + - **Permission metadata fields** (e.g., `issue.userPermissions`) - These return permission information, not actual data + +**Step 2: Directive Discovery** + +```ruby +directive = DirectiveFinder.new(@field).find(object) +``` + +The `DirectiveFinder` checks for directives in this priority order, **returning the first match found**: + +1. **Field-level directive** (`FIELD_DEFINITION`): Applied directly to the field + + ```ruby + field :project_issues, Types::IssueType.connection_type, + directives: { + Directives::Authz::GranularScope => { + permissions: ['READ_ISSUE'], + boundary_argument: 'project_path' + } + } do + argument :project_path, GraphQL::Types::ID, required: true + end + ``` + +1. **Owner type directive** (`OBJECT`): Applied to the type that owns the field + + **For GraphQL types:** + + ```ruby + class IssueType < BaseObject + directive Directives::Authz::GranularScope, + permissions: ['READ_ISSUE'], + boundary: 'project' + end + ``` + + **For mutations:** + + ```ruby + module Mutations + module Issues + class Create < BaseMutation + directive Directives::Authz::GranularScope, + permissions: ['CREATE_ISSUE'], + boundary_argument: 'project_path' + end + end + end + ``` + +1. **Implementing type directive** (for interfaces): Applied to the concrete type implementing an interface + - Only checked when the field owner is an interface and an `object` is provided + - Resolves the actual model type (e.g., `Issue`) from `GitlabSchema.types` + +1. **Return type directive**: Applied to the type returned by the field + - Always checked as a fallback if no directive found at previous levels + - Unwraps GraphQL type wrappers to find the base type: + - List types: `[Type]` → `Type` + - NonNull types: `Type!` → `Type` + - Connection types: `TypeConnection` → `Type` (e.g., `IssueConnection` → `IssueType`) + - Works with both `boundary_argument` and `boundary` strategies + - When using `boundary` with an `:id` argument, enables ID fallback for boundary extraction + +**Step 3: Boundary Extraction** + +```ruby +boundary = BoundaryExtractor.new(object:, arguments:, context:, directive:).extract +permissions = directive.arguments[:permissions].map(&:downcase) +``` + +**Note**: When no directive is found, `boundary` and `permissions` are both `nil`. The authorization service will return the error message: "Unable to determine boundary and permissions for authorization". + +The boundary extractor behavior: + +- **For standalone resources** (`boundary: 'user'` or `boundary: 'instance'`): Returns `Authz::Boundary::NilBoundary` +- **For valid project/group resources**: Returns wrapped boundary (`ProjectBoundary` or `GroupBoundary`) +- **When resource not found**: Returns `nil` (not wrapped in NilBoundary) + +Supported boundary types: + +- `Authz::Boundary::ProjectBoundary` - for Project resources +- `Authz::Boundary::GroupBoundary` - for Group resources +- `Authz::Boundary::NilBoundary` - for standalone resources (user-scoped or instance-wide) + +The extractor uses one of four strategies: + +**Strategy A: `boundary_argument` (for mutations and query fields)** + +```ruby +# Directive says: boundary_argument: 'project_path' +# Field argument: project_path: "gitlab-org/gitlab" + +extract_from_argument('project_path') + ↓ +args[:project_path] = "gitlab-org/gitlab" + ↓ +resolve_path("gitlab-org/gitlab") + ↓ +Project.find_by_full_path("gitlab-org/gitlab") || Group.find_by_full_path("gitlab-org/gitlab") + ↓ +returns Project or Group instance +``` + +**Strategy B: `boundary` (for type fields with resolved object)** + +```ruby +# Directive says: boundary: 'project' +# Object: Issue instance + +extract_from_method('project') + ↓ +unwrap_object(object) # Issue + ↓ +object_matches_boundary_type?('project') # false (Issue ≠ Project) + ↓ +object.respond_to?(:project) # true + ↓ +object.project + ↓ +returns Project instance +``` + +**Strategy C: ID Fallback (for query fields with GlobalID)** + +Used when: + +- Directive specifies `boundary: 'project'` +- Object is nil or doesn't respond to boundary method +- Field has `:id` argument with GlobalID + +```ruby +# Query: issue(id: "gid://gitlab/Issue/123") +# Directive says: boundary: 'project' +# Object: nil (query field, not resolved yet) + +extract_from_id_argument + ↓ +args[:id] = "gid://gitlab/Issue/123" + ↓ +GlobalID.parse("gid://gitlab/Issue/123") + ↓ +GlobalID::Locator.locate(gid) # Issue.find(123) - extra DB query + ↓ +extract_boundary_from_object(issue) + ↓ +issue.project + ↓ +returns Project instance +``` + +**Performance note**: This strategy fetches the record twice - once for authorization and once during field resolution, although the query will be cached. + +**Strategy D: Standalone boundaries (for user-scoped or instance-wide resources)** + +Used when: + +- Directive specifies `boundary: 'user'` (user-scoped resources) +- Directive specifies `boundary: 'instance'` (instance-wide resources) + +```ruby +# Directive says: boundary: 'user' +# Resource doesn't belong to a specific project/group + +standalone_boundary?('user') + ↓ +returns Authz::Boundary::NilBoundary.new(nil) + ↓ +Authorization will fail unless token has appropriate permissions +``` + +This strategy is used for resources that don't belong to a specific project or group boundary but are user-scoped or instance-wide. + +**Step 4: Authorization Check** + +```ruby +authorize_with_cache!(context, boundary, permissions) +``` + +This method: + +1. **Checks cache**: `context[:authz_cache]` to avoid duplicate checks +1. **Calls authorization service**: + + ```ruby + ::Authz::Tokens::AuthorizeGranularScopesService.new( + boundary: boundary, + permissions: permissions, + token: context[:access_token] + ).execute + ``` + +1. **Verifies**: Token has required permissions for the boundary +1. **Raises error** if unauthorized: `raise_resource_not_available_error!(response.message)` +1. **Caches result** to avoid redundant checks + +**Step 5: Field Resolution** + +```ruby +yield(object, arguments, **rest) +``` + +If authorization passes, the field resolver executes and returns its value. + +## Example Scenarios + +### Scenario 1: Mutation with `boundary_argument` + +**GraphQL Request:** + +```graphql +mutation { + createIssue(input: { + projectPath: "gitlab-org/gitlab", + title: "New issue" + }) { + issue { id } + } +} +``` + +**Directive:** + +```ruby +class Create < BaseMutation + directive Directives::Authz::GranularScope, + permissions: ['CREATE_ISSUE'], + boundary_argument: 'project_path' +end +``` + +**Timeline:** + +1. Extension called for `createIssue` field +1. `object` = `nil` (root mutation field) +1. Directive found on mutation class +1. Boundary extracted from `arguments[:input][:project_path]` +1. `Project.find_by_full_path("gitlab-org/gitlab")` → Project +1. Authorization service checks: Does token have `CREATE_ISSUE` permission for this project? +1. If yes: mutation executes +1. If no: raises error, mutation doesn't execute + +### Scenario 2: Type with `boundary` (nested field) + +**GraphQL Request:** + +```graphql +query { + project(fullPath: "gitlab-org/gitlab") { + issues { + nodes { + title # ← Authorization here + description # ← And here + } + } + } +} +``` + +**Directive:** + +```ruby +class IssueType < BaseObject + directive Directives::Authz::GranularScope, + permissions: ['READ_ISSUE'], + boundary: 'project' +end +``` + +**Timeline (for `title` field):** + +1. Extension called for `title` field +1. `object` = Issue instance (already resolved) +1. Directive found on `IssueType` (owner of `title` field) +1. Boundary extracted by calling `issue.project` +1. Authorization service checks: Does token have `READ_ISSUE` permission for this project? +1. Cache hit on subsequent fields (`description`, etc.) - no additional DB queries +1. If yes: field resolves and returns title +1. If no: raises error + +### Scenario 3: Query field with ID fallback + +**GraphQL Request:** + +```graphql +query { + issue(id: "gid://gitlab/Issue/123") { + title + } +} +``` + +**Directive:** + +```ruby +class IssueType < BaseObject + directive Directives::Authz::GranularScope, + permissions: ['READ_ISSUE'], + boundary: 'project' +end +``` + +**Timeline:** + +1. Extension called for `issue` field (returns IssueType) +1. `object` = `nil` (root query field) +1. Directive found on return type (`IssueType`) +1. Boundary extraction detects: object is nil, but `:id` argument present +1. Uses ID fallback: extracts GlobalID → locates Issue → gets `issue.project` +1. Authorization service checks: Does token have `READ_ISSUE` permission for this project? +1. If yes: field resolves (Issue is fetched again by resolver) +1. If no: raises error before field resolution + +## Performance Optimizations + +### 1. Caching + +**Per-Request Cache:** + +```ruby +context[:authz_cache] = Set.new +cache_key = [permissions&.sort, boundary&.class, boundary&.namespace&.id] + +# Example cache key for READ_ISSUE on a project: +# [["read_issue"], Authz::Boundary::ProjectBoundary, 123] +``` + +- Authorization results are cached per request using a Set +- Prevents redundant authorization checks for the same boundary and permissions +- Example: Checking 10 issue fields on the same project only hits authorization service once +- Cache key components: + - `permissions&.sort`: Sorted array of lowercase permission strings + - `boundary&.class`: The boundary wrapper class (e.g., `Authz::Boundary::ProjectBoundary`) + - `boundary&.namespace&.id`: The namespace ID (varies by boundary type): + - `ProjectBoundary`: `project.project_namespace.id` + - `GroupBoundary`: `group.id` + - `NilBoundary`: `nil` + +### 2. Early Returns + +```ruby +return unless context[:access_token].try(:granular?) +return if SkipRules.new(@field).should_skip? +``` + +- Non-granular tokens skip the entire system (zero overhead) +- Mutation response fields and permission metadata fields are automatically skipped (see Phase 3, Step 1 for details) + +## Error Handling + +### Authorization Failures + +When authorization fails: + +```ruby +raise_resource_not_available_error!(response.message) +``` + +**For GraphQL:** + +- Returns service error in `errors` array +- Field returns `null` + +**Example response:** + +```json +{ + "data": { "issue": null }, + "errors": [{ + "message": "Insufficient permissions", + "path": ["issue"] + }] +} +``` + +### Edge Cases and Error Scenarios + +#### Missing Configuration Errors + +1. **No directive found (with granular PAT)** + - **Behavior**: Authorization proceeds with `boundary: nil, permissions: nil` + - **Result**: Authorization service returns error + - **Error message**: `"Unable to determine boundary and permissions for authorization"` + - **Note**: All fields accessed with granular PATs must have directives + +1. **Directive has empty permissions array** + - **Behavior**: Authorization proceeds with `permissions: []` (boundary provided) + - **Result**: Authorization service returns error + - **Error message**: `"Unable to determine permissions for authorization"` + - **Cause**: Directive defined with `permissions: []` + +#### Boundary Resolution Errors + +1. **Boundary extraction returns nil (resource not found)** + - **Behavior**: Authorization proceeds with `boundary: nil` (permissions still provided) + - **Result**: Authorization service returns error + - **Error message**: `"Unable to determine boundary for authorization"` + - **Causes**: + - Invalid path/GlobalID that doesn't resolve to a resource + - Object missing expected association (e.g., `issue.project` returns `nil`) + - Directive has neither `boundary` nor `boundary_argument` configured + - **Note**: This is different from standalone boundaries which return `NilBoundary` object + +1. **Invalid GlobalID format** + - **Behavior**: `GlobalID.parse("invalid")` returns `nil` + - **Result**: Boundary extraction returns `nil` → authorization error + - **Error message**: `"Unable to determine boundary for authorization"` + - **Note**: Fails gracefully without raising exceptions + +1. **Boundary method returns nil** + - **Behavior**: `issue.project` returns `nil` + - **Result**: Returns `nil` → authorization error + - **Error message**: `"Unable to determine boundary for authorization"` + - **Common causes**: Soft-deleted associations, orphaned records + +1. **GlobalID points to non-existent record** + - **Behavior**: `GlobalID::Locator.locate(gid)` raises `ActiveRecord::RecordNotFound`, rescued and returns `nil` + - **Result**: Boundary extraction returns `nil` → authorization error + - **Error message**: `"Unable to determine boundary for authorization"` + +#### Configuration Errors + +1. **Object doesn't respond to boundary method** + - **Behavior**: Raises `ArgumentError: "Boundary method 'project' not found on Project"` + - **Cause**: Using `boundary: 'project'` but object is wrong type + - **Exceptions**: + - If field has `:id` argument, uses ID fallback instead + - If object type matches boundary name, returns object directly + - **Example**: + + ```ruby + # IssueType has: boundary: 'project' + # Field: project.issue(iid: "1") + # object = Project (not Issue) + # Project matches 'project' → returns Project + ``` + +1. **Multiple directives found** + - **Behavior**: Uses first match in priority order (field → owner → implementing type → return type) + - **Result**: May not use expected directive if multiple apply + - **Best practice**: Apply directive at only one level per field to avoid confusion + - **Note**: The directive finder stops at the first match and does not check subsequent levels + +## Directive Application Rules + +### Directive Locations + +The `GranularScope` directive can be applied at two locations: + +1. **`FIELD_DEFINITION`** - Applied directly to individual fields + + ```ruby + field :project_issues, Types::IssueType.connection_type, + directives: { Directives::Authz::GranularScope => { permissions: ['READ_ISSUE'], boundary_argument: 'project_path' } } + ``` + + - Use for fields that need different permissions than their owner type + - Use for mutations and query fields to specify boundary extraction strategy + +1. **`OBJECT`** - Applied to GraphQL object types + + ```ruby + class IssueType < BaseObject + directive Directives::Authz::GranularScope, permissions: ['READ_ISSUE'], boundary: 'project' + end + ``` + + - Applies to all fields on the type (unless overridden by field-level directive) + - Use when all fields on a type require the same permissions + +### When `boundary` applies + +✅ Fields **on** the type (e.g., `issue.title` when `IssueType` has directive) +✅ Query fields with `:id` argument returning the type (enables ID fallback) +✅ Standalone resources using `boundary: 'user'` or `boundary: 'instance'` +❌ Query fields **without** `:id` argument returning the type (object not available, raises ArgumentError) + +### When `boundary_argument` applies + +✅ Root mutations +✅ Root query fields +✅ Any field that receives boundary as an argument +✅ Fields returning types with `boundary_argument` directive + +### Standalone Boundaries + +Use `boundary: 'user'` or `boundary: 'instance'` for resources that don't belong to a specific project or group: + +```ruby +class UserSettingType < BaseObject + directive Directives::Authz::GranularScope, + permissions: ['READ_USER_SETTINGS'], + boundary: 'user' +end +``` + +These directives return `NilBoundary` which will be validated by the authorization service but won't be tied to a specific project/group namespace. + +## See Also + +- [Granular Personal Access Tokens Documentation](granular_personal_access_tokens.md) diff --git a/lib/gitlab/graphql/authz/boundary_extractor.rb b/lib/gitlab/graphql/authz/boundary_extractor.rb new file mode 100644 index 00000000000000..01502a7a444b97 --- /dev/null +++ b/lib/gitlab/graphql/authz/boundary_extractor.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Authz + # Extracts authorization boundary (Project/Group) from GraphQL field resolution + # + # Usage: + # - Use `boundary_argument` for mutations and query fields that receive the boundary as an argument + # - Use `boundary` for type fields where the boundary can be extracted from the resolved object + # - Use `boundary: 'user'` or `boundary: 'instance'` for standalone resources that don't belong to a project/group + class BoundaryExtractor + STANDALONE_BOUNDARIES = %w[user instance].freeze + + def initialize(object:, arguments:, context:, directive:) + @object = object + @arguments = arguments + @context = context + @directive = directive + end + + def extract + boundary_method = @directive.arguments[:boundary] + return ::Authz::Boundary::NilBoundary.new(nil) if standalone_boundary?(boundary_method) + + resource = extract_resource + return if resource.nil? + + ::Authz::Boundary.for(resource) + end + + private + + def standalone_boundary?(boundary_method) + STANDALONE_BOUNDARIES.include?(boundary_method&.to_s) + end + + def extract_resource + # Extract from argument (for mutations/query fields) + boundary_arg = @directive.arguments[:boundary_argument] + return extract_from_argument(boundary_arg) if boundary_arg + + # Extract from resolved object (for type fields) + boundary_method = @directive.arguments[:boundary] + if boundary_method + return extract_from_id_argument if should_use_id_fallback?(boundary_method) + + return extract_from_method(boundary_method) + end + + nil + end + + def extract_from_argument(arg_name) + args = @arguments[:input] || @arguments + arg_value = args[arg_name.to_sym] + + resolve_value(arg_value) + end + + def extract_from_method(method_name) + obj = unwrap_object(@object) + + return obj if object_matches_boundary_type?(obj, method_name) + + unless obj.respond_to?(method_name.to_sym) + raise ArgumentError, "Boundary method '#{method_name}' not found on #{obj.class}" + end + + obj.public_send(method_name.to_sym) # rubocop:disable GitlabSecurity/PublicSend -- Safe: method_name from directive config + end + + def object_matches_boundary_type?(obj, method_name) + # Check if the object's class name matches the boundary method + # E.g., 'project' matches Project, 'group' matches Group + obj.class.name.underscore == method_name.to_s + end + + def resolve_value(value) + case value + when GlobalID + resolve_global_id(value) + when String + resolve_path(value) + end + end + + def resolve_global_id(global_id) + return unless global_id + + object = GlobalID::Locator.locate(global_id) + extract_boundary_from_object(object) + rescue ActiveRecord::RecordNotFound + nil + end + + def resolve_path(path) + ::Project.find_by_full_path(path) || ::Group.find_by_full_path(path) + end + + def extract_boundary_from_object(object) + obj = unwrap_object(object) + + return obj if obj.is_a?(::Project) || obj.is_a?(::Group) + return obj.project if obj.respond_to?(:project) + return obj.group if obj.respond_to?(:group) + + nil + end + + def unwrap_object(object) + object.is_a?(::Types::BaseObject) ? object.object : object + end + + def should_use_id_fallback?(boundary_method) + # Use ID fallback when: + # 1. Object is nil (query field before resolution) + # 2. Object doesn't respond to the boundary method + # 3. An :id argument is present (GlobalID) + return false unless @arguments[:id] + + @object.nil? || !object_responds_to_boundary?(boundary_method) + end + + def object_responds_to_boundary?(method_name) + obj = unwrap_object(@object) + obj.respond_to?(method_name.to_sym) + end + + def extract_from_id_argument + # Extract boundary from :id GlobalID argument + # This is used for query fields like issue(id: "gid://gitlab/Issue/123") + # where the directive says boundary: 'project' but we don't have an issue yet + id_arg = @arguments[:id] + + case id_arg + when GlobalID + resolve_global_id(id_arg) + when String + resolve_global_id(GlobalID.parse(id_arg)) + else + raise ArgumentError, "ID argument must be a GlobalID or string" + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/authz/directive_finder.rb b/lib/gitlab/graphql/authz/directive_finder.rb new file mode 100644 index 00000000000000..08d82e584fcf5e --- /dev/null +++ b/lib/gitlab/graphql/authz/directive_finder.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Authz + # Finds GranularScope directives by checking field, owner, implementing type, and return type + class DirectiveFinder + include TypeUnwrapper + + def initialize(field) + @field = field + end + + def find(object) + find_on_field || + find_on_owner || + find_on_implementing_type(object) || + find_on_return_type + end + + private + + def find_on_field + find_directive(@field) + end + + def find_on_owner + find_directive(@field.owner) + end + + def find_on_implementing_type(object) + return unless @field.owner.kind.interface? && object + + implementing_type = resolve_implementing_type(object) + find_directive(implementing_type) + end + + def find_on_return_type + return_type = unwrap_type(@field.type) + + find_directive(return_type) + end + + def resolve_implementing_type(object) + # GraphQL wraps the model in a type object, get the actual model from object.object + model = object.respond_to?(:object) ? object.object : object + # Unwrap presenters (e.g., IssuePresenter wraps Issue) + model = model.__getobj__ if model.respond_to?(:__getobj__) + # Use model's class name to find the GraphQL type (e.g., Issue -> "Issue") + GitlabSchema.types[model.class.name] + end + + def find_directive(field_or_type) + return unless field_or_type.respond_to?(:directives) + + field_or_type.directives.find { |d| d.is_a?(Directives::Authz::GranularScope) } + end + end + end + end +end diff --git a/lib/gitlab/graphql/authz/granular_token_authorization.rb b/lib/gitlab/graphql/authz/granular_token_authorization.rb new file mode 100644 index 00000000000000..7d27351046cc78 --- /dev/null +++ b/lib/gitlab/graphql/authz/granular_token_authorization.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Authz + # Field extension for granular token authorization + class GranularTokenAuthorization < GraphQL::Schema::FieldExtension + include ::Gitlab::Graphql::Authorize::AuthorizeResource + + def resolve(object:, arguments:, context:, **rest) + authorize_field(object, arguments, context) + + yield(object, arguments, **rest) + end + + private + + def authorize_field(object, arguments, context) + return unless context[:access_token].try(:granular?) + return if SkipRules.new(@field).should_skip? + + directive = DirectiveFinder.new(@field).find(object) + boundary = boundary(object, arguments, context, directive) + permissions = permissions(directive) + + authorize_with_cache!(context, boundary, permissions) + end + + def authorize_with_cache!(context, boundary, permissions) + cache = context[:authz_cache] ||= Set.new + cache_key = [permissions&.sort, boundary&.class, boundary&.namespace&.id] + + return if cache.include?(cache_key) + + response = ::Authz::Tokens::AuthorizeGranularScopesService.new( + boundary: boundary, + permissions: permissions, + token: context[:access_token] + ).execute + + raise_resource_not_available_error!(response.message) if response.error? + + cache.add(cache_key) + end + + def boundary(object, arguments, context, directive) + return unless directive + + BoundaryExtractor.new(object:, arguments:, context:, directive:).extract + end + + def permissions(directive) + return unless directive + + directive.arguments[:permissions].map(&:downcase) + end + end + end + end +end diff --git a/lib/gitlab/graphql/authz/skip_rules.rb b/lib/gitlab/graphql/authz/skip_rules.rb new file mode 100644 index 00000000000000..8266250da53ec1 --- /dev/null +++ b/lib/gitlab/graphql/authz/skip_rules.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Authz + # Determines whether granular token authorization should be skipped for a field + class SkipRules + include TypeUnwrapper + + def initialize(field) + @field = field + @owner = field.owner + end + + def should_skip? + return false unless @owner.is_a?(Class) + + mutation_response_field? || permission_metadata_field? + end + + private + + # Mutation response fields (e.g., `createIssue.issue`) + # Authorization happens on the mutation field itself, not the response wrapper + def mutation_response_field? + !!(@owner <= ::Mutations::BaseMutation) + end + + # Permission metadata fields (e.g., `issue.userPermissions`) + # These return permission information, not actual data + def permission_metadata_field? + # Check if owner is a permission type + return true if @owner <= ::Types::PermissionTypes::BasePermissionType + + # Check if return type is a permission type + return_type = unwrap_type(@field.type) + !!(return_type.is_a?(Class) && return_type < ::Types::PermissionTypes::BasePermissionType) + end + end + end + end +end diff --git a/lib/gitlab/graphql/authz/type_unwrapper.rb b/lib/gitlab/graphql/authz/type_unwrapper.rb new file mode 100644 index 00000000000000..bc85231a7045f0 --- /dev/null +++ b/lib/gitlab/graphql/authz/type_unwrapper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Authz + # Shared module for unwrapping GraphQL type wrappers + module TypeUnwrapper + private + + # Unwraps GraphQL type wrappers to get to the underlying type + # Handles: + # - List types: [Type] -> Type + # - NonNull types: Type! -> Type + # - Connection types: TypeConnection -> Type + def unwrap_type(type) + if type.respond_to?(:of_type) && type.of_type + unwrap_type(type.of_type) + elsif type.respond_to?(:node_type) && type.node_type + type.node_type + else + type + end + end + end + end + end +end diff --git a/rubocop/cop/gitlab/bounded_contexts.rb b/rubocop/cop/gitlab/bounded_contexts.rb index 903ab6450c4c8a..63b5533de8eb95 100644 --- a/rubocop/cop/gitlab/bounded_contexts.rb +++ b/rubocop/cop/gitlab/bounded_contexts.rb @@ -20,6 +20,7 @@ class BoundedContexts < RuboCop::Cop::Base PermissionTypes Resolvers Subscriptions + Directives ].freeze class << self diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb index 928b98ca723b79..8b6a4c614e470e 100644 --- a/spec/controllers/graphql_controller_spec.rb +++ b/spec/controllers/graphql_controller_spec.rb @@ -543,6 +543,12 @@ expect(assigns(:context)[:is_sessionless_user]).to be true end + it 'assigns access_token in the context' do + subject + + expect(assigns(:context)[:access_token]).to eq(token) + end + it "assigns username in ApplicationContext" do subject @@ -837,7 +843,6 @@ context 'when cookie-based authentication is used' do it 'does not invoke DPoP' do sign_in(user) - expect(controller).not_to receive(:extract_personal_access_token) post :execute diff --git a/spec/graphql/types/authz/granular_scope_permission_enum_spec.rb b/spec/graphql/types/authz/granular_scope_permission_enum_spec.rb new file mode 100644 index 00000000000000..0494d7255b12de --- /dev/null +++ b/spec/graphql/types/authz/granular_scope_permission_enum_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Authz::GranularScopePermissionEnum, feature_category: :permissions do + specify { expect(described_class.graphql_name).to eq('GranularScopePermission') } + + it 'exposes all the granular scope permissions available for access tokens' do + expect(described_class.values.keys) + .to match_array(::Authz::Permission.all_for_tokens.map(&:name).map(&:upcase)) + end +end diff --git a/spec/lib/gitlab/graphql/authz/boundary_extractor_spec.rb b/spec/lib/gitlab/graphql/authz/boundary_extractor_spec.rb new file mode 100644 index 00000000000000..8bc9fb7f671c05 --- /dev/null +++ b/spec/lib/gitlab/graphql/authz/boundary_extractor_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Graphql::Authz::BoundaryExtractor, feature_category: :permissions do + include Authz::GranularTokenAuthorizationHelper + using RSpec::Parameterized::TableSyntax + + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } + let_it_be(:issue) { create(:issue, project: project) } + + let(:context) { {} } + let(:object) { nil } + let(:arguments) { {} } + + subject(:boundary_extractor) { described_class.new(object:, arguments:, context:, directive:) } + + shared_examples 'extracts project boundary' do + it 'returns project boundary' do + expect(boundary).to be_a(Authz::Boundary::ProjectBoundary) + expect(boundary.namespace).to eq(project.project_namespace) + end + end + + shared_examples 'extracts group boundary' do + it 'returns group boundary' do + expect(boundary).to be_a(Authz::Boundary::GroupBoundary) + expect(boundary.namespace).to eq(group) + end + end + + shared_examples 'extracts nil boundary' do + it 'returns nil boundary' do + expect(boundary).to be_a(Authz::Boundary::NilBoundary) + expect(boundary.namespace).to be_nil + end + end + + shared_examples 'returns nil' do + it 'returns nil' do + expect(boundary).to be_nil + end + end + + describe '#extract' do + subject(:boundary) { boundary_extractor.extract } + + context 'with boundary_argument in directive' do + let(:directive) { create_directive(boundary_argument: 'arg') } + + where(method: [:to_global_id, :full_path]) + + with_them do + let(:arguments) { { arg: project.public_send(method) } } + + it_behaves_like 'extracts project boundary' + end + + with_them do + let(:arguments) { { arg: group.public_send(method) } } + + it_behaves_like 'extracts group boundary' + end + + with_them do + let(:arguments) { { input: { arg: project.public_send(method) } } } + + it_behaves_like 'extracts project boundary' + end + + with_them do + let(:arguments) { { input: { arg: group.public_send(method) } } } + + it_behaves_like 'extracts group boundary' + end + + context 'with edge cases' do + where(arguments: [{ arg: nil }, { arg: 'nonexistent/project' }, {}]) + + with_them do + it_behaves_like 'returns nil' + end + end + end + + context 'with standalone boundaries' do + context 'when boundary is user' do + let(:directive) { create_directive(boundary: 'user') } + let(:object) { issue } + + it_behaves_like 'extracts nil boundary' + end + + context 'when boundary is instance' do + let(:directive) { create_directive(boundary: 'instance') } + let(:object) { issue } + + it_behaves_like 'extracts nil boundary' + end + end + + context 'with boundary method in directive' do + let(:directive) { create_directive(boundary: 'project') } + let(:object) { issue } + + it_behaves_like 'extracts project boundary' + + context 'when object matches the boundary type' do + context 'with project boundary' do + let(:object) { project } + + it_behaves_like 'extracts project boundary' + end + + context 'with group boundary' do + let(:directive) { create_directive(boundary: 'group') } + let(:object) { group } + + it_behaves_like 'extracts group boundary' + end + end + + context 'when boundary method does not exist' do + let(:directive) { create_directive(boundary: 'nonexistent_method') } + + it 'raises an error' do + expect { boundary }.to raise_error(ArgumentError, /Boundary method 'nonexistent_method' not found/) + end + end + + context 'when boundary method returns nil' do + before do + allow(issue).to receive(:project).and_return(nil) + end + + it_behaves_like 'returns nil' + end + end + + context 'when directive has neither boundary nor boundary_argument' do + let(:directive) { create_directive } + + it_behaves_like 'returns nil' + end + + context 'with ID fallback' do + let(:directive) { create_directive(boundary: 'project') } + + context 'with GlobalID string' do + let(:arguments) { { id: issue.to_global_id.to_s } } + + it_behaves_like 'extracts project boundary' + end + + context 'with GlobalID object' do + let(:arguments) { { id: issue.to_global_id } } + + it_behaves_like 'extracts project boundary' + + context 'when object is also provided' do + let(:object) { issue } + + it_behaves_like 'extracts project boundary' + end + end + + context 'when GlobalID is invalid' do + let(:arguments) { { id: 'invalid-gid' } } + + it_behaves_like 'returns nil' + end + + context 'when GlobalID points to non-existent record' do + let(:arguments) { { id: "gid://gitlab/Issue/999999999" } } + + it_behaves_like 'returns nil' + end + + context 'when ID argument is not a GlobalID or string' do + let(:arguments) { { id: 123 } } + + it 'raises an ArgumentError' do + expect { boundary }.to raise_error(ArgumentError, 'ID argument must be a GlobalID or string') + end + end + end + + context 'with wrapped GraphQL objects' do + let(:directive) { create_directive(boundary: 'project') } + let(:object) do + instance_double(Types::BaseObject, object: issue).tap do |obj| + allow(obj).to receive(:is_a?).with(Types::BaseObject).and_return(true) + end + end + + it_behaves_like 'extracts project boundary' + end + + context 'when extracting boundary from resource objects' do + let(:directive) { create_directive(boundary_argument: 'resource_id') } + + context 'when object has a project method' do + let(:arguments) { { resource_id: issue.to_global_id } } + + it_behaves_like 'extracts project boundary' + end + + context 'when object has a group method' do + let_it_be(:label) { create(:group_badge, group:) } + let(:arguments) { { resource_id: label.to_global_id } } + + it_behaves_like 'extracts group boundary' + end + + context 'when object type has no project or group method' do + let_it_be(:user) { create(:user) } + let(:arguments) { { resource_id: user.to_global_id } } + + it_behaves_like 'returns nil' + end + end + end +end diff --git a/spec/lib/gitlab/graphql/authz/directive_finder_spec.rb b/spec/lib/gitlab/graphql/authz/directive_finder_spec.rb new file mode 100644 index 00000000000000..38eabbc2cc646f --- /dev/null +++ b/spec/lib/gitlab/graphql/authz/directive_finder_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Graphql::Authz::DirectiveFinder, feature_category: :permissions do + include Authz::GranularTokenAuthorizationHelper + + let(:directive) { create_directive(boundary: 'project', permissions: ['READ_ISSUE']) } + let(:field_with_directive) { create_field_with_directive(directive:) } + let(:object) { nil } + + subject(:finder) { described_class.new(field) } + + describe '#find' do + subject(:find) { finder.find(object) } + + context 'when no directive is found' do + let(:field) { create_base_field } + + it { is_expected.to be_nil } + end + + context 'when directive is on the field' do + let(:field) { field_with_directive } + + it { is_expected.to eq(directive) } + end + + context 'when directive is on the field owner' do + let(:field) { create_base_field(owner: field_with_directive) } + + it { is_expected.to eq(directive) } + end + + context 'when directive is on the implementing type' do + let(:field) { create_base_field(owner: create_interface) } + let(:model) { build(:issue) } + let(:object) { instance_double(Types::BaseObject, object: model) } + + before do + allow(GitlabSchema).to receive(:types).and_return(model.class.name => field_with_directive) + end + + it { is_expected.to eq(directive) } + + context 'when object is a model' do + let(:object) { model } + + it { is_expected.to eq(directive) } + end + + context 'when object is nil' do + let(:object) { nil } + + it { is_expected.to be_nil } + end + + context 'when object is wrapped in a presenter' do + let(:presenter) { IssuePresenter.new(model, current_user: nil) } + let(:object) { instance_double(Types::BaseObject, object: presenter) } + + it 'unwraps the presenter and finds the directive' do + expect(find).to eq(directive) + end + end + + context 'when the implementing type is not found in the schema' do + before do + allow(GitlabSchema).to receive(:types).and_return({}) + end + + it { is_expected.to be_nil } + end + end + + context 'when directive is on the return type' do + let(:return_type) do + Class.new(GraphQL::Schema::Object) { graphql_name 'TestReturnType' }.tap do |type| + allow(type).to receive(:directives).and_return([directive]) + end + end + + let(:field) { create_base_field(type: return_type) } + + it { is_expected.to eq(directive) } + end + end +end diff --git a/spec/lib/gitlab/graphql/authz/granular_token_authorization_spec.rb b/spec/lib/gitlab/graphql/authz/granular_token_authorization_spec.rb new file mode 100644 index 00000000000000..64dbcf9fa0be53 --- /dev/null +++ b/spec/lib/gitlab/graphql/authz/granular_token_authorization_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Graphql::Authz::GranularTokenAuthorization, feature_category: :permissions do + include Authz::GranularTokenAuthorizationHelper + + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user, developer_of: project) } + let_it_be(:access_token) { create(:granular_pat) } + + let(:object) { project } + let(:arguments) { {} } + let(:context) { { access_token: } } + let(:resolve_block) { ->(_obj, _args) { 'field_value' } } + let(:field) { create_field_with_directive(boundary: 'itself', permissions: ['READ_WIKI']) } + + subject(:extension) { described_class.new(field: field, options: {}) } + + describe 'field extension behavior' do + it 'is a GraphQL field extension' do + expect(described_class).to be < GraphQL::Schema::FieldExtension + end + end + + describe '#resolve' do + subject(:resolve) { extension.resolve(object:, arguments:, context:, &resolve_block) } + + it 'raises an ResourceNotAvailable error that includes the message from the service response' do + expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'Access denied: ' \ + "Your Personal Access Token lacks the required permissions: [read_wiki] for \"#{project.full_path}\".") + end + + context 'when the token is nil' do + let(:access_token) { nil } + + it { is_expected.to eq('field_value') } + end + + context 'when the token is a legacy PAT' do + let(:access_token) { create(:personal_access_token) } + + it { is_expected.to eq('field_value') } + end + + context 'when field authorization should be skipped' do + before do + allow_next_instance_of(Gitlab::Graphql::Authz::SkipRules, field) do |skip_rules| + allow(skip_rules).to receive(:should_skip?).and_return(true) + end + end + + it { is_expected.to eq('field_value') } + end + + context 'with a granular token' do + let_it_be(:access_token) do + create(:granular_pat, namespace: project.namespace, permissions: [:read_wiki, :create_issue], user: user) + end + + it { is_expected.to eq('field_value') } + + context 'when a directive cannot be found' do + let(:field) { create_base_field } + + it 'raises an ResourceNotAvailable error that includes the message from the service response' do + expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, + 'Unable to determine boundary and permissions for authorization') + end + end + + context 'with standalone boundaries' do + context 'when boundary is user' do + let(:field) { create_field_with_directive(boundary: 'user', permissions: ['READ_WIKI']) } + + it 'raises an ResourceNotAvailable error' do + expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when boundary is instance' do + let(:field) { create_field_with_directive(boundary: 'instance', permissions: ['READ_WIKI']) } + + it 'raises an ResourceNotAvailable error' do + expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + end + + context 'with caching' do + it 'does not call service when cached result exists' do + expect(::Authz::Tokens::AuthorizeGranularScopesService).not_to receive(:new) + + context[:authz_cache] = Set[ + [['read_wiki'], Authz::Boundary::ProjectBoundary, project.project_namespace.id]] + + resolve + end + + it 'calls service again for different permissions' do + expect(::Authz::Tokens::AuthorizeGranularScopesService).to receive(:new).twice.and_call_original + + resolve + + different_field = create_field_with_directive(boundary: 'itself', permissions: ['CREATE_ISSUE']) + different_extension = described_class.new(field: different_field, options: {}) + different_extension.resolve(object: object, arguments: arguments, context: context, &resolve_block) + + expect(context[:authz_cache]).to eq(Set[ + [['read_wiki'], Authz::Boundary::ProjectBoundary, project.project_namespace.id], + [['create_issue'], Authz::Boundary::ProjectBoundary, project.project_namespace.id]]) + end + end + end + end +end diff --git a/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb b/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb new file mode 100644 index 00000000000000..5f25c7134b8e9a --- /dev/null +++ b/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Graphql::Authz::SkipRules, feature_category: :permissions do + include Authz::GranularTokenAuthorizationHelper + + let(:field_type) { GraphQL::Types::String } + let(:owner) { Types::IssueType } + let(:field) { create_base_field(type: field_type, owner: owner) } + + subject(:skip_rules) { described_class.new(field) } + + describe '#should_skip?' do + subject(:should_skip?) { skip_rules.should_skip? } + + it { is_expected.to be false } + + context 'when owner is not a Class' do + let(:owner) { Object.new } + + it { is_expected.to be false } + end + + context 'with mutation response fields' do + context 'when owner is a mutation' do + let(:owner) { Mutations::Issues::Create } + + it { is_expected.to be true } + end + + context 'when owner is a base mutation' do + let(:owner) { Mutations::BaseMutation } + + it { is_expected.to be true } + end + end + + context 'with permission metadata fields' do + context 'when owner is a permission type' do + let(:owner) { Types::PermissionTypes::Project } + + it { is_expected.to be true } + end + + context 'when owner is a base permission type' do + let(:owner) { Types::PermissionTypes::BasePermissionType } + + it { is_expected.to be true } + end + + context 'when return type is a permission type' do + let(:field_type) { Types::PermissionTypes::Project } + + it { is_expected.to be true } + end + end + end +end diff --git a/spec/lib/gitlab/graphql/authz/type_unwrapper_spec.rb b/spec/lib/gitlab/graphql/authz/type_unwrapper_spec.rb new file mode 100644 index 00000000000000..422091d2079045 --- /dev/null +++ b/spec/lib/gitlab/graphql/authz/type_unwrapper_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Graphql::Authz::TypeUnwrapper, feature_category: :permissions do + let(:test_class) do + Class.new do + include Gitlab::Graphql::Authz::TypeUnwrapper + + def unwrap(type) + unwrap_type(type) + end + end + end + + let(:base_type) { Types::IssueType } + + describe '#unwrap_type' do + subject(:unwrap_type) { test_class.new.unwrap(wrapped_type) } + + context 'with a base type' do + let(:wrapped_type) { base_type } + + it { is_expected.to eq base_type } + end + + context 'with a NonNull type' do + let(:wrapped_type) { GraphQL::Schema::NonNull.new(base_type) } + + it { is_expected.to eq base_type } + end + + context 'with a List type' do + let(:wrapped_type) { GraphQL::Schema::List.new(base_type) } + + it { is_expected.to eq base_type } + end + + context 'with nested wrappers' do + let(:wrapped_type) do + GraphQL::Schema::NonNull.new( + GraphQL::Schema::List.new( + GraphQL::Schema::NonNull.new(base_type) + ) + ) + end + + it { is_expected.to eq base_type } + end + + context 'with a Connection type' do + let(:wrapped_type) { Types::IssueType.connection_type } + + it { is_expected.to eq base_type } + end + + context 'with a NonNull Connection type' do + let(:wrapped_type) do + GraphQL::Schema::NonNull.new(Types::IssueType.connection_type) + end + + it { is_expected.to eq base_type } + end + + context 'with a type that has no wrappers' do + let(:base_type) { GraphQL::Types::String } + let(:wrapped_type) { GraphQL::Types::String } + + it { is_expected.to eq base_type } + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb index cafc002bf4472e..ac2d94d25bf30d 100644 --- a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb +++ b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb @@ -70,7 +70,7 @@ def run_mutation! context 'when the current user does not have permission to add assignees' do let(:current_user) { create(:user) } - let(:db_query_limit) { 32 } + let(:db_query_limit) { 33 } it 'does not change the assignees' do project.add_guest(current_user) diff --git a/spec/support/helpers/authz/granular_token_authorization_helper.rb b/spec/support/helpers/authz/granular_token_authorization_helper.rb new file mode 100644 index 00000000000000..261e43b2098ef0 --- /dev/null +++ b/spec/support/helpers/authz/granular_token_authorization_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Authz + module GranularTokenAuthorizationHelper + def create_interface + Module.new do + include ::Types::BaseInterface + + field :interface_field, GraphQL::Types::String, null: true + end + end + + def create_base_field(type: GraphQL::Types::String, owner: Types::ProjectType) + Types::BaseField.new(type: type, owner: owner, name: :test_field, null: true) + end + + def create_field_with_directive(directive: nil, type: GraphQL::Types::String, owner: Types::ProjectType, **args) + create_base_field(type:, owner:).tap do |field| + directive ||= create_directive(**args) + allow(field).to receive(:directives).and_return([directive]) + end + end + + def create_directive(**args) + return unless args + + instance_double(Directives::Authz::GranularScope, arguments: args).tap do |directive| + allow(directive).to receive(:is_a?) { |klass| klass == Directives::Authz::GranularScope } + end + end + end +end -- GitLab From 551c5f4d35359c265be3728c5f7a02506681ea7a Mon Sep 17 00:00:00 2001 From: Alex Buijs Date: Mon, 8 Dec 2025 15:40:46 +0100 Subject: [PATCH 2/5] Implement reviewer feedback --- .../authz/granular_scope_permission_enum.rb | 5 +- .../graphql/authz/boundary_extractor.rb | 50 ++++++++++--------- lib/gitlab/graphql/authz/directive_finder.rb | 2 +- lib/gitlab/graphql/authz/skip_rules.rb | 14 ++++-- .../granular_scope_permission_enum_spec.rb | 2 +- .../graphql/authz/boundary_extractor_spec.rb | 12 +++++ .../graphql/authz/directive_finder_spec.rb | 13 +++++ .../gitlab/graphql/authz/skip_rules_spec.rb | 6 +++ 8 files changed, 73 insertions(+), 31 deletions(-) diff --git a/app/graphql/types/authz/granular_scope_permission_enum.rb b/app/graphql/types/authz/granular_scope_permission_enum.rb index 6cbee5701ed857..49533c2af89cc6 100644 --- a/app/graphql/types/authz/granular_scope_permission_enum.rb +++ b/app/graphql/types/authz/granular_scope_permission_enum.rb @@ -6,8 +6,9 @@ class GranularScopePermissionEnum < Types::BaseEnum graphql_name 'GranularScopePermission' description 'Granular scope permission for granular token authorization' - ::Authz::Permission.all_for_tokens.each do |permission| - value permission.name.upcase, value: permission.name, description: permission.description + ::Authz::PermissionGroups::Assignable.all_permissions.uniq.each do |permission| + raw_permission = ::Authz::Permission.get(permission) + value raw_permission.name.upcase, value: raw_permission.name, description: raw_permission.description end end end diff --git a/lib/gitlab/graphql/authz/boundary_extractor.rb b/lib/gitlab/graphql/authz/boundary_extractor.rb index 01502a7a444b97..a1938e1ebeb4c8 100644 --- a/lib/gitlab/graphql/authz/boundary_extractor.rb +++ b/lib/gitlab/graphql/authz/boundary_extractor.rb @@ -5,23 +5,24 @@ module Graphql module Authz # Extracts authorization boundary (Project/Group) from GraphQL field resolution # - # Usage: - # - Use `boundary_argument` for mutations and query fields that receive the boundary as an argument - # - Use `boundary` for type fields where the boundary can be extracted from the resolved object - # - Use `boundary: 'user'` or `boundary: 'instance'` for standalone resources that don't belong to a project/group + # Usage in authorization directives: + # - `boundary_argument: 'arg_name'` - Extracts boundary from argument (GlobalID or full_path string) + # - `boundary: 'method_name'` - Calls method on resolved object, or falls back to :id argument for query fields + # - `boundary: 'user'` or `boundary: 'instance'` - For standalone resources without project/group boundaries class BoundaryExtractor STANDALONE_BOUNDARIES = %w[user instance].freeze + VALID_BOUNDARY_ACCESSOR_METHODS = %w[project group itself].freeze def initialize(object:, arguments:, context:, directive:) @object = object @arguments = arguments @context = context @directive = directive + @boundary_accessor = directive.arguments[:boundary] end def extract - boundary_method = @directive.arguments[:boundary] - return ::Authz::Boundary::NilBoundary.new(nil) if standalone_boundary?(boundary_method) + return ::Authz::Boundary::NilBoundary.new(nil) if standalone_boundary? resource = extract_resource return if resource.nil? @@ -31,8 +32,8 @@ def extract private - def standalone_boundary?(boundary_method) - STANDALONE_BOUNDARIES.include?(boundary_method&.to_s) + def standalone_boundary? + STANDALONE_BOUNDARIES.include?(@boundary_accessor&.to_s) end def extract_resource @@ -41,11 +42,10 @@ def extract_resource return extract_from_argument(boundary_arg) if boundary_arg # Extract from resolved object (for type fields) - boundary_method = @directive.arguments[:boundary] - if boundary_method - return extract_from_id_argument if should_use_id_fallback?(boundary_method) + if @boundary_accessor + return extract_from_id_argument if should_use_id_fallback? - return extract_from_method(boundary_method) + return extract_from_method end nil @@ -58,22 +58,26 @@ def extract_from_argument(arg_name) resolve_value(arg_value) end - def extract_from_method(method_name) + def extract_from_method obj = unwrap_object(@object) - return obj if object_matches_boundary_type?(obj, method_name) + return obj if object_matches_boundary_type?(obj) - unless obj.respond_to?(method_name.to_sym) - raise ArgumentError, "Boundary method '#{method_name}' not found on #{obj.class}" + unless VALID_BOUNDARY_ACCESSOR_METHODS.include?(@boundary_accessor.to_s) + raise ArgumentError, "Invalid boundary method: '#{@boundary_accessor}'" end - obj.public_send(method_name.to_sym) # rubocop:disable GitlabSecurity/PublicSend -- Safe: method_name from directive config + unless obj.respond_to?(@boundary_accessor.to_sym) + raise ArgumentError, "Boundary method '#{@boundary_accessor}' not found on #{obj.class}" + end + + obj.public_send(@boundary_accessor.to_sym) # rubocop:disable GitlabSecurity/PublicSend -- Safe: @boundary_accessor from directive config end - def object_matches_boundary_type?(obj, method_name) + def object_matches_boundary_type?(obj) # Check if the object's class name matches the boundary method # E.g., 'project' matches Project, 'group' matches Group - obj.class.name.underscore == method_name.to_s + obj.class.name.underscore == @boundary_accessor.to_s end def resolve_value(value) @@ -112,19 +116,19 @@ def unwrap_object(object) object.is_a?(::Types::BaseObject) ? object.object : object end - def should_use_id_fallback?(boundary_method) + def should_use_id_fallback? # Use ID fallback when: # 1. Object is nil (query field before resolution) # 2. Object doesn't respond to the boundary method # 3. An :id argument is present (GlobalID) return false unless @arguments[:id] - @object.nil? || !object_responds_to_boundary?(boundary_method) + @object.nil? || !object_responds_to_boundary? end - def object_responds_to_boundary?(method_name) + def object_responds_to_boundary? obj = unwrap_object(@object) - obj.respond_to?(method_name.to_sym) + obj.respond_to?(@boundary_accessor.to_sym) end def extract_from_id_argument diff --git a/lib/gitlab/graphql/authz/directive_finder.rb b/lib/gitlab/graphql/authz/directive_finder.rb index 08d82e584fcf5e..af15ef050689b6 100644 --- a/lib/gitlab/graphql/authz/directive_finder.rb +++ b/lib/gitlab/graphql/authz/directive_finder.rb @@ -29,7 +29,7 @@ def find_on_owner end def find_on_implementing_type(object) - return unless @field.owner.kind.interface? && object + return unless @field.owner&.kind&.interface? && object implementing_type = resolve_implementing_type(object) find_directive(implementing_type) diff --git a/lib/gitlab/graphql/authz/skip_rules.rb b/lib/gitlab/graphql/authz/skip_rules.rb index 8266250da53ec1..be64889db36ea4 100644 --- a/lib/gitlab/graphql/authz/skip_rules.rb +++ b/lib/gitlab/graphql/authz/skip_rules.rb @@ -29,12 +29,18 @@ def mutation_response_field? # Permission metadata fields (e.g., `issue.userPermissions`) # These return permission information, not actual data def permission_metadata_field? - # Check if owner is a permission type - return true if @owner <= ::Types::PermissionTypes::BasePermissionType + owner_is_permission_type? || return_type_is_permission_type? + end + + def owner_is_permission_type? + !!(@owner <= ::Types::PermissionTypes::BasePermissionType) + end - # Check if return type is a permission type + def return_type_is_permission_type? return_type = unwrap_type(@field.type) - !!(return_type.is_a?(Class) && return_type < ::Types::PermissionTypes::BasePermissionType) + return false unless return_type.is_a?(Class) + + !!(return_type < ::Types::PermissionTypes::BasePermissionType) end end end diff --git a/spec/graphql/types/authz/granular_scope_permission_enum_spec.rb b/spec/graphql/types/authz/granular_scope_permission_enum_spec.rb index 0494d7255b12de..f7feab69e29457 100644 --- a/spec/graphql/types/authz/granular_scope_permission_enum_spec.rb +++ b/spec/graphql/types/authz/granular_scope_permission_enum_spec.rb @@ -7,6 +7,6 @@ it 'exposes all the granular scope permissions available for access tokens' do expect(described_class.values.keys) - .to match_array(::Authz::Permission.all_for_tokens.map(&:name).map(&:upcase)) + .to match_array(::Authz::PermissionGroups::Assignable.all_permissions.uniq.map(&:to_s).map(&:upcase)) end end diff --git a/spec/lib/gitlab/graphql/authz/boundary_extractor_spec.rb b/spec/lib/gitlab/graphql/authz/boundary_extractor_spec.rb index 8bc9fb7f671c05..a5bbbe87501236 100644 --- a/spec/lib/gitlab/graphql/authz/boundary_extractor_spec.rb +++ b/spec/lib/gitlab/graphql/authz/boundary_extractor_spec.rb @@ -121,9 +121,21 @@ end end + context 'when boundary method is invalid' do + let(:directive) { create_directive(boundary: 'invalid_method') } + + it 'raises an error' do + expect { boundary }.to raise_error(ArgumentError, /Invalid boundary method: 'invalid_method'/) + end + end + context 'when boundary method does not exist' do let(:directive) { create_directive(boundary: 'nonexistent_method') } + before do + stub_const("#{described_class.name}::VALID_BOUNDARY_ACCESSOR_METHODS", ['nonexistent_method']) + end + it 'raises an error' do expect { boundary }.to raise_error(ArgumentError, /Boundary method 'nonexistent_method' not found/) end diff --git a/spec/lib/gitlab/graphql/authz/directive_finder_spec.rb b/spec/lib/gitlab/graphql/authz/directive_finder_spec.rb index 38eabbc2cc646f..8d9aae5e6c15c1 100644 --- a/spec/lib/gitlab/graphql/authz/directive_finder_spec.rb +++ b/spec/lib/gitlab/graphql/authz/directive_finder_spec.rb @@ -71,6 +71,19 @@ it { is_expected.to be_nil } end + + context 'when field owner is nil' do + let(:field) { create_base_field(owner: nil) } + + it { is_expected.to be_nil } + end + + context 'when field owner kind is nil' do + let(:owner) { class_double(GraphQL::Schema::Object, kind: nil) } + let(:field) { create_base_field(owner: owner) } + + it { is_expected.to be_nil } + end end context 'when directive is on the return type' do diff --git a/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb b/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb index 5f25c7134b8e9a..3e7ee167814b90 100644 --- a/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb +++ b/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb @@ -54,6 +54,12 @@ it { is_expected.to be true } end + + context 'when return type is a wrapped permission type' do + let(:field_type) { [Types::PermissionTypes::Project] } + + it { is_expected.to be true } + end end end end -- GitLab From d714d94e10f32e124af1eefa49543a238016a744 Mon Sep 17 00:00:00 2001 From: Alex Buijs Date: Tue, 16 Dec 2025 09:37:35 +0100 Subject: [PATCH 3/5] Add permissions check --- app/graphql/types/authz/granular_scope_permission_enum.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/graphql/types/authz/granular_scope_permission_enum.rb b/app/graphql/types/authz/granular_scope_permission_enum.rb index 49533c2af89cc6..9a48815ddea2bb 100644 --- a/app/graphql/types/authz/granular_scope_permission_enum.rb +++ b/app/graphql/types/authz/granular_scope_permission_enum.rb @@ -8,6 +8,13 @@ class GranularScopePermissionEnum < Types::BaseEnum ::Authz::PermissionGroups::Assignable.all_permissions.uniq.each do |permission| raw_permission = ::Authz::Permission.get(permission) + # rubocop:disable Gitlab/NoCodeCoverageComment -- this code is run at load time and cannot be tested with mocking + # :nocov: + next unless raw_permission + + # :nocov: + # rubocop:enable Gitlab/NoCodeCoverageComment + value raw_permission.name.upcase, value: raw_permission.name, description: raw_permission.description end end -- GitLab From e42a3c177e73b5e54354d4f0fc37ce79483657b5 Mon Sep 17 00:00:00 2001 From: Alex Buijs Date: Wed, 17 Dec 2025 13:01:12 +0100 Subject: [PATCH 4/5] Add a feature flag --- .../granular_personal_access_tokens_for_graphql.yml | 10 ++++++++++ .../graphql/authz/granular_token_authorization.rb | 7 ++++++- .../graphql/authz/granular_token_authorization_spec.rb | 8 ++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 config/feature_flags/wip/granular_personal_access_tokens_for_graphql.yml diff --git a/config/feature_flags/wip/granular_personal_access_tokens_for_graphql.yml b/config/feature_flags/wip/granular_personal_access_tokens_for_graphql.yml new file mode 100644 index 00000000000000..d6c70599da91bd --- /dev/null +++ b/config/feature_flags/wip/granular_personal_access_tokens_for_graphql.yml @@ -0,0 +1,10 @@ +--- +name: granular_personal_access_tokens_for_graphql +description: Enables Granular Personal Access Tokens for GraphQL requests +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/571510 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/211322 +rollout_issue_url: +milestone: '18.8' +group: group::authorization +type: wip +default_enabled: false diff --git a/lib/gitlab/graphql/authz/granular_token_authorization.rb b/lib/gitlab/graphql/authz/granular_token_authorization.rb index 7d27351046cc78..655c65e4ad0336 100644 --- a/lib/gitlab/graphql/authz/granular_token_authorization.rb +++ b/lib/gitlab/graphql/authz/granular_token_authorization.rb @@ -16,7 +16,7 @@ def resolve(object:, arguments:, context:, **rest) private def authorize_field(object, arguments, context) - return unless context[:access_token].try(:granular?) + return unless authorization_enabled?(context) return if SkipRules.new(@field).should_skip? directive = DirectiveFinder.new(@field).find(object) @@ -26,6 +26,11 @@ def authorize_field(object, arguments, context) authorize_with_cache!(context, boundary, permissions) end + def authorization_enabled?(context) + token = context[:access_token] + token && token.try(:granular?) && Feature.enabled?(:granular_personal_access_tokens_for_graphql, token.user) + end + def authorize_with_cache!(context, boundary, permissions) cache = context[:authz_cache] ||= Set.new cache_key = [permissions&.sort, boundary&.class, boundary&.namespace&.id] diff --git a/spec/lib/gitlab/graphql/authz/granular_token_authorization_spec.rb b/spec/lib/gitlab/graphql/authz/granular_token_authorization_spec.rb index 64dbcf9fa0be53..1db94cf4211d37 100644 --- a/spec/lib/gitlab/graphql/authz/granular_token_authorization_spec.rb +++ b/spec/lib/gitlab/graphql/authz/granular_token_authorization_spec.rb @@ -43,6 +43,14 @@ it { is_expected.to eq('field_value') } end + context 'when the `granular_personal_access_tokens_for_graphql` flag is disabled' do + before do + stub_feature_flags(granular_personal_access_tokens_for_graphql: false) + end + + it { is_expected.to eq('field_value') } + end + context 'when field authorization should be skipped' do before do allow_next_instance_of(Gitlab::Graphql::Authz::SkipRules, field) do |skip_rules| -- GitLab From 4ab1b5daadc0451024923dda6a4758245c10f8a1 Mon Sep 17 00:00:00 2001 From: Alex Buijs Date: Wed, 17 Dec 2025 17:28:25 +0100 Subject: [PATCH 5/5] Fix undercoverage error --- spec/lib/gitlab/graphql/authz/skip_rules_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb b/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb index 3e7ee167814b90..d4ffcf15eaaafd 100644 --- a/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb +++ b/spec/lib/gitlab/graphql/authz/skip_rules_spec.rb @@ -60,6 +60,14 @@ it { is_expected.to be true } end + + context 'when return type is not a class' do + before do + allow(skip_rules).to receive(:unwrap_type).and_return('NotAClass') + end + + it { is_expected.to be false } + end end end end -- GitLab