InfoGrab DocsInfoGrab Docs

GraphQL 구현 가이드

요약

침해된 개인 액세스 토큰(PAT)의 보안 영향을 줄이기 위해, 세분화된 PAT 또는 세밀한 PAT 는 특정 조직 경계(그룹, 프로젝트, 사용자 또는 인스턴스 수준)로 제한된 세밀한 권한을 가진 토큰을 사용자가 만들 수 있게 합니다.

침해된 개인 액세스 토큰(PAT)의 보안 영향을 줄이기 위해, 세분화된 PAT 또는 세밀한 PAT 는 특정 조직 경계(그룹, 프로젝트, 사용자 또는 인스턴스 수준)로 제한된 세밀한 권한을 가진 토큰을 사용자가 만들 수 있게 합니다. 이를 통해 사용자는 토큰에 필요한 권한만 부여하여 최소 권한 원칙을 따를 수 있습니다.

세분화된 PAT 는 경계와 특정 리소스 권한으로 구성된 세분화된 스코프를 통해 세밀한 액세스 제어를 제공합니다. 세분화된 PAT 로 GraphQL 요청을 인증할 때 GitLab 은 토큰의 권한에 지정된 경계 수준에서 요청된 리소스에 대한 액세스가 포함되는지 검증합니다.

이 문서는 GraphQL 쿼리와 뮤테이션을 세분화된 PAT 인가에 맞게 준수시키려는 커뮤니티 기여자와 GitLab 개발자를 대상으로 작성되었습니다.

단계별 구현 가이드#

이 가이드는 GraphQL 타입과 뮤테이션에 세분화된 PAT 인가를 추가하는 과정을 안내합니다. 시작하기 전에 문서 전반에서 사용되는 용어를 이해하기 위해 권한 명명 규칙 문서를 확인합니다.

Note

이 단계는 GraphQL 타입과 뮤테이션만 다룹니다. REST API 엔드포인트 보호는 REST API 구현 가이드를 참고합니다.

인가 시스템이 내부적으로 어떻게 동작하는지에 대한 자세한 설명은 GraphQL 아키텍처 문서를 참고합니다.

워크플로 개요#

구현은 다음 흐름을 따릅니다:

  1. 1-2단계: 계획 - 타입/뮤테이션 식별 및 권한 설계
  2. 3단계: 원시 권한 생성(YAML 파일)
  3. 4단계: 원시 권한을 할당 가능한 권한으로 묶기(YAML 파일)
  4. 5단계: 타입/뮤테이션에 인가 지시어 추가(Ruby 코드)
  5. 6단계: 인가 테스트 작성(Ruby 스펙)
  6. 7단계: 로컬 테스트(수동 검증)
  7. 8단계: 문서 검증 및 재생성(Rake 태스크)

1단계: 보호할 GraphQL 타입 및 뮤테이션 식별#

목표: 작업 중인 리소스의 모든 GraphQL 타입과 뮤테이션을 찾습니다.

  1. app/graphql/types/ 에서 해당 리소스의 GraphQL 타입을 찾습니다.

    예시: 이슈 리소스의 경우 app/graphql/types/issue_type.rb 를 엽니다

  2. app/graphql/mutations/ 에서 관련 뮤테이션을 찾습니다.

    예시: 이슈의 경우 app/graphql/mutations/issues/ 를 확인합니다

  3. 인가가 필요한 타입과 뮤테이션을 식별합니다:

    • 사용자가 액세스하는 리소스를 나타내는 객체 타입(예: IssueType, ProjectType)
    • 리소스를 생성, 업데이트, 삭제하는 뮤테이션(예: Mutations::Issues::Create)
    • 리소스를 직접 반환하는 쿼리 필드(예: QueryType 의 field :project)
  4. 이미 authorize_granular_token 지시어가 선언된 타입이나 뮤테이션이 있는지 확인합니다. 지시어가 없는 타입과 뮤테이션에는 지시어를 추가해야 합니다.

2단계: 필요한 권한 결정#

목표: GitLab 명명 규칙에 따라 세분화된 권한을 정의합니다.

명명 규칙은 규칙 문서의 권한 명명을 참고합니다.

타입과 뮤테이션의 권한 이름 결정#

세분화된 PAT 인가를 구현할 때는 GraphQL 스키마 구조가 아니라, 해당 타입이 무엇을 나타내는지 또는 해당 뮤테이션이 무엇을 하는지를 기준으로 권한 이름을 정합니다.

예시:

  • 타입 IssueType → 이슈 읽기를 나타냄 → 권한 이름은 read_issue
  • 뮤테이션 Mutations::Issues::Create → 이슈를 생성함 → 권한 이름은 create_issue
  • 타입 ProjectType → 프로젝트 데이터 읽기를 나타냄 → 권한 이름은 read_project

일반적인 패턴#

  • 객체 타입: 해당 타입의 모든 필드를 포괄하는 read_resource 권한을 사용합니다
    • IssueType → read_issue
    • ProjectType → 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}\"}'" }
  1. 다른 터미널에 이 명령을 붙여 넣습니다. 성공해야 합니다.

8단계: 문서 검증 및 재생성#

목표: 모든 권한 정의와 지시어가 일관되는지 확인하고, 생성된 참조 문서를 업데이트합니다.

  1. 세밀한 토큰 참조 문서를 재생성합니다:

    bundle exec rake gitlab:permissions:graphql:compile_docs
    

    이 명령은 doc/auth/tokens/fine_grained_access_tokens_graphql.md 를 업데이트합니다. 이 파일은 직접 편집하면 안 됩니다.

  2. 권한 검증을 실행합니다:

    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 구현 가이드

GitLab v19.5
원문 보기

요약

