InfoGrab DocsInfoGrab Docs

GraphQL 세분화 토큰 인가 아키텍처

요약

이 문서는 GranularScopeAuthorization 이 GraphQL 타입, 필드, 뮤테이션에 세분화된 개인 액세스 토큰(PAT) 권한을 적용하는 방식을 설명합니다. 세분화 토큰 인가 시스템은 타입, 필드, 뮤테이션에 적용된 authorize_granular_token 지시어를 기준으로 GraphQL에 세분화된 권한 검사를 추가합니다.

이 문서는 GranularScopeAuthorization 이 GraphQL 타입, 필드, 뮤테이션에 세분화된 개인 액세스 토큰(PAT) 권한을 적용하는 방식을 설명합니다. 단계별 구현 가이드는 GraphQL 구현 가이드를 참고합니다.

개요#

세분화 토큰 인가 시스템은 타입, 필드, 뮤테이션에 적용된 authorize_granular_token 지시어를 기준으로 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
  • 목적: 필요한 권한과 경계 추출 전략을 선언합니다
  • 인수:
    • permissions: 필요한 권한 문자열의 배열입니다(예: ['read_issue']).
    • boundary: 해석된 객체에서 경계를 추출할 메서드 이름입니다.
    • boundary_argument: 경계가 담긴 인수 이름입니다.
    • boundary_type: 인가 경계의 유형입니다(project, group, user, instance). 권한 경계의 유효성 검사와 문서화에 사용합니다.
    • requirement_group: 같은 요구 사항에 대해 서로 대안이 되는 경계인 지시어들을 묶는 레이블입니다. 토큰은 한 그룹 안의 경계 중 하나에서 인가받아야 하고, 타입이나 뮤테이션에 있는 서로 다른 모든 그룹에서 인가받아야 합니다. requirement_group 이 없는 지시어는 기본 그룹에 속합니다. 추가 스코프는 boundary_argument에서 파생된 그룹 이름을 받고, 그것이 없으면 additional_<index>를 받습니다.
    • assignable_when: 토큰 생성 UI에서 해당 권한이 제시되려면 현재 사용자가 충족해야 하는 조건입니다. 유효성 검사 태스크만 읽으며, 요청 시점에는 적용되지 않습니다.

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)
  • 유효성 검사: 권한은 gitlab:permissions:validate Rake 태스크가 Authz::PermissionGroups::Assignable.all_permissions와 대조해 검사합니다.

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은 다음 단계로 요청을 평가합니다.

1단계: 토큰 확인

이 검사는 세분화된 PAT 에만 적용됩니다. 레거시 PAT는 이 단계를 통과하고 기존 스코프 인가를 따르지만, 강제 적용이 설정된 경우는 예외입니다(Phase 3 참고).

2단계: 지시어 확인

세분화 지시어가 선언되어 있지 않으면 인가에 실패합니다.

3단계: 경계 추출

경계는 각 지시어가 선언한 소스에서 추출합니다. 타입 수준 검사는 해석된 객체에서, 뮤테이션과 루트 쿼리 필드는 인수에서 추출합니다. 자세한 내용은 경계 추출을 참고합니다.

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 배치 로딩에 함께 참여합니다.
  • 전체 경로 문자열은 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)

인수가 다른 레코드로 해석되면 그 레코드에서 Project 나 Group에 도달하도록 boundary를 추가합니다.

authorize_granular_token permissions: :create_note, boundary_argument: :id, boundary: :project, boundary_type: :project
# boundary => located_record.project

여러 경계#

같은 리소스가 서로 다른 경계 유형에 속할 수 있으면 타입은 경계를 둘 이상 선언할 수 있습니다. 구체 경계는 특정 Project 또는 Group 레코드입니다. 독립 경계는 프로젝트나 그룹에 속하지 않는 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를 가지고 있는지 확인합니다.

독립 경계가 구체 경계보다 우선하는 경우는 러너에 소유 프로젝트나 그룹이 없는 마지막 경우뿐입니다.

추가로 필요한 스코프#

