Add GraphQL granular token authorization

What does this MR do and why?

Add support for authorizing granular tokens for GraphQL queries and mutations.

The included documentation describes all the components that are added in this MR.

This flowchart gives a quick summary of the auth flow:

Click to expand
GraphQL Request

┌─────────────────────────────────────────────────────────────┐
│ GranularTokenAuthorization Extension (on every field)       │
│ - Automatically applied via BaseField                       │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 1. Check Token Type                                         │
│    - Is token granular? (token.granular?)                   │
│    - If NO → Skip authorization                             │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 2. Check Skip Rules                                         │
│    - Is this a mutation response field? (field :issue)      │
│    - Is this a permission metadata field? (userPermissions) │
│    - If YES → Skip authorization                            │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 3. Find Directive (DirectiveFinder)                         │
│    Check in order:                                          │
│    a) Field itself                                          │
│    b) Owner (mutation class, type class)                    │
│    c) Implementing type (for interfaces)                    │
│    d) Return type                                           │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 4. Extract Boundary (BoundaryExtractor)                     │
│    - If boundary_argument → Extract from arguments          │
│    - If boundary → Call method on object                    │
│    - Returns: Project/Group boundary or nil.                │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 5. Authorize (AuthorizeGranularScopesService)               │
│    - Check feature flag (granular_personal_access_tokens)   │
│    - Check token has required permissions                   │
│    - Check token has access to boundary                     │
│    - Cache result to avoid duplicate checks                 │
└─────────────────────────────────────────────────────────────┘

   Success → Execute field resolver
   Failure → Raise ResourceNotAvailable error

Reference MR that implements an authorization directive on a query and a mutation: !214111

References

Issue: #571510

How to set up and validate locally

  1. Copy this patch and execute pbpaste | git apply

    Click to expand
    diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb
    index ebe24c2aab5a..5611fa328fe1 100644
    --- a/app/graphql/mutations/issues/create.rb
    +++ b/app/graphql/mutations/issues/create.rb
    @@ -11,2 +11,4 @@ class Create < BaseMutation
     
    +      directive Directives::Authz::GranularScope, permissions: ['CREATE_ISSUE'], boundary_argument: 'project_path'
    +
           authorize :create_issue
    diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
    index 07fdc994a5ac..8257d8be71f1 100644
    --- a/app/graphql/types/issue_type.rb
    +++ b/app/graphql/types/issue_type.rb
    @@ -12,2 +12,4 @@ class IssueType < BaseObject
     
    +    directive Directives::Authz::GranularScope, permissions: ['READ_ISSUE'], boundary: 'project'
    +
         authorize :read_issue
    diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
    index 90c4e8867285..49804712e85e 100644
    --- a/app/graphql/types/query_type.rb
    +++ b/app/graphql/types/query_type.rb
    @@ -157,3 +157,9 @@ def self.authorization_scopes
           description: "Find a project.",
    -      scopes: [:api, :read_api, :ai_workflows]
    +      scopes: [:api, :read_api, :ai_workflows],
    +      directives: {
    +        Directives::Authz::GranularScope => {
    +          permissions: ['READ_PROJECT'],
    +          boundary_argument: 'full_path'
    +        }
    +      }
         field :projects,
    diff --git a/config/authz/permissions/issue/read.yml b/config/authz/permissions/issue/read.yml
    new file mode 100644
    index 000000000000..a8719e0dd3bf
    --- /dev/null
    +++ b/config/authz/permissions/issue/read.yml
    @@ -0,0 +1,5 @@
    +---
    +name: read_issue
    +description: Grants the ability to read issues
    +feature_category: team_planning
    +available_for_tokens: true
    diff --git a/config/authz/permissions/project/read.yml b/config/authz/permissions/project/read.yml
    new file mode 100644
    index 000000000000..06e90a82c84d
    --- /dev/null
    +++ b/config/authz/permissions/project/read.yml
    @@ -0,0 +1,5 @@
    +---
    +name: read_project
    +description: Grants the ability to read projects
    +feature_category: team_planning
    +available_for_tokens: true
    
  2. In Rails console, enable the granular_personal_access_tokens FF and create a granular PAT with a granular scope for a user.

# Enable feature flag
Feature.enable(:granular_personal_access_tokens)

user = User.human.last

# Create granular token
token = PersonalAccessTokens::CreateService.new(
  current_user: user,
  target_user: user,
  organization_id: user.organization_id,
  params: { expires_at: 1.month.from_now, scopes: ['granular'], granular: true, name: 'gPAT' }
).execute[:personal_access_token]

# Get the boundary object (project or group)
project = user.projects.first

# Create a granular scope and add it to the granular PAT
scope = Authz::GranularScope.new(namespace: project.project_namespace, permissions: [:read_project, :read_issue, :create_issue])
Authz::GranularScopeService.new(token).add_granular_scopes(scope)

# Copy the PAT to the clipboard
IO.popen('pbcopy', 'w') { |f| f.puts token.token }
  1. Verify GraphQL mutations to create issues within the project are working (replace my-user/my-project with project.full_path and PAT with the copied token):

    curl http://gdk.test:3000/api/graphql -X POST \
      -H "PRIVATE-TOKEN: PAT" \
      -H 'Content-Type: application/json' \
      -d '{"query":"mutation { createIssue(input: { projectPath: \"my-user/my-project\", title: \"Test Issue\" }) { issue { iid title } errors } }"}'

    output:

    => {"data":{"createIssue":{"issue":{"iid":"1","title":"Test Issue"},"errors":[]}}}
  2. Verify GraphQL queries to lookup issue titles within the project are working (replace my-user/my-project with project.full_path and PAT with the copied token):

    curl http://gdk.test:3000/api/graphql -X POST \
      -H "PRIVATE-TOKEN: PAT" \
      -H 'Content-Type: application/json' \
      -d '{"query":"{ project(fullPath: \"my-user/my-project\") { issues { nodes { title } } } }"}'

    output:

    => {"data":{"project":{"issues":{"nodes":[{"title":"Test Issue"}]}}}}

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 Alex Buijs

Merge request reports

Loading