doc/development/permissions/granular_access/graphql_architecture.md
This document explains how GranularScopeAuthorization enforces granular Personal Access Token (PAT) permissions on GraphQL types, fields, 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 based on authorize_granular_token directives applied to types, fields, and mutations.
It ensures that granular PATs can only access resources they have explicit permissions for within specific project, group, user, or instance 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_scope_authorization.rbauthorize_granular_token directives declared on a type, field, or mutation against the request's granular PATok?(object, context, arguments: nil): Returns true or false. Used for type-level and field-level authorization.authorize!(object, context, arguments: nil): Raises Gitlab::Graphql::Errors::ArgumentError with the denial message when authorization fails. Used for mutations.app/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/boundary_extractor.rb:user, or :instance, but is not limited to these.boundary_argument read from the field or mutation arguments, all other directives read from the already-resolved object.lib/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.app/services/authz/tokens/authorize_granular_scopes_service.rbServiceResponse.success. On failure, a ServiceResponse.error with an error message describing which permissions the token is missing.Authorization runs at three points, each constructing GranularScopeAuthorization from the directives declared at that level.
| Level | Entry point | Directives source | On failure |
|---|---|---|---|
| Type | Types::BaseObject.authorized? | The type's directives | Returns false, which denies access |
| Field | Types::BaseField#field_authorized? | The field's directives | Returns false for query fields, raises a resource-not-available error for MutationType fields |
| Mutation | Mutations::BaseMutation#authorized? | The mutation class's directives | Raises Gitlab::Graphql::Errors::ArgumentError |
At each entry point, GranularScopeAuthorization evaluates the request in these steps:
Step 1: Token check
The check applies only to granular PATs. Legacy PATs pass this step and rely on the existing scope authorization, unless enforcement applies (see Phase 3).
Step 2: Directive check
If no granular directives are declared, authorization fails.
Step 3: Boundary extraction
The boundaries are extracted from the source each directive declares: the resolved object for type-level checks, or the arguments for mutations and root query fields. For details, see Boundary extraction.
Step 4: Authorization
AuthorizeGranularScopesService checks whether the token holds the required permissions on the extracted boundaries, and the result is cached.
Granular permission checks also apply to legacy (non-granular) tokens when a boundary's root namespace enforces granular tokens.
AuthorizeGranularScopesService reads each boundary's root namespace setting through granular_tokens_enforced?:
Each directive declares where its boundary comes from, and BoundaryExtractor reads from that source.
Type-level checks authorize an object that GraphQL has already resolved, so the boundary is reached by calling a method on that object.
Mutations and root query fields authorize before any object is resolved, so the boundary must be derived from the arguments.
Directives without a boundary_argument read from the resolved object.
The extractor reads the boundary method from the directive and resolves the boundary from the already-resolved object:
boundary: :itself returns the object as its own boundary. Use this for types that are themselves a Project or Group.For example, ProjectType is its own boundary:
authorize_granular_token permissions: :read_project, boundary: :itself, boundary_type: :project
# boundary => the Project itself
IssueType reaches its boundary through a method:
authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
# boundary => issue.project
Directives with a boundary_argument read from the arguments.
This applies to mutations and to root query fields, where no parent object exists to reach a boundary from.
The extractor reads the boundary_argument from the directive and locates the record:
GitlabSchema.find_by_gid (forced with Gitlab::Graphql::Lazy), so the lookup participates in GraphQL batch loading.Project.find_by_full_path or Group.find_by_full_path.boundary method called on it to reach the Project or Group.When the argument resolves to a Project or Group directly, only boundary_argument is needed:
authorize_granular_token permissions: :create_issue, boundary_argument: :project_path, boundary_type: :project
# boundary => Project.find_by_full_path(project_path)
When the argument resolves to some other record, add boundary to reach the Project or Group from it:
authorize_granular_token permissions: :create_note, boundary_argument: :id, boundary: :project, boundary_type: :project
# boundary => located_record.project
A type can declare more than one boundary when the same resource can belong to different boundary types.
A concrete boundary is a specific Project or Group record.
A standalone boundary is the user or instance boundary type, which does not belong to a project or group.
Concrete boundaries take precedence.
A standalone boundary is used only when no concrete boundary resolves.
A directive is also skipped when its resolved object does not match its declared boundary_type, so the same boundary method can serve more than one type.
For example, Ci::RunnerType declares three boundaries because a runner can belong to a project, belong to a group, or be instance-wide:
authorize_granular_token(
permissions: :read_runner,
boundaries: [
{ boundary: :owner, boundary_type: :project },
{ boundary: :owner, boundary_type: :group },
{ boundary: :instance, boundary_type: :instance }
]
)
The boundary that applies depends on the runner:
runner.owner is a Project, so the project boundary resolves and is used.runner.owner is a Group, so the group boundary resolves and is used.runner.owner is neither a Project nor a Group, so both concrete directives are skipped. The standalone instance boundary is used instead, and the token is checked for read_runner on the instance.The standalone boundary is preferred over the concrete boundaries only in the last case, where the runner has no owning project or group.
boundary_argumentA createIssue mutation passes the project as a path argument:
mutation {
createIssue(input: { projectPath: "gitlab-org/gitlab", title: "New issue" }) {
issue { id }
}
}
Mutations::Issues::Create declares:
authorize_granular_token permissions: :create_issue, boundary_argument: :project_path, boundary_type: :project
Mutations::BaseMutation#authorized? before the mutation runs.BoundaryExtractor reads project_path and resolves the Project through Project.find_by_full_path.AuthorizeGranularScopesService checks whether the token has create_issue on that project.Gitlab::Graphql::Errors::ArgumentError is raised.boundaryA query reads fields on resolved issues:
query {
project(fullPath: "gitlab-org/gitlab") {
issues {
nodes { title }
}
}
}
IssueType declares:
authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
Issue is resolved, Types::BaseObject.authorized? runs for IssueType.BoundaryExtractor calls issue.project to reach the boundary.AuthorizeGranularScopesService checks whether the token has read_issue on that project.false.boundaryA createNote mutation passes a GlobalID, and the boundary is reached from the located record:
authorize_granular_token permissions: :create_note, boundary_argument: :id, boundary: :project, boundary_type: :project
For the argument id: "gid://gitlab/Issue/1", BoundaryExtractor locates the Issue through GitlabSchema.find_by_gid, then calls issue.project to reach the boundary.
boundary_argumentA root query field resolves computed data for a namespace passed as an argument, so there is no parent object and no resolved record to reach a boundary from:
query {
aiToolRules(fullPath: "gitlab-org") {
nodes { id }
}
}
The field's resolver declares:
authorize_granular_token permissions: :read_ai_tool_rule, boundary_argument: :full_path, boundary_type: :group
Types::BaseField#authorized? with the field arguments before the resolver runs.BoundaryExtractor reads full_path and resolves the Group through Group.find_by_full_path.AuthorizeGranularScopesService checks whether the token has read_ai_tool_rule on that group.Directives declared on a resolver class are applied to the fields the resolver is mounted on, so authorize_granular_token can be declared either on the resolver or on the field definition itself with directives: granular_scope_directive(...).
Authorization results are cached per request. Multiple fields that resolve to the same boundary and permissions reuse the cached result, so the authorization service runs only once for them.
Resolving a boundary from each node in a collection one at a time produces an N+1 query.
For example, if IssueType declares authorize_granular_token permissions: :read_issue, boundary: :project, then for each resolved issue object the extractor calls issue.project, which creates an N+1 problem.
BoundaryExtractors::Preloader solves this by preloading the collection's nodes before authorization runs:
Gitlab::SafeRequestStore.Legacy tokens are blocked from accessing a namespace's resources when the namespace setting that enforces granular tokens is enabled. For more information, see issue 20180.
Instead of loading the root namespace of a boundary and its settings on every check, GitLab preloads them and stores the granular_tokens_enforced? value in a cache.
AuthorizeGranularScopesService later reads this cached value.
When authorization fails:
Gitlab::Graphql::Errors::ArgumentError, with an error message that tells the user which granular permissions the token is missing. The message is carried into the GraphQL response errors array.false, which denies access to the object.false for query fields, which denies access. Fields on MutationType instead raise a resource-not-available error, so the mutation response populates errors.{
"data": { "issue": null },
"errors": [{
"message": "Access denied: This operation requires a fine-grained personal access token with the following project permissions: [Issue: Read].",
"path": ["issue"]
}]
}
The extractor returns an empty array when it cannot resolve a boundary.
This happens when a path or GlobalID does not match a record, when a boundary method returns nil, or when the directive is missing the boundary or boundary_argument it needs.
AuthorizeGranularScopesService treats an empty boundary set with requested permissions as an unresolved resource and returns a 404 Not Found error.
Returning 404 Not Found rather than a permission error avoids disclosing whether the resource exists.
These errors indicate that a directive is misconfigured, and are surfaced during development or validation rather than at request time:
InvalidInputError in AuthorizeGranularScopesService, and is also caught by the gitlab:permissions:validate Rake task against Authz::PermissionGroups::Assignable.all_permissions.boundaries: entry that is not a Hash with a boundary_type key raises ArgumentError.traversal: true to a type-level authorize_granular_token raises ArgumentError. Use granular_scope_directive(traversal: true) on the field definition instead.