여러 경계는 한 그룹 안의 대안을 다루며, 이때 토큰은 그룹을 공유하는 지시어 중 하나만 충족하면 됩니다. 추가로 필요한 스코프는 반대로 동작합니다. 각 그룹은 서로 독립된 누적 요구 사항이므로 토큰은 모든 그룹을 충족해야 합니다.

BoundaryExtractor는 타입이나 뮤테이션에 선언된 지시어를 requirement_group으로 묶은 다음, 각 그룹의 경계를 독립적으로 해석합니다. GranularScopeAuthorization은 각 그룹을 AuthorizeGranularScopesService 호출로 따로 인가하고, 그 그룹의 권한과 해석된 경계로 만든 키에 결과를 캐시합니다. 토큰은 기본 스코프에서는 인가되고 추가 스코프에서는 거부될 수 있으며 그 반대도 가능하고, 어느 결과도 다른 쪽의 캐시 항목에 영향을 주지 않습니다.

authorize_granular_token에 additional_scopes를 선언하면 항목마다 지시어가 하나씩 추가되며, 각 지시어는 boundary_argument에서 파생된 requirement_group을 받고, 그것이 없으면 additional_<index>를 받습니다. 기본 경계와 그에 대한 boundaries 대안은 기본 그룹을 유지합니다. boundary_argument를 공유하는 항목은 같은 요구 사항 그룹에 속하며 그 안에서 서로 대안으로 동작합니다. permissions_for는 그룹마다 첫 항목에서 가져온 권한 목록 하나만 읽으므로, 요구 사항 그룹을 공유하는 항목은 동일한 permissions 값을 선언해야 합니다.

authorize_granular_token permissions: :move_issue,
  boundary_argument: :project_path, boundary_type: :project,
  additional_scopes: [
    { permissions: :create_work_item, boundary_argument: :target_project_path, boundary_type: :project }
  ]

이 뮤테이션에서 기본 지시어(project_path로 해석된 프로젝트의 move_issue)는 기본 그룹에 속하고, 추가 지시어(target_project_path로 해석된 프로젝트의 create_work_item)는 자체 target_project_path 그룹에 속합니다. 뮤테이션이 실행되려면 두 그룹 모두 인가받아야 합니다.

그룹의 경계를 해석할 수 없으면 AuthorizeGranularScopesService는 기본 경계를 해석하지 못한 경우와 마찬가지로 해당 그룹에 404 Not Found를 반환합니다. 경계 해석 오류를 참고합니다.

예시 시나리오#

시나리오 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
  1. 뮤테이션이 실행되기 전에 GraphQL Ruby가 Mutations::BaseMutation#authorized?를 호출합니다.
  2. BoundaryExtractor가 project_path를 읽고 Project.find_by_full_path로 Project를 해석합니다.
  3. AuthorizeGranularScopesService가 토큰이 그 프로젝트에서 create_issue를 가지고 있는지 확인합니다.
  4. 토큰에 권한이 있으면 뮤테이션이 실행됩니다. 없으면 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
  1. 각 Issue가 해석될 때 IssueType에 대해 Types::BaseObject.authorized?가 실행됩니다.
  2. BoundaryExtractor가 issue.project를 호출해 경계에 도달합니다.
  3. AuthorizeGranularScopesService가 토큰이 그 프로젝트에서 read_issue를 가지고 있는지 확인합니다.
  4. 토큰에 권한이 있으면 이슈가 해석됩니다. 없으면 접근이 거부되고 검사는 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
  1. 리졸버가 실행되기 전에 GraphQL Ruby가 필드 인수와 함께 Types::BaseField#authorized?를 호출합니다.
  2. BoundaryExtractor가 full_path를 읽고 Group.find_by_full_path로 Group을 해석합니다.
  3. AuthorizeGranularScopesService가 토큰이 그 그룹에서 read_ai_tool_rule을 가지고 있는지 확인합니다.
  4. 토큰에 권한이 있으면 필드가 해석됩니다. 없으면 필드는 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를 발생시키며, gitlab:permissions:validate Rake 태스크가 Authz::PermissionGroups::Assignable.all_permissions와 대조할 때도 잡아냅니다.
  • boundary_type 키를 가진 Hash가 아닌 boundaries: 항목은 ArgumentError를 발생시킵니다.

