GraphQL 세분화 토큰 인가 아키텍처
GitLab v19.2요약
이 문서는 GranularScopeAuthorization이 GraphQL 타입, 필드, 뮤테이션에서 세분화된 개인 액세스 토큰(PAT) 권한을 적용하는 방식을 설명합니다. 세분화 토큰 인가 시스템은 타입, 필드, 뮤테이션에 적용된 authorize_granular_token 지시어(directive)를 기반으로 GraphQL에 세밀한 권한 검사를 추가합니다.
이 문서는 GranularScopeAuthorization이 GraphQL 타입, 필드, 뮤테이션에서 세분화된 개인 액세스 토큰(PAT) 권한을 적용하는 방식을 설명합니다. 단계별 구현 가이드는 GraphQL 구현 가이드를 참조하세요.
개요#
세분화 토큰 인가 시스템은 타입, 필드, 뮤테이션에 적용된 authorize_granular_token 지시어(directive)를 기반으로 GraphQL에 세밀한 권한 검사를 추가합니다. 이 시스템은 세분화된 PAT가 특정 프로젝트, 그룹, 사용자, 인스턴스 경계 내에서 명시적으로 권한이 부여된 리소스에만 접근할 수 있도록 보장합니다.
기능 플래그: 이 기능은 토큰의 사용자에 대해 granular_personal_access_tokens 기능 플래그가 활성화되어 있어야 합니다. 플래그가 비활성화된 경우 세분화된 PAT는 GraphQL 요청에 대해 작동하지 않습니다.
아키텍처 구성 요소#
1. 인가 확인(Authorization check)#
-
위치:
lib/gitlab/graphql/authz/granular_scope_authorization.rb -
목적: 타입, 필드, 뮤테이션에 선언된
authorize_granular_token지시어를 요청의 세분화된 PAT에 대해 평가합니다. -
공개 메서드:
-
ok?(object, context, arguments: nil):true또는false를 반환합니다. 타입 수준 및 필드 수준 인가에 사용됩니다. -
authorize!(object, context, arguments: nil): 인가가 실패하면 거부 메시지와 함께Gitlab::Graphql::Errors::ArgumentError를 발생시킵니다. 뮤테이션에 사용됩니다.
2. 지시어(Directive)#
-
위치:
app/graphql/directives/authz/granular_scope.rb -
목적: 필요한 권한 및 경계 추출 전략을 선언합니다.
-
인수(Arguments):
-
permissions: 필요한 권한 문자열의 배열입니다 (예:['read_issue']). -
boundary: 해석된 객체에서 경계를 추출하는 메서드 이름입니다. -
boundary_argument: 경계를 포함하는 인수 이름입니다. -
boundary_type: 인가 경계의 타입입니다 (project,group,user,instance). 권한 경계의 유효성 검사 및 문서화에 사용됩니다. -
traversal:true인 경우, 지시어는 토큰이 경계로 스코프가 지정되어 있는지만 확인합니다(read_boundary). 나열된 권한은 필드 자체에서 적용되지 않습니다. 다운스트림 필드가 실제 권한을 적용하는Query.group(fullPath:)와 같은 진입점 필드에 사용합니다.
3. 경계 추출기(Boundary extractor)#
-
위치:
lib/gitlab/graphql/authz/boundary_extractor.rb -
목적: 토큰이 스코프로 지정되어야 하는 객체를 해석합니다. 객체는 프로젝트, 그룹,
:user,:instance일 수 있으나 이에 국한되지 않습니다. -
동작: 각 지시어가 선언한 소스에서 경계를 추출합니다.
boundary_argument를 가진 지시어는 필드 또는 뮤테이션 인수에서 읽고, 그 외의 모든 지시어는 이미 해석된 객체에서 읽습니다.
4. 헬퍼 모듈(Helper module)#
-
위치:
lib/gitlab/graphql/authz/authorize_granular_token.rb -
목적: 더 깔끔한 지시어 구문을 위한
authorize_granular_token헬퍼 메서드를 제공합니다. -
포함 위치:
Types::BaseObject,Types::BaseField,Mutations::BaseMutation -
메서드:
authorize_granular_token(permissions:, boundary_type:, boundary: nil, boundary_argument: nil) -
유효성 검사: 권한은
Authz::PermissionGroups::Assignable.all_permissions에 대해gitlab:permissions:validateRake 태스크로 검증됩니다.
5. 인가 서비스(Authorization service)#
-
위치:
app/services/authz/tokens/authorize_granular_scopes_service.rb -
목적: 토큰이 해석된 경계에 대해 필요한 권한을 보유하고 있는지 확인합니다.
-
반환:
ServiceResponse.success를 반환합니다. 실패 시에는 토큰에 누락된 권한을 설명하는 오류 메시지와 함께ServiceResponse.error를 반환합니다.
요청 흐름 타임라인#
Phase 1: 인가 진입점#
인가는 세 지점에서 실행되며, 각 지점은 해당 수준에서 선언된 지시어로부터 GranularScopeAuthorization을 생성합니다.
| 수준 | 진입점 | 지시어 소스 | 실패 시 |
|---|---|---|---|
| 타입 | Types::BaseObject.authorized? | 타입의 지시어 | false를 반환하여 접근을 거부합니다 |
| 필드 | Types::BaseField#field_authorized? | 필드의 지시어 | 쿼리 필드의 경우 false를 반환하고, MutationType 필드의 경우 resource-not-available 오류를 발생시킵니다 |
| 뮤테이션 | Mutations::BaseMutation#authorized? | 뮤테이션 클래스의 지시어 | Gitlab::Graphql::Errors::ArgumentError를 발생시킵니다 |
Phase 2: 인가 확인#
각 진입점에서 GranularScopeAuthorization은 다음 단계로 요청을 평가합니다:
Step 1: 토큰 확인
이 확인은 세분화된 PAT에만 적용됩니다. 레거시 PAT는 이 단계를 통과하며, 적용(enforcement)이 해당되지 않는 한 기존 스코프 인가에 의존합니다(Phase 3 참조).
Step 2: 지시어 확인
세분화 지시어가 선언되지 않은 경우 인가가 실패합니다.
Step 3: 경계 추출
경계는 각 지시어가 선언한 소스에서 추출됩니다. 타입 수준 검사의 경우 해석된 객체에서, 뮤테이션 및 루트 쿼리 필드의 경우 인수에서 추출합니다. 자세한 내용은 경계 추출을 참조하세요.
Step 4: 인가
AuthorizeGranularScopesService는 토큰이 추출된 경계에 대해 필요한 권한을 보유하고 있는지 확인하며, 그 결과는 캐시됩니다.
Phase 3: 레거시 토큰 적용#
세분화 권한 검사는 경계의 루트 네임스페이스가 세분화 토큰을 적용하는 경우 레거시(비세분화) 토큰에도 적용됩니다. AuthorizeGranularScopesService는 granular_tokens_enforced?를 통해 각 경계의 루트 네임스페이스 설정을 읽습니다:
-
적용이 켜져 있으면, 레거시 토큰은 세분화 토큰과 동일한 권한 검사의 대상이 됩니다.
-
적용이 꺼져 있으면, 레거시 토큰은 통과하며 기존 스코프 인가에 의존합니다.
경계 추출#
각 지시어는 경계가 어디에서 오는지 선언하며, BoundaryExtractor는 해당 소스에서 읽습니다. 타입 수준 검사는 GraphQL이 이미 해석한 객체를 인가하므로, 해당 객체의 메서드를 호출하여 경계에 도달합니다. 뮤테이션 및 루트 쿼리 필드는 객체가 해석되기 전에 인가하므로, 경계를 인수에서 도출해야 합니다.
1. 해석된 객체로부터 (타입 및 중첩 필드)#
boundary_argument가 없는 지시어는 해석된 객체에서 읽습니다. 추출기는 지시어에서 boundary 메서드를 읽고 이미 해석된 객체에서 경계를 해석합니다:
-
boundary: :itself는 객체 자신을 경계로 반환합니다. 자신이 Project 또는 Group인 타입에 사용합니다. -
그 외의 값은 객체에서 해당 메서드를 호출합니다.
예를 들어, ProjectType은 자신이 경계입니다:
authorize_granular_token permissions: :read_project, boundary: :itself, boundary_type: :project
# boundary => the Project itself
IssueType은 메서드를 통해 경계에 도달합니다:
authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
# boundary => issue.project
2. 인수로부터 (뮤테이션 및 루트 쿼리 필드)#
boundary_argument를 가진 지시어는 인수에서 읽습니다. 이는 경계에 도달할 부모 객체가 존재하지 않는 뮤테이션 및 루트 쿼리 필드에 적용됩니다. 추출기는 지시어에서 boundary_argument를 읽고 레코드를 찾습니다:
-
GlobalID는
GitlabSchema.find_by_gid를 통해 찾으며(Gitlab::Graphql::Lazy로 강제됨), 따라서 조회가 GraphQL 배치 로딩에 참여합니다. -
전체 경로(full-path) 문자열은
Project.find_by_full_path또는Group.find_by_full_path를 통해 찾습니다. -
Project 또는 Group 레코드는 직접 반환됩니다.
-
그 외의 레코드는 지시어의
boundary메서드를 호출하여 Project 또는 Group에 도달합니다.
인수가 Project 또는 Group으로 직접 해석되는 경우, boundary_argument만 필요합니다:
authorize_granular_token permissions: :create_issue, boundary_argument: :project_path, boundary_type: :project
# boundary => Project.find_by_full_path(project_path)
인수가 다른 레코드로 해석되는 경우, boundary를 추가하여 해당 레코드로부터 Project 또는 Group에 도달합니다:
authorize_granular_token permissions: :create_note, boundary_argument: :id, boundary: :project, boundary_type: :project
# boundary => located_record.project
여러 경계#
동일한 리소스가 서로 다른 경계 타입에 속할 수 있는 경우, 타입은 둘 이상의 경계를 선언할 수 있습니다. 구체적 경계(concrete boundary)는 특정 Project 또는 Group 레코드입니다. 독립형 경계(standalone boundary)는 프로젝트나 그룹에 속하지 않는 user 또는 instance 경계 타입입니다.
구체적 경계가 우선합니다. 독립형 경계는 해석되는 구체적 경계가 없을 때만 사용됩니다. 또한 지시어의 해석된 객체가 선언된 boundary_type과 일치하지 않으면 해당 지시어는 건너뜁니다. 따라서 동일한 boundary 메서드가 둘 이상의 타입을 처리할 수 있습니다.
예를 들어, Ci::RunnerType은 러너가 프로젝트에 속하거나, 그룹에 속하거나, 인스턴스 전체일 수 있으므로 세 개의 경계를 선언합니다:
authorize_granular_token(
permissions: :read_runner,
boundaries: [
{ boundary: :owner, boundary_type: :project },
{ boundary: :owner, boundary_type: :group },
{ boundary: :instance, boundary_type: :instance }
]
)
적용되는 경계는 러너에 따라 달라집니다:
-
프로젝트 러너의 경우,
runner.owner가 Project이므로 프로젝트 경계가 해석되어 사용됩니다. -
그룹 러너의 경우,
runner.owner가 Group이므로 그룹 경계가 해석되어 사용됩니다. -
인스턴스 러너의 경우,
runner.owner가 Project도 Group도 아니므로 두 구체적 지시어가 모두 건너뜁니다. 대신 독립형instance경계가 사용되며, 토큰은 인스턴스에 대해read_runner가 있는지 확인됩니다.
독립형 경계가 구체적 경계보다 선호되는 것은 러너에 소유 프로젝트나 그룹이 없는 마지막 경우뿐입니다.
예시 시나리오#
시나리오 1: boundary_argument를 사용한 뮤테이션#
createIssue 뮤테이션은 프로젝트를 경로 인수로 전달합니다:
mutation {
createIssue(input: { projectPath: "gitlab-org/gitlab", title: "New issue" }) {
issue { id }
}
}
Mutations::Issues::Create는 다음을 선언합니다:
authorize_granular_token permissions: :create_issue, boundary_argument: :project_path, boundary_type: :project
-
GraphQL Ruby는 뮤테이션이 실행되기 전에
Mutations::BaseMutation#authorized?를 호출합니다. -
BoundaryExtractor는project_path를 읽고Project.find_by_full_path를 통해 Project를 해석합니다. -
AuthorizeGranularScopesService는 토큰이 해당 프로젝트에 대해create_issue를 가지고 있는지 확인합니다. -
토큰에 권한이 있으면 뮤테이션이 실행됩니다. 그렇지 않으면
Gitlab::Graphql::Errors::ArgumentError가 발생합니다.
시나리오 2: boundary를 사용한 쿼리 타입#
쿼리가 해석된 이슈의 필드를 읽습니다:
query {
project(fullPath: "gitlab-org/gitlab") {
issues {
nodes { title }
}
}
}
IssueType은 다음을 선언합니다:
authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
-
각
Issue가 해석될 때,IssueType에 대해Types::BaseObject.authorized?가 실행됩니다. -
BoundaryExtractor는issue.project를 호출하여 경계에 도달합니다. -
AuthorizeGranularScopesService는 토큰이 해당 프로젝트에 대해read_issue를 가지고 있는지 확인합니다. -
토큰에 권한이 있으면 이슈가 해석됩니다. 그렇지 않으면 접근이 거부되고 검사가
false를 반환합니다.
시나리오 3: GlobalID와 boundary를 사용한 뮤테이션#
createNote 뮤테이션은 GlobalID를 전달하며, 경계는 찾은 레코드로부터 도달됩니다:
authorize_granular_token permissions: :create_note, boundary_argument: :id, boundary: :project, boundary_type: :project
id: "gid://gitlab/Issue/1" 인수의 경우, BoundaryExtractor는 GitlabSchema.find_by_gid를 통해 Issue를 찾은 다음, issue.project를 호출하여 경계에 도달합니다.
시나리오 4: boundary_argument를 사용한 루트 쿼리 필드#
루트 쿼리 필드는 인수로 전달된 네임스페이스에 대해 계산된 데이터를 해석하므로, 경계에 도달할 부모 객체나 해석된 레코드가 존재하지 않습니다:
query {
aiToolRules(fullPath: "gitlab-org") {
nodes { id }
}
}
필드의 해석기는 다음을 선언합니다:
authorize_granular_token permissions: :read_ai_tool_rule, boundary_argument: :full_path, boundary_type: :group
-
GraphQL Ruby는 해석기가 실행되기 전에 필드 인수와 함께
Types::BaseField#authorized?를 호출합니다. -
BoundaryExtractor는full_path를 읽고Group.find_by_full_path를 통해 Group을 해석합니다. -
AuthorizeGranularScopesService는 토큰이 해당 그룹에 대해read_ai_tool_rule을 가지고 있는지 확인합니다. -
토큰에 권한이 있으면 필드가 해석됩니다. 그렇지 않으면 필드가 null로 처리됩니다.
해석기 클래스에 선언된 지시어는 해당 해석기가 마운트된 필드에 적용됩니다. 따라서 authorize_granular_token은 해석기에 선언하거나 directives: granular_scope_directive(...)를 사용하여 필드 정의 자체에 선언할 수 있습니다.
성능 최적화#
1. 요청별 인가 캐시#
인가 결과는 요청별로 캐시됩니다. 동일한 경계 및 권한으로 해석되는 여러 필드는 캐시된 결과를 재사용하므로, 인가 서비스는 그 필드들에 대해 한 번만 실행됩니다.
2. 배치 경계 프리로딩#
컬렉션의 각 노드에서 경계를 하나씩 해석하면 N+1 쿼리가 발생합니다. 예를 들어, IssueType이 authorize_granular_token permissions: :read_issue, boundary: :project를 선언하면, 해석된 각 이슈 객체에 대해 추출기가 issue.project를 호출하여 N+1 문제가 발생합니다.
BoundaryExtractors::Preloader는 인가가 실행되기 전에 컬렉션의 노드를 프리로드하여 이 문제를 해결합니다:
-
프리로더는 한 번의 쿼리 집합으로 모든 노드에 걸쳐 경계 연관을 배치 로드한 다음, 로드된 레코드를
Gitlab::SafeRequestStore에 캐시합니다. -
경계 추출기는 다시 쿼리하지 않고 이 캐시된 레코드를 재사용합니다.
3. 적용 프리로딩#
세분화 토큰을 적용하는 네임스페이스 설정이 활성화되면 레거시 토큰은 해당 네임스페이스의 리소스에 접근할 수 없습니다. 자세한 내용은 이슈 20180을 참조하세요.
GitLab은 매 검사마다 경계의 루트 네임스페이스와 그 설정을 로드하는 대신, 이를 프리로드하여 granular_tokens_enforced? 값을 캐시에 저장합니다. AuthorizeGranularScopesService는 이후에 이 캐시된 값을 읽습니다.
오류 처리#
1. 인가 실패#
인가가 실패하면:
-
뮤테이션은
Gitlab::Graphql::Errors::ArgumentError를 발생시키며, 토큰에 누락된 세분화 권한을 사용자에게 알리는 오류 메시지를 포함합니다. 이 메시지는 GraphQL 응답의errors배열로 전달됩니다. -
타입 수준 검사는
false를 반환하여 객체에 대한 접근을 거부합니다. -
필드 수준 검사는 쿼리 필드의 경우
false를 반환하여 접근을 거부합니다.MutationType의 필드는 대신 resource-not-available 오류를 발생시키므로, 뮤테이션 응답의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"]
}]
}
2. 경계 해석 오류#
추출기는 경계를 해석할 수 없을 때 빈 배열을 반환합니다. 이는 경로나 GlobalID가 레코드와 일치하지 않을 때, 경계 메서드가 nil을 반환할 때, 또는 지시어에 필요한 boundary나 boundary_argument가 누락되었을 때 발생합니다.
AuthorizeGranularScopesService는 요청된 권한이 있으나 경계 집합이 비어 있는 경우를 해석되지 않은 리소스로 간주하고 404 Not Found 오류를 반환합니다. 권한 오류가 아닌 404 Not Found를 반환함으로써 리소스의 존재 여부가 노출되는 것을 방지합니다.
3. 설정 오류#
다음 오류들은 지시어가 잘못 구성되었음을 나타내며, 요청 시점이 아닌 개발 또는 유효성 검사 중에 드러납니다:
-
유효하지 않은 권한 이름은
AuthorizeGranularScopesService에서InvalidInputError를 발생시키며,Authz::PermissionGroups::Assignable.all_permissions에 대한gitlab:permissions:validateRake 태스크에서도 감지됩니다. -
boundary_type키를 가진 Hash가 아닌boundaries:항목은ArgumentError를 발생시킵니다. -
타입 수준
authorize_granular_token에traversal: true를 전달하면ArgumentError가 발생합니다. 대신 필드 정의에granular_scope_directive(traversal: true)를 사용하세요.