GraphQL 구현 가이드
GitLab v19.5요약
침해된 개인 액세스 토큰(PAT)의 보안 영향을 줄이기 위해, 세분화된 PAT 또는 세밀한 PAT 는 특정 조직 경계(그룹, 프로젝트, 사용자 또는 인스턴스 수준)로 제한된 세밀한 권한을 가진 토큰을 사용자가 만들 수 있게 합니다.
침해된 개인 액세스 토큰(PAT)의 보안 영향을 줄이기 위해, 세분화된 PAT 또는 세밀한 PAT 는 특정 조직 경계(그룹, 프로젝트, 사용자 또는 인스턴스 수준)로 제한된 세밀한 권한을 가진 토큰을 사용자가 만들 수 있게 합니다. 이를 통해 사용자는 토큰에 필요한 권한만 부여하여 최소 권한 원칙을 따를 수 있습니다.
세분화된 PAT 는 경계와 특정 리소스 권한으로 구성된 세분화된 스코프를 통해 세밀한 액세스 제어를 제공합니다. 세분화된 PAT 로 GraphQL 요청을 인증할 때 GitLab 은 토큰의 권한에 지정된 경계 수준에서 요청된 리소스에 대한 액세스가 포함되는지 검증합니다.
이 문서는 GraphQL 쿼리와 뮤테이션을 세분화된 PAT 인가에 맞게 준수시키려는 커뮤니티 기여자와 GitLab 개발자를 대상으로 작성되었습니다.
단계별 구현 가이드#
이 가이드는 GraphQL 타입과 뮤테이션에 세분화된 PAT 인가를 추가하는 과정을 안내합니다. 시작하기 전에 문서 전반에서 사용되는 용어를 이해하기 위해 권한 명명 규칙 문서를 확인합니다.
이 단계는 GraphQL 타입과 뮤테이션만 다룹니다. REST API 엔드포인트 보호는 REST API 구현 가이드를 참고합니다.
인가 시스템이 내부적으로 어떻게 동작하는지에 대한 자세한 설명은 GraphQL 아키텍처 문서를 참고합니다.
워크플로 개요#
구현은 다음 흐름을 따릅니다:
- 1-2단계: 계획 - 타입/뮤테이션 식별 및 권한 설계
- 3단계: 원시 권한 생성(YAML 파일)
- 4단계: 원시 권한을 할당 가능한 권한으로 묶기(YAML 파일)
- 5단계: 타입/뮤테이션에 인가 지시어 추가(Ruby 코드)
- 6단계: 인가 테스트 작성(Ruby 스펙)
- 7단계: 로컬 테스트(수동 검증)
- 8단계: 문서 검증 및 재생성(Rake 태스크)
1단계: 보호할 GraphQL 타입 및 뮤테이션 식별#
목표: 작업 중인 리소스의 모든 GraphQL 타입과 뮤테이션을 찾습니다.
-
app/graphql/types/에서 해당 리소스의 GraphQL 타입을 찾습니다.예시: 이슈 리소스의 경우
app/graphql/types/issue_type.rb를 엽니다 -
app/graphql/mutations/에서 관련 뮤테이션을 찾습니다.예시: 이슈의 경우
app/graphql/mutations/issues/를 확인합니다 -
인가가 필요한 타입과 뮤테이션을 식별합니다:
- 사용자가 액세스하는 리소스를 나타내는 객체 타입(예:
IssueType,ProjectType) - 리소스를 생성, 업데이트, 삭제하는 뮤테이션(예:
Mutations::Issues::Create) - 리소스를 직접 반환하는 쿼리 필드(예:
QueryType의field :project)
- 사용자가 액세스하는 리소스를 나타내는 객체 타입(예:
-
이미
authorize_granular_token지시어가 선언된 타입이나 뮤테이션이 있는지 확인합니다. 지시어가 없는 타입과 뮤테이션에는 지시어를 추가해야 합니다.
2단계: 필요한 권한 결정#
목표: GitLab 명명 규칙에 따라 세분화된 권한을 정의합니다.
명명 규칙은 규칙 문서의 권한 명명을 참고합니다.
타입과 뮤테이션의 권한 이름 결정#
세분화된 PAT 인가를 구현할 때는 GraphQL 스키마 구조가 아니라, 해당 타입이 무엇을 나타내는지 또는 해당 뮤테이션이 무엇을 하는지를 기준으로 권한 이름을 정합니다.
예시:
- 타입
IssueType→ 이슈 읽기를 나타냄 → 권한 이름은read_issue - 뮤테이션
Mutations::Issues::Create→ 이슈를 생성함 → 권한 이름은create_issue - 타입
ProjectType→ 프로젝트 데이터 읽기를 나타냄 → 권한 이름은read_project
일반적인 패턴#
- 객체 타입: 해당 타입의 모든 필드를 포괄하는
read_resource권한을 사용합니다IssueType→read_issueProjectType→read_project
- 생성 뮤테이션:
create_resource를 사용합니다Mutations::Issues::Create→create_issue
- 업데이트 뮤테이션:
update_resource를 사용합니다Mutations::Issues::Update→update_issue
- 삭제 뮤테이션:
delete_resource를 사용합니다Mutations::Issues::Destroy→delete_issue
- 특수 동작 뮤테이션: 고유한 작업에는 전용 권한을 만듭니다
- 이동, 보관, 이전 등은 각각 자체 권한을 가집니다
3단계: 권한 정의 파일 생성#
목표: 아직 없다면 권한마다 YAML 정의 파일을 만듭니다.
권한 정의 파일 절의 안내에 따라 bin/permission 명령으로 원시 권한 YAML 파일을 만듭니다. 이 단계는 REST API 구현과 GraphQL 구현에서 동일합니다.
4단계: 할당 가능한 권한 생성 또는 업데이트#
목표: 사용자 경험을 단순하게 하기 위해 원시 권한을 할당 가능한 권한으로 묶습니다.
합리적인 경우라면 새 할당 가능한 권한을 만들기보다 기존 할당 가능한 권한에 원시 권한을 추가하는 편이 좋습니다. 할당 가능한 권한은 사용자에게 노출됩니다. 새로 만들 때마다 토큰 생성 UI 에 표시되고, 그 이름은 해당 권한을 선택한 모든 토큰에 대해 데이터베이스에 저장됩니다. 나중에 할당 가능한 권한을 제거하거나 이름을 바꾸면 호환성이 깨지는 변경이지만, 기존 할당 가능한 권한 안의 원시 권한은 자유롭게 이름을 바꾸거나 옮길 수 있습니다. 해당 리소스의 기존 할당 가능한 권한과 별개로 사용자가 부여할 수 있어야 하는 기능을 원시 권한이 나타낼 때에만 새 할당 가능한 권한을 만듭니다. 각 변경 유형이 미치는 영향은 할당 가능한 권한 유지 관리를 참고합니다.
할당 가능한 권한 문서의 안내에 따라 할당 가능한 권한 YAML 파일을 만들거나 업데이트합니다. 이 단계는 REST API 구현과 GraphQL 구현에서 동일합니다.
5단계: 타입과 뮤테이션에 인가 지시어 추가#
목표: GraphQL 타입과 뮤테이션에 세분화된 PAT 인가 지시어를 추가합니다.
authorize_granular_token 메서드로 타입과 뮤테이션에 권한을 선언합니다. 이 메서드는 모든 GraphQL 타입(Types::BaseObject 경유), 뮤테이션(Mutations::BaseMutation 경유), 리졸버(Resolvers::BaseResolver 경유)에서 사용할 수 있습니다.
메서드 시그니처:
authorize_granular_token(permissions:, boundary_type: nil, boundary: nil, boundary_argument: nil, boundaries: nil, skip_reason: nil)
매개변수:
| 매개변수 | 설명 |
|---|---|
permissions |
(필수) 필요한 권한을 나타내는 심볼입니다(예: :read_issue). 권한 배열도 가능합니다. Authz::PermissionGroups::Assignable.all_permissions 에 있는 유효한 권한이어야 합니다. gitlab:permissions:validate Rake 태스크가 이를 검증합니다. |
boundary_type |
인가 경계의 유형을 선언하는 심볼입니다(:project, :group, :user, :instance). boundaries: 를 사용하지 않을 때는 필수입니다. gitlab:permissions:validate Rake 태스크가 할당 가능한 권한의 경계와 대조해 검증합니다. |
boundary |
경계를 추출하기 위해 해석된 객체에서 호출할 메서드를 나타내는 심볼입니다(예: :project). 해석된 객체가 Project 나 Group 자체인 경우에는 :itself 를 사용합니다(예: ProjectType 과 GroupType). 독립형 리소스에는 :user 나 :instance 를 사용합니다. |
boundary_argument |
경계 경로를 담고 있는 인수 이름을 나타내는 심볼입니다(예: :project_path). |
boundaries |
여러 경계 유형을 지원하는 리소스를 위한 경계 해시 배열입니다. 각 해시에는 boundary_type 키가 필요하며 boundary 나 boundary_argument 를 포함할 수 있습니다. 자세한 내용은 여러 경계를 참고합니다. |
skip_reason |
해당 타입이 세분화 토큰 인가를 의도적으로 적용하지 않음을 선언하는 심볼입니다. permissions: 와 경계 대신 사용하며, 함께 쓰지 않습니다. 자세한 내용은 skip_reason 으로 인가 건너뛰기를 참고합니다. |
assignable_when |
선택 사항. 토큰 생성 UI 에서 해당 권한이 제공되려면 현재 사용자가 충족해야 하는 조건 심볼의 배열입니다. 해당 호출이 선언한 모든 경계에 적용되며, boundaries: 의 모든 항목을 포함합니다. boundaries: 안의 해시도 자체 assignable_when 을 설정할 수 있고, 이는 그 경계에만 조건을 추가합니다. 보안 통제 수단은 아닙니다. 자세한 내용은 조건부로 제공되는 타입과 뮤테이션 태그 지정을 참고합니다. |
객체 타입의 경우:
class IssueType < BaseObject
authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
end
뮤테이션의 경우:
module Mutations
module Issues
class Create < BaseMutation
authorize_granular_token permissions: :create_issue, boundary_argument: :project_path, boundary_type: :project
end
end
end
인수가 Project 나 Group 자체가 아닌 레코드로 해석될 때는 boundary_argument 와 boundary 를 함께 사용합니다.
인수가 레코드를 찾고, boundary 가 그 레코드에서 Project 나 Group 에 도달합니다:
module Mutations
module Notes
module Create
class Base < Mutations::Notes::Base
authorize_granular_token permissions: :create_note,
boundaries: [
{ boundary_argument: :noteable_id, boundary: :resource_parent, boundary_type: :project },
{ boundary_argument: :noteable_id, boundary: :resource_parent, boundary_type: :group }
]
end
end
end
end
인수가 noteable_id: "gid://gitlab/Issue/1" 이면 추출기가 해당 이슈를 찾은 다음, issue.resource_parent 를 호출해 경계에 도달합니다.
루트 쿼리 필드의 경우:
루트 쿼리 필드에는 부모 객체가 없으므로, 경계는 boundary_argument 로 필드 인수에서 읽습니다.
지시어는 해당 필드의 리졸버에 선언합니다.
리졸버에 선언한 지시어는 그 리졸버를 마운트하는 필드에 적용됩니다:
module Resolvers
module Ai
class ToolRulesResolver < BaseResolver
authorize_granular_token permissions: :read_ai_tool_rule, boundary_argument: :full_path, boundary_type: :group
argument :full_path, GraphQL::Types::ID, required: true
end
end
end
토큰에 해당 권한이 없으면 필드는 null 로 해석되며, 이는 타입 수준 인가가 객체를 가리는 방식과 같습니다.
전체 구현과 그 인가 테스트는
ee/app/graphql/resolvers/ai/tool_rules_resolver.rb 와
ee/spec/requests/api/graphql/ai/tool_rules_spec.rb 를 참고합니다.
boundary 가 적용되는 경우#
- 해석된 객체의 필드(예:
IssueType이 지시어를 선언할 때의issue.title). - 해석된 객체가 곧 경계인 타입(
boundary: :itself를 사용합니다). boundary_type: :user나boundary_type: :instance를 사용하는 독립형 리소스.
객체가 아직 해석되지 않았을 때는 boundary_argument 를 사용합니다.
boundary_argument 가 적용되는 경우#
- 루트 뮤테이션
- 경로나 GlobalID 인수를 받는 루트 쿼리 필드
- 경계를 인수로 받는 모든 필드
독립형 경계#
특정 프로젝트나 그룹에 속하지 않는 리소스에는 boundary_type: :user 나 boundary_type: :instance 를 사용합니다. 이러한 경계 유형에서는 boundary_type 만으로 경계가 결정됩니다. 관례에 따라 boundary: 도 같은 값으로 설정합니다:
module Mutations
module Todos
class SnoozeMany < BaseMany
authorize_granular_token permissions: :update_todo, boundary: :user, boundary_type: :user
end
end
end
여러 경계#
서로 다른 경계 유형에 속할 수 있는 리소스는 boundaries: 로 각 경계를 선언합니다.
러너는 프로젝트에 속할 수도, 그룹에 속할 수도, 인스턴스 전역일 수도 있으므로 Ci::RunnerType 이 이렇게 선언합니다:
class RunnerType < BaseObject
authorize_granular_token(
permissions: :read_runner,
boundaries: [
{ boundary: :owner, boundary_type: :project },
{ boundary: :owner, boundary_type: :group },
{ boundary: :instance, boundary_type: :instance }
]
)
end
구체적인 경계(프로젝트 또는 그룹)가 하나라도 해석되면 그 경계를 우선합니다.
해석된 객체가 선언된 boundary_type 과 맞지 않는 지시어는 건너뜁니다.
인스턴스 러너에서는 runner.owner 가 User 를 반환하므로 프로젝트 지시어도 그룹 지시어도
맞지 않고, 독립형 instance 경계가 적용됩니다.
자세한 내용은 여러 경계를 참고합니다.
인가된 타입 사이의 순회#
인가된 타입의 필드가 authorize_granular_token 을 함께 선언한 다른 타입을
반환하면 두 지시어 모두 적용됩니다.
GranularScopeAuthorization 은 각 타입과 필드의 지시어를 각각 독립적으로
평가하므로, 토큰에 자식 타입의 권한과 함께 소유 타입의 권한도 필요하다고
가정해 권한을 계획합니다.
예를 들어 GroupType.groupMembers 는 GroupMemberType 을 반환하고, 두 타입 모두
세분화 토큰 지시어를 선언합니다. 토큰은 그룹의 필드를 해석하려면 read_group 이,
그 멤버의 필드를 해석하려면 read_member 가 필요합니다:
query {
group(fullPath: "gitlab-org") {
groupMembers {
nodes { id }
}
}
}
소유 타입의 지시어를 자동으로 건너뛰어 read_member 만 가진 토큰이 그룹을
통해 멤버에 도달할 수 있게 하는 방식은 이전에 구현되었으나 현재
재구현을 기다리는 상태입니다. 이 동작에 의존하면 안 됩니다.
skip_reason 으로 인가 건너뛰기#
일부 객체 타입은 의도적으로 자체 권한을 선언하지 않습니다. 이러한 타입에는
skip_reason: 으로 건너뛰기를 선언해 인가를 생략한 이유를 기록합니다.
값은 그 이유를 가리키며, 이를 통해 결정 사항을 문서화하고 검증기가
의도적인 건너뛰기와 인가가 누락된 타입을 구분할 수 있게 합니다.
class VulnerabilityIdentifierType < BaseObject
authorize_granular_token skip_reason: :parent_authorizes
end
유효한 이유와 그 의미는 lib/tasks/gitlab/permissions/graphql/skip_reasons.rb 에 정의되어 있습니다.
gitlab:permissions:validate Rake 태스크는 모든 객체 타입이 지시어나 건너뛰기 중
하나를 선언하도록 요구합니다. 이 요구 사항보다 먼저 있던 타입은
config/authz/graphql/authorization_todo.txt 에 나열되어 있습니다. 이 파일에는 새 항목을
추가하면 안 됩니다. skip_reason: 은 permissions: 나 경계 인수와 함께 쓸 수
없습니다.
조건부로 제공되는 타입과 뮤테이션 태그 지정#
타입, 뮤테이션, 리졸버가 멤버십을 넘어, 예를 들어 관리자로 액세스를
제한할 때는 그 제한이 권한 도구에 드러나도록
assignable_when 으로 태그를 지정합니다:
class InstanceAuditEventType < BaseObject
authorize_granular_token permissions: :read_audit_event, boundary: :instance, boundary_type: :instance,
assignable_when: [:admin]
end
이 조건은 해당 호출이 내보내는 모든 지시어, 즉 기본 경계와
boundaries: 의 모든 항목에
적용됩니다.
boundaries: 안의 각 해시도 자체 assignable_when 을
설정할 수 있습니다. 호출이 여러 경계를 선언하지만 그중 하나만 제한되는 경우에
사용합니다. 해시에 설정한 조건은 그 경계에 대해서만 호출 수준 조건에
더해집니다:
authorize_granular_token permissions: :create_runner,
boundaries: [
{ boundary_argument: :project_id, boundary_type: :project },
{ boundary_argument: :group_id, boundary_type: :group },
{ boundary: :instance, boundary_type: :instance, assignable_when: [:admin] }
]
이 태그는 제한을 설명하기만 하고 강제하지는 않습니다. 해당 타입이나
뮤테이션이 제한을 직접 강제해야 하며, 예를 들어
authorize :admin_all_resources 나 리졸버의 관리자 확인으로 처리합니다.
검증 태스크는 REST 엔드포인트에서와 마찬가지로 알 수 없는 조건 이름을 거부합니다.
검증 태스크는 이 태그를 할당 가능한 권한 YAML 파일에 선언된 조건과 대조해 확인합니다. 자세한 내용은 조건부로 할당 가능한 권한을 참고합니다.
6단계: 인가 테스트 추가#
목표: GraphQL 타입과 뮤테이션에서 세분화된 PAT 권한이 올바르게 적용되는지 확인합니다.
쿼리의 경우#
'authorizing granular token permissions for GraphQL' 공유 예제를 추가합니다:
it_behaves_like 'authorizing granular token permissions for GraphQL', :<permission_name> do
let(:user) { current_user }
let(:boundary_object) { <boundary_object> }
let(:request) { post_graphql(query, token: { personal_access_token: pat }) }
end
예시:
it_behaves_like 'authorizing granular token permissions for GraphQL', :read_issue do
let(:user) { current_user }
let(:boundary_object) { project }
let(:request) { post_graphql(query, token: { personal_access_token: pat }) }
end
뮤테이션의 경우#
it_behaves_like 'authorizing granular token permissions for GraphQL', :<permission_name> do
let(:user) { current_user }
let(:boundary_object) { <boundary_object> }
let(:request) { post_graphql_mutation(mutation, token: { personal_access_token: pat }) }
end
인가를 건너뛰는 타입의 경우#
타입이 skip_reason: :parent_authorizes 를 선언하면, 부모 타입이 인가되었을 때 해당 데이터가
반환되고 인가되지 않았을 때는 반환되지 않는지 확인합니다. skipped_data_path 는 건너뛴 타입의
데이터에 해당하는 GraphQL 응답 경로로 설정합니다:
it_behaves_like 'authorizing granular token permissions for GraphQL with a skipped child type', :read_vulnerability do
let(:user) { current_user }
let(:boundary_object) { project }
let(:request) { post_graphql(query, current_user: user, token: { personal_access_token: pat }) }
let(:skipped_data_path) { %i[vulnerability identifiers] }
end
경계 객체 매핑#
boundary_object 는 boundary_type 과 일치해야 합니다:
| 경계 유형 | 경계 객체 |
|---|---|
:project |
project |
:group |
group |
:user |
:user |
:instance |
:instance |
중요: 경계 객체가 :project 나 :group 일 때, 인가가 부여되려면 user 가 해당 네임스페이스(프로젝트 또는 그룹)에 접근할 수 있어야 합니다.
이 테스트가 확인하는 내용:
- 레거시(세분화되지 않은) 개인 액세스 토큰이 계속 액세스를 부여하는지
- 경계의 최상위 그룹이 세밀한 토큰을 강제할 때 레거시 토큰의 액세스가 거부되는지
- 세분화된 PAT 에서 필요한 권한을 부여받은 사용자에게 액세스가 허용되는지
- 필요한 권한이 없는 사용자의 액세스가 거부되는지. 인가되지 않은 쿼리는
200응답과 함께null데이터를 반환하고, 인가되지 않은 뮤테이션은 최상위 수준 GraphQL 오류를 반환합니다 - 인가 시스템이 타입/뮤테이션의 권한 요구 사항과 대조해 세분화된 스코프를 올바르게 평가하는지
granular_personal_access_tokens기능 플래그가 올바르게 적용되는지(비활성화되면 액세스를 거부)
7단계: 수동 검증#
목표: 머지 리퀘스트를 만들기 전에 로컬 환경에서 구현을 직접 테스트해 권한이 의도대로 동작하는지 확인합니다.
설정:
Rails 콘솔에서 사용자를 위한 세분화된 PAT 를 만듭니다:
# Enable feature flag
Feature.enable(:granular_personal_access_tokens)
user = User.human.first
# Create granular token
token = PersonalAccessTokens::CreateService.new(
current_user: user,
target_user: user,
organization_id: user.organization_id,
params: { expires_at: 1.month.from_now, scopes: ['granular'], granular: true, name: 'gPAT' }
).execute[:personal_access_token]
# Get the appropriate boundary object (project, group, :user, or :instance)
project = user.projects.first
boundary = Authz::Boundary.for(project)
# Create a scope with the assignable permissions being tested.
# Scopes store assignable permission names, which expand to raw permissions at request time.
# The example query below resolves fields on both ProjectType (raw permission read_project,
# part of the read_project assignable permission) and IssueType (raw permission read_issue,
# part of the read_work_item assignable permission), so the scope needs both.
scope = Authz::GranularScope.new(namespace: boundary.namespace, access: boundary.access, permissions: [:read_project, :read_work_item])
# Add the scope to the token
Authz::GranularScopeService.new(token).add_granular_scopes(scope)
# Copy a curl command for testing a GraphQL query
query = '{ project(fullPath: \"' + project.full_path + '\") { issues { nodes { title } } } }'
IO.popen('pbcopy', 'w') { |f| f.puts "curl \"http://#{Gitlab.host_with_port}/api/graphql\" --request POST --header \"PRIVATE-TOKEN: #{token.token}\" --header \"Content-Type: application/json\" --data '{\"query\": \"#{query}\"}'" }
- 다른 터미널에 이 명령을 붙여 넣습니다. 성공해야 합니다.
8단계: 문서 검증 및 재생성#
목표: 모든 권한 정의와 지시어가 일관되는지 확인하고, 생성된 참조 문서를 업데이트합니다.
-
세밀한 토큰 참조 문서를 재생성합니다:
bundle exec rake gitlab:permissions:graphql:compile_docs이 명령은
doc/auth/tokens/fine_grained_access_tokens_graphql.md를 업데이트합니다. 이 파일은 직접 편집하면 안 됩니다. -
권한 검증을 실행합니다:
bundle exec rake gitlab:permissions:validate이 태스크는 Lefthook pre-push 훅으로도 실행됩니다. 여러 검사 가운데 다음과 같은 경우에 실패합니다:
- 객체 타입이나 뮤테이션에 세분화 토큰 지시어도
skip_reason:도 없는 경우.config/authz/graphql/authorization_todo.txt에 기존 예외로 등록된 경우는 제외합니다. - 지시어의 권한이 어떤 할당 가능한 권한에도 속하지 않는 경우.
- 지시어의
boundary_type이 할당 가능한 권한의boundaries와 맞지 않는 경우. skip_reason:이lib/tasks/gitlab/permissions/graphql/skip_reasons.rb에 정의되어 있지 않은 경우.- 지시어가
skip_reason:과permissions:를 함께 선언한 경우. assignable_when조건이 해당 권한을 사용하는 코드의 태그와 맞지 않는 경우.- 생성된 참조 문서가 최신이 아닌 경우.
- 객체 타입이나 뮤테이션에 세분화 토큰 지시어도
참고 자료#
- GraphQL 아키텍처 문서: 인가 시스템이 내부적으로 어떻게 동작하는지에 대한 자세한 설명
- 할당 가능한 권한: 할당 가능한 권한 파일을 만드는 방법
- 권한 명명 규칙: 권한 명명 지침
- REST API 구현 가이드: REST API 엔드포인트에 세분화된 PAT 인가 추가
- 세분화된 개인 액세스 토큰 문서