Back to Gitlabhq

GraphQL Granular Token Authorization Architecture

doc/development/permissions/granular_access/graphql_architecture.md

19.1.024.2 KB
Original Source

This document explains how the GranularTokenAuthorization field extension works to enforce granular Personal Access Token (PAT) permissions on GraphQL queries and mutations. For a step-by-step implementation guide, see GraphQL implementation guide.

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.

Feature Flag: This feature requires the granular_personal_access_tokens feature flag to be enabled for the token's user. When the flag is disabled, granular PATs do not work for GraphQL requests.

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 (for example, ['read_issue']).
    • boundary: Method name to extract boundary from resolved object.
    • boundary_argument: Argument name containing the boundary.
    • boundary_type: The type of authorization boundary (project, group, user, instance). Used for validation and documentation of the permission boundary.
    • traversal: When true, the directive verifies only that the token is scoped to the boundary (read_boundary). The listed permissions are not enforced on the field itself. Use for entry-point fields like Query.group(fullPath:) where downstream fields enforce the real permissions.

3. Directive Finder

  • Location: lib/gitlab/graphql/authz/directive_finder.rb
  • Purpose: Locates applicable directives in priority order: field, implementing type, return type, owner 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 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

6. Helper Module

  • Location: lib/gitlab/graphql/authz/authorize_granular_token.rb
  • Purpose: Provides the authorize_granular_token helper method for cleaner directive syntax
  • Included in: Types::BaseObject, Types::BaseField, and Mutations::BaseMutation
  • Method: authorize_granular_token(permissions:, boundary_type:, boundary: nil, boundary_argument: nil)
  • Validation: Permissions are validated by the gitlab:permissions:validate Rake task against Authz::PermissionGroups::Assignable.all_permissions.

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 authorization_enabled?(context)  # Only authorize granular PATs
  return if SkipRules.new(@field).should_skip?  # Skip certain fields
  # ...
end

def authorization_enabled?(context)
  token = context[:access_token]
  token && token.try(:granular?)
end
  • 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 (for example, createIssue.issue). Authorization happens on the mutation itself, not the response wrapper.
    • Permission metadata fields (for example, issue.userPermissions). These return permission information, not actual data.
    • Edge wrapper fields (for example, groupMembers.nodes, groupMembers.cursor). These traverse to the underlying node type, which enforces authorization on its own fields.
    • Traversal to an authorized return type. When a field has no own directive, its owner type carries a directive, and the unwrapped return type carries its own directive and has deeper authorized sub-fields, the owner-level directive is skipped. The return type's directive applies when fields on the child object are resolved. Leaf return types (all scalar fields) are not skipped because an empty result would bypass the check entirely. For more details, see Traversal to an authorized return type.

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
  2. 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 (for example, Issue) from GitlabSchema.types
  3. Return type directive: Applied to the type returned by the field
    • Unwraps GraphQL type wrappers to find the base type:
      • List types: [Type]Type
      • NonNull types: Type!Type
      • Connection types: TypeConnectionType (for example, IssueConnectionIssueType)
    • Works with both boundary_argument and boundary strategies
    • When using boundary with an :id argument, enables ID fallback for boundary extraction
  4. Owner type directive (OBJECT): Applied to the type that owns the field
    • Checked last so that a field leading to a leaf authorized type (for example, project.languagesRepositoryLanguageType) uses that type's directive rather than the containing type's directive (for example, read_project). This ensures the correct permission is enforced even when the result is empty.

Step 3: Boundary Extraction

ruby
boundary = BoundaryExtractor.new(object:, arguments:, context:, directive:).extract
permissions = directive.arguments[:permissions]

[!note] When no directive is found, boundary and permissions are both nil. The authorization service will return the error message: "Unable to determine boundaries 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)

The boundary method must be one of the valid accessor methods: project, group, or itself. An ArgumentError is raised for any other value.

ruby
# Directive says: boundary: 'project'
# Object: Issue instance

extract_from_method('project')
  ↓
unwrap_object(object)  # Issue
  ↓
