doc/development/permissions/granular_access/graphql_architecture.md
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.
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.
lib/gitlab/graphql/authz/granular_token_authorization.rbTypes::BaseFieldapp/graphql/directives/authz/granular_scope.rbpermissions: 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.lib/gitlab/graphql/authz/directive_finder.rbTypeUnwrapper module for unwrapping GraphQL type wrapperslib/gitlab/graphql/authz/boundary_extractor.rblib/gitlab/graphql/authz/type_unwrapper.rblib/gitlab/graphql/authz/authorize_granular_token.rbauthorize_granular_token helper method for cleaner directive syntaxTypes::BaseObject, Types::BaseField, and Mutations::BaseMutationauthorize_granular_token(permissions:, boundary_type:, boundary: nil, boundary_argument: nil)gitlab:permissions:validate Rake task against Authz::PermissionGroups::Assignable.all_permissions.1. GraphQL request arrives (query or mutation)
2. GraphQL Ruby begins parsing and validation
3. Execution begins with root fields
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 HERE
Step 1: Early Exit Conditions
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
createIssue.issue). Authorization
happens on the mutation itself, not the response wrapper.issue.userPermissions). These
return permission information, not actual data.groupMembers.nodes,
groupMembers.cursor). These traverse to the underlying node type, which
enforces authorization on its own fields.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_DEFINITION): Applied directly to the fieldobject is providedIssue) from GitlabSchema.types[Type] → TypeType! → TypeTypeConnection → Type (for example, IssueConnection → IssueType)boundary_argument and boundary strategiesboundary with an :id argument, enables ID fallback for boundary extractionOBJECT): Applied to the type that owns the field
project.languages → RepositoryLanguageType) 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
boundary = BoundaryExtractor.new(object:, arguments:, context:, directive:).extract
permissions = directive.arguments[:permissions]
[!note] When no directive is found,
boundaryandpermissionsare bothnil. The authorization service will return the error message: "Unable to determine boundaries and permissions for authorization".
The boundary extractor behavior:
boundary: 'user' or boundary: 'instance'): Returns Authz::Boundary::NilBoundaryProjectBoundary or GroupBoundary)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 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.
# 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:
# 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:
boundary: 'project':id argument 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 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:
boundary: 'user' (user-scoped resources)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 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
authorize_with_cache!(context, boundary, permissions)
This method:
Checks cache: context[:authz_cache] to avoid duplicate checks.
Calls authorization service:
::Authz::Tokens::AuthorizeGranularScopesService.new(
boundaries: boundary,
permissions: permissions,
token: context[:access_token]
).execute
Verifies: Token has required permissions for the boundary.
Raises error if unauthorized: raise_resource_not_available_error!(response.message).
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
yield(object, arguments, **rest)
If authorization passes, the field resolver executes and returns its value.
boundary_argumentGraphQL 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
end
Timeline:
createIssue fieldobject = nil (root mutation field)arguments[:input][:project_path]Project.find_by_full_path("gitlab-org/gitlab") → Projectcreate_issue permission for this project?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
end
Timeline (for title field):
title fieldobject = Issue instance (already resolved)IssueType (owner of title field)issue.projectread_issue permission for this project?description, etc.) - no additional DB queriesGraphQL Request:
query {
issue(id: "gid://gitlab/Issue/123") {
title
}
}
Directive:
class IssueType < BaseObject
authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
end
Timeline:
issue field (returns IssueType)object = nil (root query field)IssueType):id argument presentissue.projectread_issue permission for this project?traversal: trueGraphQL Request:
query {
group(fullPath: "gitlab-org") {
groupMembers {
nodes {
id
}
}
}
}
Entry-point directive on Query.group:
field :group, Types::GroupType,
resolver: Resolvers::GroupResolver,
directives: granular_scope_directive(
permissions: :read_group, boundary_argument: :full_path, boundary_type: :group,
traversal: true
)
Timeline:
group field.traversal: true.arguments[:full_path] ("gitlab-org").token.can?(:read_boundary, boundary). The read_group permission is not
enforced.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.nodes field. Skipped as an edge wrapper.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.
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 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.
traversal: trueTop-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:
boundary_argument as usual.token.can?(:read_boundary, boundary). The permissions argument is not
enforced. It remains in the directive for documentation.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.
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]
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: nilreturn unless authorization_enabled?(context)
return if SkipRules.new(@field).should_skip?
When authorization fails:
raise_resource_not_available_error!(response.message)
For GraphQL:
errors arraynullExample response:
{
"data": { "issue": null },
"errors": [{
"message": "Insufficient permissions",
"path": ["issue"]
}]
}
No directive found (with granular PAT)
boundary: nil, permissions: nil"Unable to determine boundaries and permissions for authorization"Directive has empty permissions array
permissions: [] (boundary provided)"Unable to determine permissions for authorization"permissions: []Boundary extraction returns nil (resource not found)
boundary: nil (permissions still provided)"Unable to determine boundaries for authorization"issue.project returns nil)boundary nor boundary_argument configuredNilBoundary objectInvalid GlobalID format
GlobalID.parse("invalid") returns nilnil → authorization error"Unable to determine boundaries for authorization"Boundary method returns nil
issue.project returns nilnil → authorization error"Unable to determine boundaries for authorization"GlobalID points to non-existent record
GlobalID::Locator.locate(gid) raises ActiveRecord::RecordNotFound, rescued and returns nilnil → authorization error"Unable to determine boundaries for authorization"Invalid boundary method
ArgumentError: "Invalid boundary method: 'foo'"boundary value not in the valid accessor methods (project, group, itself)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:
:id argument, uses ID fallback insteadExample:
# IssueType has: boundary: 'project'
# Field: project.issue(iid: "1")
# object = Project (not Issue)
# Project matches 'project' → returns Project
Invalid permission name
gitlab:permissions:validate Rake taskAuthz::PermissionGroups::Assignable.all_permissionsMultiple directives found