참조#

GraphQL 세분화 토큰 인가 아키텍처

GitLab v19.4
원문 보기

요약

이 문서는 GranularScopeAuthorization 이 GraphQL 타입, 필드, 뮤테이션에 세분화된 개인 액세스 토큰(PAT) 권한을 적용하는 방식을 설명합니다. 세분화 토큰 인가 시스템은 타입, 필드, 뮤테이션에 적용된 authorize_granular_token 지시어를 기준으로 GraphQL에 세분화된 권한 검사를 추가합니다.

이 문서는 GranularScopeAuthorization 이 GraphQL 타입, 필드, 뮤테이션에 세분화된 개인 액세스 토큰(PAT) 권한을 적용하는 방식을 설명합니다. 단계별 구현 가이드는 GraphQL 구현 가이드를 참고합니다.

개요#

세분화 토큰 인가 시스템은 타입, 필드, 뮤테이션에 적용된 authorize_granular_token 지시어를 기준으로 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
  • 목적: 필요한 권한과 경계 추출 전략을 선언합니다
  • 인수:
    • permissions: 필요한 권한 문자열의 배열입니다(예: ['read_issue']).
    • boundary: 해석된 객체에서 경계를 추출할 메서드 이름입니다.
    • boundary_argument: 경계가 담긴 인수 이름입니다.
    • boundary_type: 인가 경계의 유형입니다(project, group, user, instance). 권한 경계의 유효성 검사와 문서화에 사용합니다.
    • requirement_group: 같은 요구 사항에 대해 서로 대안이 되는 경계인 지시어들을 묶는 레이블입니다. 토큰은 한 그룹 안의 경계 중 하나에서 인가받아야 하고, 타입이나 뮤테이션에 있는 서로 다른 모든 그룹에서 인가받아야 합니다. requirement_group 이 없는 지시어는 기본 그룹에 속합니다. 추가 스코프는 boundary_argument에서 파생된 그룹 이름을 받고, 그것이 없으면 additional_<index>를 받습니다.
    • assignable_when: 토큰 생성 UI에서 해당 권한이 제시되려면 현재 사용자가 충족해야 하는 조건입니다. 유효성 검사 태스크만 읽으며, 요청 시점에는 적용되지 않습니다.

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)
  • 유효성 검사: 권한은 gitlab:permissions:validate Rake 태스크가 Authz::PermissionGroups::Assignable.all_permissions와 대조해 검사합니다.

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은 다음 단계로 요청을 평가합니다.

1단계: 토큰 확인

이 검사는 세분화된 PAT 에만 적용됩니다. 레거시 PAT는 이 단계를 통과하고 기존 스코프 인가를 따르지만, 강제 적용이 설정된 경우는 예외입니다(Phase 3 참고).

2단계: 지시어 확인

세분화 지시어가 선언되어 있지 않으면 인가에 실패합니다.

3단계: 경계 추출

경계는 각 지시어가 선언한 소스에서 추출합니다. 타입 수준 검사는 해석된 객체에서, 뮤테이션과 루트 쿼리 필드는 인수에서 추출합니다. 자세한 내용은 경계 추출을 참고합니다.

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 배치 로딩에 함께 참여합니다.
  • 전체 경로 문자열은 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)

인수가 다른 레코드로 해석되면 그 레코드에서 Project 나 Group에 도달하도록 boundary를 추가합니다.

authorize_granular_token permissions: :create_note, boundary_argument: :id, boundary: :project, boundary_type: :project
# boundary => located_record.project

여러 경계#

같은 리소스가 서로 다른 경계 유형에 속할 수 있으면 타입은 경계를 둘 이상 선언할 수 있습니다. 구체 경계는 특정 Project 또는 Group 레코드입니다. 독립 경계는 프로젝트나 그룹에 속하지 않는 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를 가지고 있는지 확인합니다.

독립 경계가 구체 경계보다 우선하는 경우는 러너에 소유 프로젝트나 그룹이 없는 마지막 경우뿐입니다.

추가로 필요한 스코프#