object_matches_boundary_type?('project')  # false (Issue ≠ Project)VALID_BOUNDARY_ACCESSOR_METHODS.include?('project')  # true
  ↓
object.respond_to?(:project) # true
  ↓
object.project
  ↓
returns Project instance

When using boundary: 'itself', the object is returned as its own boundary. This is useful for types that are themselves a Project or Group:

ruby
# Directive says: boundary: 'itself'
# Object: Project instance

extract_from_method('itself')
  ↓
unwrap_object(object)  # Project
  ↓
object_matches_boundary_type?('itself')  # false (Project ≠ Itself)VALID_BOUNDARY_ACCESSOR_METHODS.include?('itself')  # true
  ↓
object.itself  # Ruby's Object#itself returns self
  ↓
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')
  ↓
@boundary_accessor.to_sym  # :userAuthz::Boundary.for(:user)
  ↓
returns Authz::Boundary::NilBoundary.new(:user)
  ↓
Authorization checks 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.

  2. Calls authorization service:

    ruby
    ::Authz::Tokens::AuthorizeGranularScopesService.new(
      boundaries: boundary,
      permissions: permissions,
      token: context[:access_token]
    ).execute
    
  3. Verifies: Token has required permissions for the boundary.

  4. Raises error if unauthorized: raise_resource_not_available_error!(response.message).

  5. Caches result to avoid redundant checks.

When the matched directive has traversal: true, the extension uses a separate authorization path that only verifies the boundary is visible to the token. For more details, see Entry-point fields with traversal: true.

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
  authorize_granular_token permissions: :create_issue, boundary_argument: :project_path, boundary_type: :project
end

Timeline:

  1. Extension called for createIssue field
  2. object = nil (root mutation field)
  3. Directive found on mutation class
  4. Boundary extracted from arguments[:input][:project_path]
  5. Project.find_by_full_path("gitlab-org/gitlab") → Project
  6. Authorization service checks: Does token have create_issue permission for this project?
  7. If yes: mutation executes
  8. 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
  authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
end

Timeline (for title field):

  1. Extension called for title field
  2. object = Issue instance (already resolved)
  3. Directive found on IssueType (owner of title field)
  4. Boundary extracted by calling issue.project
  5. Authorization service checks: Does token have read_issue permission for this project?
  6. Cache hit on subsequent fields (description, etc.) - no additional DB queries
  7. If yes: field resolves and returns title
  8. 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
  authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
end

Timeline:

  1. Extension called for issue field (returns IssueType)
  2. object = nil (root query field)
  3. Directive found on return type (IssueType)
  4. Boundary extraction detects: object is nil, but :id argument present
  5. Uses ID fallback: extracts GlobalID → locates Issue → gets issue.project
  6. Authorization service checks: Does token have read_issue permission for this project?
  7. If yes: field resolves (Issue is fetched again by resolver)
  8. If no: raises error before field resolution

Scenario 4: Entry-point field with traversal: true

GraphQL Request:

graphql
query {
  group(fullPath: "gitlab-org") {
    groupMembers {
      nodes {
        id
      }
    }
  }
}

Entry-point directive on Query.group:

ruby
field :group, Types::GroupType,
  resolver: Resolvers::GroupResolver,
  directives: granular_scope_directive(
    permissions: :read_group, boundary_argument: :full_path, boundary_type: :group,
    traversal: true
  )

Timeline:

  1. Extension called for group field.
  2. Directive resolved on the field, with traversal: true.
  3. Boundary extracted from arguments[:full_path] ("gitlab-org").
  4. Authorization service runs in traversal mode and verifies token.can?(:read_boundary, boundary). The read_group permission is not enforced.
  5. Extension called for groupMembers field. Owner is GroupType (which carries a read_group directive). Return type is GroupMemberType (which carries a read_member directive). The traversal skip applies, so no token check fires.
  6. Extension called for nodes field. Skipped as an edge wrapper.
  7. Extension called for id field on each GroupMember. Owner is GroupMemberType, which requires read_member. The token is checked for read_member against the group boundary.

The token reaches the members data with only read_member, matching the REST endpoint GET /api/v4/groups/:id/members.