침해된 개인 액세스 토큰(PAT)의 보안 영향을 줄이기 위해, 세분화된 PAT 또는 세밀한 PAT 는 특정 조직 경계(그룹, 프로젝트, 사용자 또는 인스턴스 수준)로 제한된 세밀한 권한을 가진 토큰을 사용자가 만들 수 있게 합니다.

침해된 개인 액세스 토큰(PAT)의 보안 영향을 줄이기 위해, 세분화된 PAT 또는 세밀한 PAT 는 특정 조직 경계(그룹, 프로젝트, 사용자 또는 인스턴스 수준)로 제한된 세밀한 권한을 가진 토큰을 사용자가 만들 수 있게 합니다. 이를 통해 사용자는 토큰에 필요한 권한만 부여하여 최소 권한 원칙을 따를 수 있습니다.

세분화된 PAT 는 경계와 특정 리소스 권한으로 구성된 세분화된 스코프를 통해 세밀한 액세스 제어를 제공합니다. 세분화된 PAT 로 GraphQL 요청을 인증할 때 GitLab 은 토큰의 권한에 지정된 경계 수준에서 요청된 리소스에 대한 액세스가 포함되는지 검증합니다.

이 문서는 GraphQL 쿼리와 뮤테이션을 세분화된 PAT 인가에 맞게 준수시키려는 커뮤니티 기여자와 GitLab 개발자를 대상으로 작성되었습니다.

단계별 구현 가이드#

이 가이드는 GraphQL 타입과 뮤테이션에 세분화된 PAT 인가를 추가하는 과정을 안내합니다. 시작하기 전에 문서 전반에서 사용되는 용어를 이해하기 위해 권한 명명 규칙 문서를 확인합니다.

Note

이 단계는 GraphQL 타입과 뮤테이션만 다룹니다. REST API 엔드포인트 보호는 REST API 구현 가이드를 참고합니다.

인가 시스템이 내부적으로 어떻게 동작하는지에 대한 자세한 설명은 GraphQL 아키텍처 문서를 참고합니다.

워크플로 개요#

구현은 다음 흐름을 따릅니다:

  1. 1-2단계: 계획 - 타입/뮤테이션 식별 및 권한 설계
  2. 3단계: 원시 권한 생성(YAML 파일)
  3. 4단계: 원시 권한을 할당 가능한 권한으로 묶기(YAML 파일)
  4. 5단계: 타입/뮤테이션에 인가 지시어 추가(Ruby 코드)
  5. 6단계: 인가 테스트 작성(Ruby 스펙)
  6. 7단계: 로컬 테스트(수동 검증)
  7. 8단계: 문서 검증 및 재생성(Rake 태스크)

1단계: 보호할 GraphQL 타입 및 뮤테이션 식별#

목표: 작업 중인 리소스의 모든 GraphQL 타입과 뮤테이션을 찾습니다.

  1. app/graphql/types/ 에서 해당 리소스의 GraphQL 타입을 찾습니다.

    예시: 이슈 리소스의 경우 app/graphql/types/issue_type.rb 를 엽니다

  2. app/graphql/mutations/ 에서 관련 뮤테이션을 찾습니다.

    예시: 이슈의 경우 app/graphql/mutations/issues/ 를 확인합니다

  3. 인가가 필요한 타입과 뮤테이션을 식별합니다:

    • 사용자가 액세스하는 리소스를 나타내는 객체 타입(예: IssueType, ProjectType)
    • 리소스를 생성, 업데이트, 삭제하는 뮤테이션(예: Mutations::Issues::Create)
    • 리소스를 직접 반환하는 쿼리 필드(예: QueryType 의 field :project)
  4. 이미 authorize_granular_token 지시어가 선언된 타입이나 뮤테이션이 있는지 확인합니다. 지시어가 없는 타입과 뮤테이션에는 지시어를 추가해야 합니다.

2단계: 필요한 권한 결정#

목표: GitLab 명명 규칙에 따라 세분화된 권한을 정의합니다.

명명 규칙은 규칙 문서의 권한 명명을 참고합니다.

타입과 뮤테이션의 권한 이름 결정#

세분화된 PAT 인가를 구현할 때는 GraphQL 스키마 구조가 아니라, 해당 타입이 무엇을 나타내는지 또는 해당 뮤테이션이 무엇을 하는지를 기준으로 권한 이름을 정합니다.

예시:

  • 타입 IssueType → 이슈 읽기를 나타냄 → 권한 이름은 read_issue
  • 뮤테이션 Mutations::Issues::Create → 이슈를 생성함 → 권한 이름은 create_issue
  • 타입 ProjectType → 프로젝트 데이터 읽기를 나타냄 → 권한 이름은 read_project

일반적인 패턴#

  • 객체 타입: 해당 타입의 모든 필드를 포괄하는 read_resource 권한을 사용합니다
    • IssueType → read_issue
    • ProjectType → 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}\"}'" }
  1. 다른 터미널에 이 명령을 붙여 넣습니다. 성공해야 합니다.

8단계: 문서 검증 및 재생성#

목표: 모든 권한 정의와 지시어가 일관되는지 확인하고, 생성된 참조 문서를 업데이트합니다.

  1. 세밀한 토큰 참조 문서를 재생성합니다:

    bundle exec rake gitlab:permissions:graphql:compile_docs
    

    이 명령은 doc/auth/tokens/fine_grained_access_tokens_graphql.md 를 업데이트합니다. 이 파일은 직접 편집하면 안 됩니다.

  2. 권한 검증을 실행합니다:

    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 조건이 해당 권한을 사용하는 코드의 태그와 맞지 않는 경우.
    • 생성된 참조 문서가 최신이 아닌 경우.

참고 자료#