여러 경계는 한 그룹 안의 대안을 다루며, 이때 토큰은 그룹을 공유하는 지시어 중 하나만 충족하면 됩니다. 추가로 필요한 스코프는 반대로 동작합니다. 각 그룹은 서로 독립된 누적 요구 사항이므로 토큰은 모든 그룹을 충족해야 합니다.

BoundaryExtractor는 타입이나 뮤테이션에 선언된 지시어를 requirement_group으로 묶은 다음, 각 그룹의 경계를 독립적으로 해석합니다. GranularScopeAuthorization은 각 그룹을 AuthorizeGranularScopesService 호출로 따로 인가하고, 그 그룹의 권한과 해석된 경계로 만든 키에 결과를 캐시합니다. 토큰은 기본 스코프에서는 인가되고 추가 스코프에서는 거부될 수 있으며 그 반대도 가능하고, 어느 결과도 다른 쪽의 캐시 항목에 영향을 주지 않습니다.

authorize_granular_token에 additional_scopes를 선언하면 항목마다 지시어가 하나씩 추가되며, 각 지시어는 boundary_argument에서 파생된 requirement_group을 받고, 그것이 없으면 additional_<index>를 받습니다. 기본 경계와 그에 대한 boundaries 대안은 기본 그룹을 유지합니다. boundary_argument를 공유하는 항목은 같은 요구 사항 그룹에 속하며 그 안에서 서로 대안으로 동작합니다. permissions_for는 그룹마다 첫 항목에서 가져온 권한 목록 하나만 읽으므로, 요구 사항 그룹을 공유하는 항목은 동일한 permissions 값을 선언해야 합니다.

authorize_granular_token permissions: :move_issue,
  boundary_argument: :project_path, boundary_type: :project,
  additional_scopes: [
    { permissions: :create_work_item, boundary_argument: :target_project_path, boundary_type: :project }
  ]

이 뮤테이션에서 기본 지시어(project_path로 해석된 프로젝트의 move_issue)는 기본 그룹에 속하고, 추가 지시어(target_project_path로 해석된 프로젝트의 create_work_item)는 자체 target_project_path 그룹에 속합니다. 뮤테이션이 실행되려면 두 그룹 모두 인가받아야 합니다.

그룹의 경계를 해석할 수 없으면 AuthorizeGranularScopesService는 기본 경계를 해석하지 못한 경우와 마찬가지로 해당 그룹에 404 Not Found를 반환합니다. 경계 해석 오류를 참고합니다.

예시 시나리오#

시나리오 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
  1. 뮤테이션이 실행되기 전에 GraphQL Ruby가 Mutations::BaseMutation#authorized?를 호출합니다.
  2. BoundaryExtractor가 project_path를 읽고 Project.find_by_full_path로 Project를 해석합니다.
  3. AuthorizeGranularScopesService가 토큰이 그 프로젝트에서 create_issue를 가지고 있는지 확인합니다.
  4. 토큰에 권한이 있으면 뮤테이션이 실행됩니다. 없으면 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
  1. 각 Issue가 해석될 때 IssueType에 대해 Types::BaseObject.authorized?가 실행됩니다.
  2. BoundaryExtractor가 issue.project를 호출해 경계에 도달합니다.
  3. AuthorizeGranularScopesService가 토큰이 그 프로젝트에서 read_issue를 가지고 있는지 확인합니다.
  4. 토큰에 권한이 있으면 이슈가 해석됩니다. 없으면 접근이 거부되고 검사는 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
  1. 리졸버가 실행되기 전에 GraphQL Ruby가 필드 인수와 함께 Types::BaseField#authorized?를 호출합니다.
  2. BoundaryExtractor가 full_path를 읽고 Group.find_by_full_path로 Group을 해석합니다.
  3. AuthorizeGranularScopesService가 토큰이 그 그룹에서 read_ai_tool_rule을 가지고 있는지 확인합니다.
  4. 토큰에 권한이 있으면 필드가 해석됩니다. 없으면 필드는 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를 발생시키며, gitlab:permissions:validate Rake 태스크가 Authz::PermissionGroups::Assignable.all_permissions와 대조할 때도 잡아냅니다.
  • boundary_type 키를 가진 Hash가 아닌 boundaries: 항목은 ArgumentError를 발생시킵니다.

참조#