Traversal to an authorized return type

A field on a granular-token-authorized type would otherwise inherit the owner type's directive. The owner directive becomes redundant when the field's unwrapped return type also carries a granular-token directive. The return type's directive enforces authorization when fields on the child object resolve. The SkipRules class detects this case and skips the owner-level check.

The skip applies when all of the following are true:

  • The field has no own granular-token directive (an explicit field-level directive always wins).
  • The field's owner type carries a granular-token directive.
  • The field's unwrapped return type (after stripping list, non-null, and connection wrappers) carries a granular-token directive.
  • The return type has at least one field whose own unwrapped return type carries a granular-token directive (that is, it is not a "leaf" type whose fields all return plain scalars).

The fourth condition is required for safety: when the return type is a leaf (all its fields return scalars, for example RepositoryLanguageType or PushRulesType), no per-item resolver fires for an empty collection or a nil result. Skipping the collection-level check would let an empty result bypass authorization entirely. For leaf types, the collection-level check is the only enforcement point, so the skip must not fire.

Effect: a token with only the child resource's permission can traverse to it through the parent, without also needing the parent's read permission. Data fields on the parent (which return scalars or other unauthorized types) still require the parent's permission.

Example: Group.groupMembers returns GroupMemberType. Both GroupType and GroupMemberType declare granular-token directives. Resolving group.groupMembers no longer requires read_group. Resolving any field on each GroupMember requires read_member. Resolving group.name (a scalar) still requires read_group.

Entry-point fields with traversal: true

Top-level fields like Query.group(fullPath:) and Query.project(fullPath:) exist to resolve a boundary from a path argument. They do not expose data themselves. Downstream fields enforce the actual permissions. Set traversal: true on the directive to declare this intent.

When traversal: true:

  • The boundary is resolved from boundary_argument as usual.
  • The authorization service runs in traversal mode and checks only token.can?(:read_boundary, boundary). The permissions argument is not enforced. It remains in the directive for documentation.
  • If no boundary resolves, or the boundary is not visible to the token, the service returns 404 Not Found and the field returns null with an error.

The traversal cache key is [:traversal, boundary.class, boundary.namespace&.id], separate from permission-based cache keys.

traversal: true only applies to project and group boundary types. For all other boundary types, the extension falls back to the regular permission check.

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 authorization_enabled?(context)
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 boundaries and permissions for authorization"
    • Note: All fields accessed with granular PATs must have directives
  2. 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 boundaries 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
  2. Invalid GlobalID format

    • Behavior: GlobalID.parse("invalid") returns nil
    • Result: Boundary extraction returns nil → authorization error
    • Error message: "Unable to determine boundaries for authorization"
    • Note: Fails gracefully without raising exceptions
  3. Boundary method returns nil

    • Behavior: issue.project returns nil
    • Result: Returns nil → authorization error
    • Error message: "Unable to determine boundaries for authorization"
    • Common causes: Soft-deleted associations, orphaned records
  4. 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 boundaries for authorization"

Configuration Errors

  1. Invalid boundary method

    • Behavior: Raises ArgumentError: "Invalid boundary method: 'foo'"
    • Cause: Using a boundary value not in the valid accessor methods (project, group, itself)
    • Note: This validation runs before checking if the object responds to the method
  2. Object doesn't respond to boundary method

    • Behavior: Raises ArgumentError: "Boundary method 'project' not found on Project"

    • Cause: Using a valid boundary method (e.g., boundary: 'project') but the object doesn't have that method

    • 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
      
  3. Invalid permission name

    • Behavior: Detected by the gitlab:permissions:validate Rake task
    • Cause: Using a permission symbol that doesn't exist in Authz::PermissionGroups::Assignable.all_permissions
    • Note: This validation runs as part of CI to ensure all directive permissions reference valid assignable permissions
  4. Multiple directives found

    • Behavior: Uses first match in priority order (field, implementing type, return type, owner).
    • 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. The owner-level directive is also skipped when the field traverses to an authorized return type with deeper authorized sub-fields. For more details, see Traversal to an authorized return type.

See Also