GraphQL Granular Token Authorization Architecture
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 both the granular_personal_access_tokens and the granular_personal_access_tokens_for_graphql feature flags to be enabled for the token’s user. When either one is disabled, granular PATs will 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 (e.g.,['read_issue'])boundary: Method name to extract boundary from resolved objectboundary_argument: Argument name containing the boundaryboundary_type: The type of authorization boundary (project,group,user,instance). Used for validation and documentation of the permission 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:
TypeUnwrappermodule 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_tokenhelper method for cleaner directive syntax - Included in:
Types::BaseObject,Types::BaseField, andMutations::BaseMutation - Method:
authorize_granular_token(permissions:, boundary_type:, boundary: nil, boundary_argument: nil) - Validation: Permissions are validated by the
gitlab:permissions:validateRake task againstAuthz::PermissionGroups::Assignable.all_permissions.
Request Flow Timeline
Phase 1: Request Initiation
1. GraphQL request arrives (query or mutation)
2. GraphQL Ruby begins parsing and validation
3. Execution begins with root fieldsPhase 2: Field Resolution (per field)
For each field being resolved:
1. GraphQL Ruby calls field extensions in order
├─ CallsGitaly::FieldExtension (dev/test only)
├─ Present::FieldExtension
├─ Authorize::FieldExtension
└─ GranularTokenAuthorization ← WE ARE HEREPhase 3: Authorization Check
Step 1: Early Exit Conditions
def authorize_field(object, arguments, context)
return unless authorization_enabled?(context) # Only authorize granular PATs with feature flag enabled
return if SkipRules.new(@field).should_skip? # Skip certain fields
# ...
end
def authorization_enabled?(context)
token = context[:access_token]
token && token.try(:granular?) && Feature.enabled?(:granular_personal_access_tokens_for_graphql, token.user)
end- If not using a granular PAT or feature flag is disabled, granular scope authorization is skipped (legacy PATs use existing scope authorization)
- The feature flag
:granular_personal_access_tokens_for_graphqlmust be enabled for the user - 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
- Mutation response fields (e.g.,
Step 2: Directive Discovery
directive = DirectiveFinder.new(@field).find(object)The DirectiveFinder checks for directives in this priority order, returning the first match found:
- Field-level directive (
FIELD_DEFINITION): Applied directly to the field - Owner type directive (
OBJECT): Applied to the type that owns the field - Implementing type directive (for interfaces): Applied to the concrete type implementing an interface
- Only checked when the field owner is an interface and an
objectis provided - Resolves the actual model type (e.g.,
Issue) fromGitlabSchema.types
- Only checked when the field owner is an interface and an
- 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)
- List types:
- Works with both
boundary_argumentandboundarystrategies - When using
boundarywith an:idargument, enables ID fallback for boundary extraction
Step 3: Boundary Extraction
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'orboundary: 'instance'): ReturnsAuthz::Boundary::NilBoundary - For valid project/group resources: Returns wrapped boundary (
ProjectBoundaryorGroupBoundary) - When resource not found: Returns
nil(not wrapped in NilBoundary)
Supported boundary types:
Authz::Boundary::ProjectBoundary- for Project resourcesAuthz::Boundary::GroupBoundary- for Group resourcesAuthz::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)
# 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 instanceStrategy 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.
# 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 instanceWhen using boundary: 'itself', the object is returned as its own boundary. This is useful for types that are themselves a Project or Group:
# 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 instanceStrategy 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
:idargument with GlobalID
# 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 instancePerformance 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)
# Directive says: boundary: 'user'
# Resource doesn't belong to a specific project/group
standalone_boundary?('user')
↓
@boundary_accessor.to_sym # :user
↓
Authz::Boundary.for(:user)
↓
returns Authz::Boundary::NilBoundary.new(:user)
↓
Authorization checks token has appropriate permissionsThis 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
authorize_with_cache!(context, boundary, permissions)This method:
Checks cache:
context[:authz_cache]to avoid duplicate checksCalls authorization service:
::Authz::Tokens::AuthorizeGranularScopesService.new( boundaries: boundary, permissions: permissions, token: context[:access_token] ).executeVerifies: Token has required permissions for the boundary
Raises error if unauthorized:
raise_resource_not_available_error!(response.message)Caches result to avoid redundant checks
Step 5: Field Resolution
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:
mutation {
createIssue(input: {
projectPath: "gitlab-org/gitlab",
title: "New issue"
}) {
issue { id }
}
}Directive:
class Create < BaseMutation
authorize_granular_token permissions: :create_issue, boundary_argument: :project_path, boundary_type: :project
endTimeline:
- Extension called for
createIssuefield object=nil(root mutation field)- Directive found on mutation class
- Boundary extracted from
arguments[:input][:project_path] Project.find_by_full_path("gitlab-org/gitlab")→ Project- Authorization service checks: Does token have
create_issuepermission for this project? - If yes: mutation executes
- If no: raises error, mutation doesn’t execute
Scenario 2: Type with boundary (nested field)
GraphQL Request:
query {
project(fullPath: "gitlab-org/gitlab") {
issues {
nodes {
title # ← Authorization here
description # ← And here
}
}
}
}Directive:
class IssueType < BaseObject
authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
endTimeline (for title field):
- Extension called for
titlefield object= Issue instance (already resolved)- Directive found on
IssueType(owner oftitlefield) - Boundary extracted by calling
issue.project - Authorization service checks: Does token have
read_issuepermission for this project? - Cache hit on subsequent fields (
description, etc.) - no additional DB queries - If yes: field resolves and returns title
- If no: raises error
Scenario 3: Query field with ID fallback
GraphQL Request:
query {
issue(id: "gid://gitlab/Issue/123") {
title
}
}Directive:
class IssueType < BaseObject
authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
endTimeline:
- Extension called for
issuefield (returns IssueType) object=nil(root query field)- Directive found on return type (
IssueType) - Boundary extraction detects: object is nil, but
:idargument present - Uses ID fallback: extracts GlobalID → locates Issue → gets
issue.project - Authorization service checks: Does token have
read_issuepermission for this project? - If yes: field resolves (Issue is fetched again by resolver)
- If no: raises error before field resolution
Performance Optimizations
1. Caching
Per-Request Cache:
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 stringsboundary&.class: The boundary wrapper class (e.g.,Authz::Boundary::ProjectBoundary)boundary&.namespace&.id: The namespace ID (varies by boundary type):ProjectBoundary:project.project_namespace.idGroupBoundary:group.idNilBoundary:nil
2. Early Returns
return unless authorization_enabled?(context)
return if SkipRules.new(@field).should_skip?- Non-granular tokens skip the entire system (zero overhead)
- Feature flag check:
granular_personal_access_tokens_for_graphqlmust be enabled - Mutation response fields and permission metadata fields are automatically skipped (see Phase 3, Step 1 for details)
Error Handling
Authorization Failures
When authorization fails:
raise_resource_not_available_error!(response.message)For GraphQL:
- Returns service error in
errorsarray - Field returns
null
Example response:
{
"data": { "issue": null },
"errors": [{
"message": "Insufficient permissions",
"path": ["issue"]
}]
}Edge Cases and Error Scenarios
Missing Configuration Errors
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
- Behavior: Authorization proceeds with
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: []
- Behavior: Authorization proceeds with
Boundary Resolution Errors
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.projectreturnsnil) - Directive has neither
boundarynorboundary_argumentconfigured
- Note: This is different from standalone boundaries which return
NilBoundaryobject
- Behavior: Authorization proceeds with
Invalid GlobalID format
- Behavior:
GlobalID.parse("invalid")returnsnil - Result: Boundary extraction returns
nil→ authorization error - Error message:
"Unable to determine boundaries for authorization" - Note: Fails gracefully without raising exceptions
- Behavior:
Boundary method returns nil
- Behavior:
issue.projectreturnsnil - Result: Returns
nil→ authorization error - Error message:
"Unable to determine boundaries for authorization" - Common causes: Soft-deleted associations, orphaned records
- Behavior:
GlobalID points to non-existent record
- Behavior:
GlobalID::Locator.locate(gid)raisesActiveRecord::RecordNotFound, rescued and returnsnil - Result: Boundary extraction returns
nil→ authorization error - Error message:
"Unable to determine boundaries for authorization"
- Behavior:
Configuration Errors
Invalid boundary method
- Behavior: Raises
ArgumentError: "Invalid boundary method: 'foo'" - Cause: Using a
boundaryvalue not in the valid accessor methods (project,group,itself) - Note: This validation runs before checking if the object responds to the method
- Behavior: Raises
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 methodExceptions:
- If field has
:idargument, uses ID fallback instead - If object type matches boundary name, returns object directly
- If field has
Example:
# IssueType has: boundary: 'project' # Field: project.issue(iid: "1") # object = Project (not Issue) # Project matches 'project' → returns Project
Invalid permission name
- Behavior: Detected by the
gitlab:permissions:validateRake 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
- Behavior: Detected by the
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