InfoGrab DocsInfoGrab Docs

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

요약

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

이 문서는 GranularTokenAuthorization 필드 확장이 GraphQL 쿼리 및 뮤테이션에서 세분화된 개인 액세스 토큰(PAT) 권한을 적용하는 방식을 설명합니다. 단계별 구현 가이드는 GraphQL 구현 가이드를 참조하세요.

개요#

세분화 토큰 인가 시스템은 타입, 필드, 뮤테이션에 적용된 지시어(directive)를 기반으로 GraphQL 필드에 세밀한 권한 검사를 추가합니다. 이 시스템은 세분화된 PAT가 특정 프로젝트 또는 그룹 경계 내에서 명시적으로 권한이 부여된 리소스에만 접근할 수 있도록 보장합니다.

기능 플래그: 이 기능은 토큰의 사용자에 대해 granular_personal_access_tokens 기능 플래그가 활성화되어 있어야 합니다. 플래그가 비활성화된 경우 세분화된 PAT는 GraphQL 요청에 대해 작동하지 않습니다.

아키텍처 구성 요소#

1. 필드 확장(Field Extension)#

  • 위치: lib/gitlab/graphql/authz/granular_token_authorization.rb

  • 목적: 필드 해석(resolution)을 가로채어 인가 검사를 수행합니다.

  • 적용 대상: Types::BaseField를 통해 모든 GraphQL 필드에 적용됩니다.

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. 지시어 파인더(Directive Finder)#

  • 위치: lib/gitlab/graphql/authz/directive_finder.rb

  • 목적: 우선순위 순서(field, implementing type, return type, owner type)로 적용 가능한 지시어를 찾습니다.

  • 포함: GraphQL 타입 래퍼를 언래핑하기 위한 TypeUnwrapper 모듈

4. 경계 추출기(Boundary Extractor)#

  • 위치: lib/gitlab/graphql/authz/boundary_extractor.rb

  • 목적: 다양한 소스에서 인가 경계를 추출합니다.

5. 타입 언래퍼(Type Unwrapper)#

  • 위치: lib/gitlab/graphql/authz/type_unwrapper.rb

  • 목적: GraphQL 타입 래퍼(List, NonNull, Connection)를 언래핑하기 위한 공유 모듈입니다.

  • 사용처: DirectiveFinder 및 SkipRules

6. 헬퍼 모듈(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:validate Rake 태스크로 검증됩니다.

요청 흐름 타임라인#

Phase 1: 요청 시작#

1. GraphQL 요청 도착 (쿼리 또는 뮤테이션)
2. GraphQL Ruby가 파싱 및 유효성 검사 시작
3. 루트 필드로 실행 시작

Phase 2: 필드 해석(필드별)#

각 필드가 해석될 때:

1. GraphQL Ruby가 순서대로 필드 확장을 호출합니다
   ├─ CallsGitaly::FieldExtension (개발/테스트 전용)
   ├─ Present::FieldExtension
   ├─ Authorize::FieldExtension
   └─ GranularTokenAuthorization ← 여기에 있습니다

Phase 3: 인가 검사#

Step 1: 조기 종료 조건

def authorize_field(object, arguments, context)
  return unless authorization_enabled?(context)  # 세분화된 PAT만 인가
  return if SkipRules.new(@field).should_skip?  # 특정 필드 건너뜀
  # ...
end

def authorization_enabled?(context)
  token = context[:access_token]
  token && token.try(:granular?)
end
  • 세분화된 PAT를 사용하지 않는 경우 세분화 스코프 인가가 건너뜁니다 (레거시 PAT는 기존 스코프 인가를 사용합니다).

  • 특정 필드는 자동으로 건너뜁니다:

뮤테이션 응답 필드 (예: createIssue.issue). 인가는 응답 래퍼가 아닌 뮤테이션 자체에서 수행됩니다.

  • 권한 메타데이터 필드 (예: issue.userPermissions). 이 필드들은 실제 데이터가 아닌 권한 정보를 반환합니다.

  • 엣지 래퍼 필드 (예: groupMembers.nodes, groupMembers.cursor). 이 필드들은 자체 필드에서 인가를 적용하는 기본 노드 타입으로 순회합니다.

  • 인가된 반환 타입으로의 순회. 필드에 자체 지시어가 없고, 소유자 타입이 지시어를 가지며, 언래핑된 반환 타입이 자체 지시어를 가지고 더 깊은 인가된 하위 필드를 가지는 경우, 소유자 수준의 지시어는 건너뜁니다. 반환 타입의 지시어는 자식 객체의 필드가 해석될 때 적용됩니다. 리프(Leaf) 반환 타입(모든 스칼라 필드)은 빈 결과가 검사를 완전히 우회할 수 있으므로 건너뛰지 않습니다. 자세한 내용은 인가된 반환 타입으로의 순회를 참조하세요.

Step 2: 지시어 탐색

directive = DirectiveFinder.new(@field).find(object)

DirectiveFinder는 다음 우선순위 순서로 지시어를 검사하며 첫 번째 일치 항목을 반환합니다:

  • 필드 수준 지시어 (FIELD_DEFINITION): 필드에 직접 적용됩니다.

  • 구현 타입 지시어 (인터페이스의 경우): 인터페이스를 구현하는 구체 타입에 적용됩니다.

필드 소유자가 인터페이스이고 object가 제공된 경우에만 검사됩니다.

  • GitlabSchema.types에서 실제 모델 타입(예: Issue)을 해석합니다.

  • 반환 타입 지시어: 필드가 반환하는 타입에 적용됩니다.

GraphQL 타입 래퍼를 언래핑하여 기본 타입을 찾습니다:

List 타입: [Type]Type

  • NonNull 타입: Type!Type

  • Connection 타입: TypeConnectionType (예: IssueConnectionIssueType)

  • boundary_argumentboundary 전략 모두에서 동작합니다.

  • :id 인수와 함께 boundary를 사용할 경우 경계 추출을 위한 ID 폴백을 활성화합니다.

  • 소유자 타입 지시어 (OBJECT): 필드를 소유하는 타입에 적용됩니다.

리프 인가 타입으로 이어지는 필드(예: project.languagesRepositoryLanguageType)가 포함 타입의 지시어(예: read_project) 대신 해당 타입의 지시어를 사용하도록 마지막에 검사합니다. 이를 통해 결과가 비어 있을 때도 올바른 권한이 적용됩니다.

Step 3: 경계 추출

boundary = BoundaryExtractor.new(object:, arguments:, context:, directive:).extract
permissions = directive.arguments[:permissions]

지시어를 찾을 수 없는 경우, boundarypermissions는 모두 nil입니다. 인가 서비스는 "Unable to determine boundaries and permissions for authorization"이라는 오류 메시지를 반환합니다.

경계 추출기의 동작:

  • 독립형 리소스 (boundary: 'user' 또는 boundary: 'instance'): Authz::Boundary::NilBoundary를 반환합니다.

  • 유효한 프로젝트/그룹 리소스: 래핑된 경계를 반환합니다 (ProjectBoundary 또는 GroupBoundary).

  • 리소스를 찾을 수 없는 경우: nil을 반환합니다 (NilBoundary로 래핑되지 않음).

지원되는 경계 타입:

  • Authz::Boundary::ProjectBoundary - Project 리소스용

  • Authz::Boundary::GroupBoundary - Group 리소스용

  • Authz::Boundary::NilBoundary - 독립형 리소스용 (사용자 스코프 또는 인스턴스 전체)

추출기는 네 가지 전략 중 하나를 사용합니다:

전략 A: boundary_argument (뮤테이션 및 쿼리 필드용)

# 지시어: boundary_argument: 'project_path'
# 필드 인수: project_path: "gitlab-org/gitlab"

extract_from_argument('project_path')
  ↓
args[:project_path] = "gitlab-org/gitlab"
  ↓
resolve_path("gitlab-org/gitlab")
  ↓
Project.find_by_full_path("gitlab-org/gitlab") || Group.find_by_full_path("gitlab-org/gitlab")
  ↓
returns Project or Group instance

전략 B: boundary (해석된 객체가 있는 타입 필드용)

경계 메서드는 유효한 접근자 메서드 중 하나여야 합니다: project, group, 또는 itself. 다른 값을 사용하면 ArgumentError가 발생합니다.

# 지시어: boundary: 'project'
# Object: Issue 인스턴스

extract_from_method('project')
  ↓
unwrap_object(object)  # Issue
  ↓
object_matches_boundary_type?('project')  # false (Issue ≠ Project)
  ↓
VALID_BOUNDARY_ACCESSOR_METHODS.include?('project')  # true
  ↓
object.respond_to?(:project) # true
  ↓
object.project
  ↓
returns Project instance

boundary: 'itself'를 사용하면 객체 자신이 경계로 반환됩니다. 이는 자신이 Project 또는 Group인 타입에 유용합니다:

# 지시어: boundary: 'itself'
# Object: Project 인스턴스

extract_from_method('itself')
  ↓
unwrap_object(object)  # Project
  ↓
object_matches_boundary_type?('itself')  # false (Project ≠ Itself)
  ↓
VALID_BOUNDARY_ACCESSOR_METHODS.include?('itself')  # true
  ↓
object.itself  # Ruby의 Object#itself는 self를 반환
  ↓
returns Project instance

전략 C: ID 폴백 (GlobalID가 있는 쿼리 필드용)

다음 경우에 사용됩니다:

  • 지시어가 boundary: 'project'를 지정한 경우

  • 객체가 nil이거나 경계 메서드에 응답하지 않는 경우

  • 필드에 GlobalID가 있는 :id 인수가 있는 경우

# 쿼리: issue(id: "gid://gitlab/Issue/123")
# 지시어: boundary: 'project'
# Object: nil (쿼리 필드, 아직 해석되지 않음)

extract_from_id_argument
  ↓
args[:id] = "gid://gitlab/Issue/123"
  ↓
GlobalID.parse("gid://gitlab/Issue/123")
  ↓
GlobalID::Locator.locate(gid)  # Issue.find(123) - 추가 DB 쿼리
  ↓
extract_boundary_from_object(issue)
  ↓
issue.project
  ↓
returns Project instance

성능 참고: 이 전략은 레코드를 두 번 가져옵니다 - 인가를 위해 한 번, 필드 해석 중에 한 번. 단, 쿼리는 캐시됩니다.

전략 D: 독립형 경계 (사용자 스코프 또는 인스턴스 전체 리소스용)

다음 경우에 사용됩니다:

  • 지시어가 boundary: 'user'를 지정한 경우 (사용자 스코프 리소스)

  • 지시어가 boundary: 'instance'를 지정한 경우 (인스턴스 전체 리소스)

# 지시어: boundary: 'user'
# 리소스가 특정 프로젝트/그룹에 속하지 않음

standalone_boundary?('user')
  ↓
@boundary_accessor.to_sym  # :user
  ↓
Authz::Boundary.for(:user)
  ↓
returns Authz::Boundary::NilBoundary.new(:user)
  ↓
Authorization checks token has appropriate permissions

이 전략은 특정 프로젝트 또는 그룹 경계에 속하지 않지만 사용자 스코프이거나 인스턴스 전체인 리소스에 사용됩니다.

Step 4: 인가 검사

authorize_with_cache!(context, boundary, permissions)

이 메서드는 다음을 수행합니다:

  • 캐시 확인: 중복 검사를 방지하기 위해 context[:authz_cache]를 확인합니다.

  • 인가 서비스 호출:

::Authz::Tokens::AuthorizeGranularScopesService.new(
  boundaries: boundary,
  permissions: permissions,
  token: context[:access_token]
).execute
  • 검증: 토큰이 경계에 대해 필요한 권한을 가지고 있는지 확인합니다.

  • 인가 실패 시 오류 발생: raise_resource_not_available_error!(response.message).

  • 결과 캐싱: 중복 검사를 방지하기 위해 결과를 캐시합니다.

매칭된 지시어에 traversal: true가 있는 경우, 확장은 토큰에 경계가 표시되는지만 확인하는 별도의 인가 경로를 사용합니다. 자세한 내용은 traversal: true를 사용하는 진입점 필드를 참조하세요.

Step 5: 필드 해석

yield(object, arguments, **rest)

인가가 통과되면 필드 해석기가 실행되고 값을 반환합니다.

예시 시나리오#

시나리오 1: boundary_argument를 사용한 뮤테이션#

GraphQL 요청:

mutation {
  createIssue(input: {
    projectPath: "gitlab-org/gitlab",
    title: "New issue"
  }) {
    issue { id }
  }
}

지시어:

class Create < BaseMutation
  authorize_granular_token permissions: :create_issue, boundary_argument: :project_path, boundary_type: :project
end

타임라인:

  • createIssue 필드에 대해 확장이 호출됩니다.

  • object = nil (루트 뮤테이션 필드)

  • 뮤테이션 클래스에서 지시어 발견

  • arguments[:input][:project_path]에서 경계 추출

  • Project.find_by_full_path("gitlab-org/gitlab") → Project

  • 인가 서비스 검사: 토큰이 이 프로젝트에 대해 create_issue 권한을 가지고 있는가?

  • 예인 경우: 뮤테이션 실행

  • 아닌 경우: 오류 발생, 뮤테이션 실행되지 않음

시나리오 2: boundary를 사용한 타입 (중첩 필드)#

GraphQL 요청:

query {
  project(fullPath: "gitlab-org/gitlab") {
    issues {
      nodes {
        title        # ← 여기서 인가
        description  # ← 그리고 여기서도
      }
    }
  }
}

지시어:

class IssueType < BaseObject
  authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
end

타임라인 (title 필드의 경우):

  • title 필드에 대해 확장이 호출됩니다.

  • object = Issue 인스턴스 (이미 해석됨)

  • IssueType에서 지시어 발견 (title 필드의 소유자)

  • issue.project를 호출하여 경계 추출

  • 인가 서비스 검사: 토큰이 이 프로젝트에 대해 read_issue 권한을 가지고 있는가?

  • 후속 필드(description 등)에서 캐시 히트 - 추가 DB 쿼리 없음

  • 예인 경우: 필드가 해석되어 title 반환

  • 아닌 경우: 오류 발생

시나리오 3: ID 폴백을 사용한 쿼리 필드#

GraphQL 요청:

query {
  issue(id: "gid://gitlab/Issue/123") {
    title
  }
}

지시어:

class IssueType < BaseObject
  authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
end

타임라인:

  • issue 필드에 대해 확장이 호출됩니다 (IssueType 반환).

  • object = nil (루트 쿼리 필드)

  • 반환 타입(IssueType)에서 지시어 발견

  • 경계 추출 감지: object가 nil이지만 :id 인수가 있음

  • ID 폴백 사용: GlobalID 추출 → Issue 찾기 → issue.project 가져오기

  • 인가 서비스 검사: 토큰이 이 프로젝트에 대해 read_issue 권한을 가지고 있는가?

  • 예인 경우: 필드 해석 (Issue가 해석기에 의해 다시 가져옴)

  • 아닌 경우: 필드 해석 전에 오류 발생

시나리오 4: traversal: true를 사용한 진입점 필드#

GraphQL 요청:

query {
  group(fullPath: "gitlab-org") {
    groupMembers {
      nodes {
        id
      }
    }
  }
}

Query.group에 대한 진입점 지시어:

field :group, Types::GroupType,
  resolver: Resolvers::GroupResolver,
  directives: granular_scope_directive(
    permissions: :read_group, boundary_argument: :full_path, boundary_type: :group,
    traversal: true
  )

타임라인:

  • group 필드에 대해 확장이 호출됩니다.

  • traversal: true인 지시어가 필드에서 해석됩니다.

  • arguments[:full_path] ("gitlab-org")에서 경계 추출.

  • 인가 서비스가 순회 모드에서 실행되어 token.can?(:read_boundary, boundary)를 검증합니다. read_group 권한은 적용되지 않습니다.

  • groupMembers 필드에 대해 확장이 호출됩니다. 소유자는 GroupType이며 (read_group 지시어를 가짐), 반환 타입은 GroupMemberType입니다 (read_member 지시어를 가짐). 순회 건너뜀이 적용되므로 토큰 검사가 실행되지 않습니다.

  • nodes 필드에 대해 확장이 호출됩니다. 엣지 래퍼로서 건너뜁니다.

  • GroupMemberid 필드에 대해 확장이 호출됩니다. 소유자는 GroupMemberType이며 read_member가 필요합니다. 그룹 경계에 대해 read_member를 토큰으로 검사합니다.

토큰은 read_member만으로 멤버 데이터에 도달하며, 이는 REST 엔드포인트 GET /api/v4/groups/:id/members와 일치합니다.

인가된 반환 타입으로의 순회#

세분화 토큰 인가 타입의 필드는 그렇지 않으면 소유자 타입의 지시어를 상속합니다. 필드의 언래핑된 반환 타입도 세분화 토큰 지시어를 가질 때 소유자 지시어는 중복됩니다. 반환 타입의 지시어는 자식 객체의 필드가 해석될 때 인가를 적용합니다. SkipRules 클래스는 이 경우를 감지하고 소유자 수준 검사를 건너뜁니다.

다음 조건이 모두 충족될 때 건너뜀이 적용됩니다:

  • 필드에 자체 세분화 토큰 지시어가 없습니다 (명시적인 필드 수준 지시어는 항상 우선합니다).

  • 필드의 소유자 타입이 세분화 토큰 지시어를 가집니다.

  • 필드의 언래핑된 반환 타입(list, non-null, connection 래퍼를 제거한 후)이 세분화 토큰 지시어를 가집니다.

  • 반환 타입에 자체 언래핑된 반환 타입이 세분화 토큰 지시어를 가지는 필드가 하나 이상 있습니다 (즉, 모든 필드가 일반 스칼라를 반환하는 "리프" 타입이 아닌 경우).

네 번째 조건은 안전을 위해 필요합니다: 반환 타입이 리프인 경우(모든 필드가 스칼라를 반환하는 경우, 예: RepositoryLanguageType 또는 PushRulesType), 빈 컬렉션이나 nil 결과에 대해 항목별 해석기가 실행되지 않습니다. 컬렉션 수준 검사를 건너뛰면 빈 결과가 인가를 완전히 우회할 수 있습니다. 리프 타입의 경우 컬렉션 수준 검사가 유일한 적용 지점이므로 건너뜀이 실행되지 않아야 합니다.

효과: 자식 리소스의 권한만 가진 토큰은 부모의 읽기 권한 없이도 부모를 통해 자식으로 순회할 수 있습니다. 스칼라나 다른 인가되지 않은 타입을 반환하는 부모의 데이터 필드는 여전히 부모의 권한이 필요합니다.

예시: Group.groupMembersGroupMemberType을 반환합니다. GroupTypeGroupMemberType 모두 세분화 토큰 지시어를 선언합니다. group.groupMembers를 해석할 때 read_group이 더 이상 필요하지 않습니다. 각 GroupMember의 모든 필드를 해석할 때는 read_member가 필요합니다. group.name (스칼라)을 해석할 때는 여전히 read_group이 필요합니다.

traversal: true를 사용하는 진입점 필드#

Query.group(fullPath:)Query.project(fullPath:)와 같은 최상위 필드는 경로 인수에서 경계를 해석하기 위해 존재합니다. 이 필드들은 자체적으로 데이터를 노출하지 않습니다. 다운스트림 필드가 실제 권한을 적용합니다. 이 의도를 선언하려면 지시어에 traversal: true를 설정하세요.

traversal: true인 경우:

  • 경계가 평소와 같이 boundary_argument에서 해석됩니다.

  • 인가 서비스가 순회 모드에서 실행되어 token.can?(:read_boundary, boundary)만 검사합니다. permissions 인수는 적용되지 않습니다. 문서화를 위해 지시어에 남아 있습니다.

  • 경계가 해석되지 않거나 토큰에게 경계가 표시되지 않으면, 서비스는 404 Not Found를 반환하고 필드는 오류와 함께 null을 반환합니다.

순회 캐시 키는 [:traversal, boundary.class, boundary.namespace&.id]이며, 권한 기반 캐시 키와 별개입니다.

traversal: trueprojectgroup 경계 타입에만 적용됩니다. 다른 모든 경계 타입의 경우, 확장은 일반 권한 검사로 폴백합니다.

성능 최적화#

1. 캐싱#

요청별 캐시:

context[:authz_cache] = Set.new
cache_key = [permissions&.sort, boundary&.class, boundary&.namespace&.id]

# `read_issue`에 대한 캐시 키 예시 (프로젝트의 경우):
# [["read_issue"], Authz::Boundary::ProjectBoundary, 123]
  • 인가 결과는 Set을 사용하여 요청별로 캐시됩니다.

  • 동일한 경계 및 권한에 대한 중복 인가 검사를 방지합니다.

  • 예시: 동일한 프로젝트에서 10개의 이슈 필드를 검사할 때 인가 서비스를 한 번만 호출합니다.

  • 캐시 키 구성 요소:

permissions&.sort: 소문자 권한 문자열의 정렬된 배열

  • boundary&.class: 경계 래퍼 클래스 (예: Authz::Boundary::ProjectBoundary)

  • boundary&.namespace&.id: 네임스페이스 ID (경계 타입에 따라 다름):

ProjectBoundary: project.project_namespace.id

  • GroupBoundary: group.id

  • NilBoundary: nil

2. 조기 반환#

return unless authorization_enabled?(context)
return if SkipRules.new(@field).should_skip?
  • 세분화되지 않은 토큰은 전체 시스템을 건너뜁니다 (오버헤드 없음)

  • 뮤테이션 응답 필드와 권한 메타데이터 필드는 자동으로 건너뜁니다 (자세한 내용은 Phase 3, Step 1 참조)

오류 처리#

인가 실패#

인가가 실패하면:

raise_resource_not_available_error!(response.message)

GraphQL의 경우:

  • errors 배열에 서비스 오류를 반환합니다.

  • 필드가 null을 반환합니다.

응답 예시:

{
  "data": { "issue": null },
  "errors": [{
    "message": "Insufficient permissions",
    "path": ["issue"]
  }]
}

엣지 케이스 및 오류 시나리오#

설정 누락 오류#

  • 지시어를 찾을 수 없는 경우 (세분화된 PAT 사용 시)

동작: boundary: nil, permissions: nil로 인가가 진행됩니다.

  • 결과: 인가 서비스가 오류를 반환합니다.

  • 오류 메시지: "Unable to determine boundaries and permissions for authorization"

  • 참고: 세분화된 PAT로 접근하는 모든 필드에는 지시어가 있어야 합니다.

  • 지시어에 빈 권한 배열이 있는 경우

동작: permissions: []로 인가가 진행됩니다 (경계는 제공됨).

  • 결과: 인가 서비스가 오류를 반환합니다.

  • 오류 메시지: "Unable to determine permissions for authorization"

  • 원인: permissions: []로 지시어가 정의됨

경계 해석 오류#

  • 경계 추출이 nil을 반환하는 경우 (리소스를 찾을 수 없음)

동작: boundary: nil로 인가가 진행됩니다 (권한은 여전히 제공됨).

  • 결과: 인가 서비스가 오류를 반환합니다.

  • 오류 메시지: "Unable to determine boundaries for authorization"

  • 원인:

리소스로 해석되지 않는 유효하지 않은 경로/GlobalID

  • 예상 연관을 누락한 객체 (예: issue.projectnil 반환)

  • 지시어에 boundaryboundary_argument 모두 설정되지 않은 경우

  • 참고: 이는 NilBoundary 객체를 반환하는 독립형 경계와 다릅니다.

  • 유효하지 않은 GlobalID 형식

동작: GlobalID.parse("invalid")nil을 반환합니다.

  • 결과: 경계 추출이 nil을 반환 → 인가 오류

  • 오류 메시지: "Unable to determine boundaries for authorization"

  • 참고: 예외를 발생시키지 않고 정상적으로 실패합니다.

  • 경계 메서드가 nil을 반환하는 경우

동작: issue.projectnil을 반환합니다.

  • 결과: nil 반환 → 인가 오류

  • 오류 메시지: "Unable to determine boundaries for authorization"

  • 일반적인 원인: 소프트 삭제된 연관, 고아 레코드

  • GlobalID가 존재하지 않는 레코드를 가리키는 경우

동작: GlobalID::Locator.locate(gid)ActiveRecord::RecordNotFound를 발생시키고 구조 처리되어 nil을 반환합니다.

  • 결과: 경계 추출이 nil을 반환 → 인가 오류

  • 오류 메시지: "Unable to determine boundaries for authorization"

설정 오류#

  • 유효하지 않은 경계 메서드

동작: ArgumentError: "Invalid boundary method: 'foo'"를 발생시킵니다.

  • 원인: 유효한 접근자 메서드 (project, group, itself)에 없는 boundary 값 사용

  • 참고: 이 유효성 검사는 객체가 메서드에 응답하는지 확인하기 전에 실행됩니다.

  • 객체가 경계 메서드에 응답하지 않는 경우

동작: ArgumentError: "Boundary method 'project' not found on Project"를 발생시킵니다.

  • 원인: 유효한 경계 메서드(예: boundary: 'project')를 사용하지만 객체에 해당 메서드가 없는 경우

  • 예외:

필드에 :id 인수가 있으면 대신 ID 폴백 사용

  • 객체 타입이 경계 이름과 일치하면 객체를 직접 반환

  • 예시:

# IssueType에는: boundary: 'project'가 있음
# 필드: project.issue(iid: "1")
# object = Project (Issue 아님)
# Project가 'project'와 일치 → Project 반환
  • 유효하지 않은 권한 이름

동작: gitlab:permissions:validate Rake 태스크로 감지됩니다.

  • 원인: Authz::PermissionGroups::Assignable.all_permissions에 존재하지 않는 권한 심볼 사용

  • 참고: 이 유효성 검사는 CI의 일부로 실행되어 모든 지시어 권한이 유효한 할당 가능한 권한을 참조하는지 확인합니다.

  • 여러 지시어가 발견된 경우

동작: 우선순위 순서(field, implementing type, return type, owner)에서 첫 번째 일치 항목을 사용합니다.

  • 결과: 여러 개가 적용되면 예상 지시어를 사용하지 않을 수 있습니다.

  • 모범 사례: 혼동을 피하기 위해 필드당 한 수준에만 지시어를 적용하세요.

  • 참고: 지시어 파인더는 첫 번째 일치에서 멈추며 이후 수준은 검사하지 않습니다. 소유자 수준 지시어는 필드가 더 깊은 인가된 하위 필드를 가진 인가된 반환 타입으로 순회할 때도 건너뜁니다. 자세한 내용은 인가된 반환 타입으로의 순회를 참조하세요.

참조#

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

GitLab v19.1
원문 보기
요약

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

이 문서는 GranularTokenAuthorization 필드 확장이 GraphQL 쿼리 및 뮤테이션에서 세분화된 개인 액세스 토큰(PAT) 권한을 적용하는 방식을 설명합니다. 단계별 구현 가이드는 GraphQL 구현 가이드를 참조하세요.

개요#

세분화 토큰 인가 시스템은 타입, 필드, 뮤테이션에 적용된 지시어(directive)를 기반으로 GraphQL 필드에 세밀한 권한 검사를 추가합니다. 이 시스템은 세분화된 PAT가 특정 프로젝트 또는 그룹 경계 내에서 명시적으로 권한이 부여된 리소스에만 접근할 수 있도록 보장합니다.

기능 플래그: 이 기능은 토큰의 사용자에 대해 granular_personal_access_tokens 기능 플래그가 활성화되어 있어야 합니다. 플래그가 비활성화된 경우 세분화된 PAT는 GraphQL 요청에 대해 작동하지 않습니다.

아키텍처 구성 요소#

1. 필드 확장(Field Extension)#

  • 위치: lib/gitlab/graphql/authz/granular_token_authorization.rb

  • 목적: 필드 해석(resolution)을 가로채어 인가 검사를 수행합니다.

  • 적용 대상: Types::BaseField를 통해 모든 GraphQL 필드에 적용됩니다.

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. 지시어 파인더(Directive Finder)#

  • 위치: lib/gitlab/graphql/authz/directive_finder.rb

  • 목적: 우선순위 순서(field, implementing type, return type, owner type)로 적용 가능한 지시어를 찾습니다.

  • 포함: GraphQL 타입 래퍼를 언래핑하기 위한 TypeUnwrapper 모듈

4. 경계 추출기(Boundary Extractor)#

  • 위치: lib/gitlab/graphql/authz/boundary_extractor.rb

  • 목적: 다양한 소스에서 인가 경계를 추출합니다.

5. 타입 언래퍼(Type Unwrapper)#

  • 위치: lib/gitlab/graphql/authz/type_unwrapper.rb

  • 목적: GraphQL 타입 래퍼(List, NonNull, Connection)를 언래핑하기 위한 공유 모듈입니다.

  • 사용처: DirectiveFinder 및 SkipRules

6. 헬퍼 모듈(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:validate Rake 태스크로 검증됩니다.

요청 흐름 타임라인#

Phase 1: 요청 시작#

1. GraphQL 요청 도착 (쿼리 또는 뮤테이션)
2. GraphQL Ruby가 파싱 및 유효성 검사 시작
3. 루트 필드로 실행 시작

Phase 2: 필드 해석(필드별)#

각 필드가 해석될 때:

1. GraphQL Ruby가 순서대로 필드 확장을 호출합니다
   ├─ CallsGitaly::FieldExtension (개발/테스트 전용)
   ├─ Present::FieldExtension
   ├─ Authorize::FieldExtension
   └─ GranularTokenAuthorization ← 여기에 있습니다

Phase 3: 인가 검사#

Step 1: 조기 종료 조건

def authorize_field(object, arguments, context)
  return unless authorization_enabled?(context)  # 세분화된 PAT만 인가
  return if SkipRules.new(@field).should_skip?  # 특정 필드 건너뜀
  # ...
end

def authorization_enabled?(context)
  token = context[:access_token]
  token && token.try(:granular?)
end
  • 세분화된 PAT를 사용하지 않는 경우 세분화 스코프 인가가 건너뜁니다 (레거시 PAT는 기존 스코프 인가를 사용합니다).

  • 특정 필드는 자동으로 건너뜁니다:

뮤테이션 응답 필드 (예: createIssue.issue). 인가는 응답 래퍼가 아닌 뮤테이션 자체에서 수행됩니다.

  • 권한 메타데이터 필드 (예: issue.userPermissions). 이 필드들은 실제 데이터가 아닌 권한 정보를 반환합니다.

  • 엣지 래퍼 필드 (예: groupMembers.nodes, groupMembers.cursor). 이 필드들은 자체 필드에서 인가를 적용하는 기본 노드 타입으로 순회합니다.

  • 인가된 반환 타입으로의 순회. 필드에 자체 지시어가 없고, 소유자 타입이 지시어를 가지며, 언래핑된 반환 타입이 자체 지시어를 가지고 더 깊은 인가된 하위 필드를 가지는 경우, 소유자 수준의 지시어는 건너뜁니다. 반환 타입의 지시어는 자식 객체의 필드가 해석될 때 적용됩니다. 리프(Leaf) 반환 타입(모든 스칼라 필드)은 빈 결과가 검사를 완전히 우회할 수 있으므로 건너뛰지 않습니다. 자세한 내용은 인가된 반환 타입으로의 순회를 참조하세요.

Step 2: 지시어 탐색

directive = DirectiveFinder.new(@field).find(object)

DirectiveFinder는 다음 우선순위 순서로 지시어를 검사하며 첫 번째 일치 항목을 반환합니다:

  • 필드 수준 지시어 (FIELD_DEFINITION): 필드에 직접 적용됩니다.

  • 구현 타입 지시어 (인터페이스의 경우): 인터페이스를 구현하는 구체 타입에 적용됩니다.

필드 소유자가 인터페이스이고 object가 제공된 경우에만 검사됩니다.

  • GitlabSchema.types에서 실제 모델 타입(예: Issue)을 해석합니다.

  • 반환 타입 지시어: 필드가 반환하는 타입에 적용됩니다.

GraphQL 타입 래퍼를 언래핑하여 기본 타입을 찾습니다:

List 타입: [Type]Type

  • NonNull 타입: Type!Type

  • Connection 타입: TypeConnectionType (예: IssueConnectionIssueType)

  • boundary_argumentboundary 전략 모두에서 동작합니다.

  • :id 인수와 함께 boundary를 사용할 경우 경계 추출을 위한 ID 폴백을 활성화합니다.

  • 소유자 타입 지시어 (OBJECT): 필드를 소유하는 타입에 적용됩니다.

리프 인가 타입으로 이어지는 필드(예: project.languagesRepositoryLanguageType)가 포함 타입의 지시어(예: read_project) 대신 해당 타입의 지시어를 사용하도록 마지막에 검사합니다. 이를 통해 결과가 비어 있을 때도 올바른 권한이 적용됩니다.

Step 3: 경계 추출

boundary = BoundaryExtractor.new(object:, arguments:, context:, directive:).extract
permissions = directive.arguments[:permissions]

지시어를 찾을 수 없는 경우, boundarypermissions는 모두 nil입니다. 인가 서비스는 "Unable to determine boundaries and permissions for authorization"이라는 오류 메시지를 반환합니다.

경계 추출기의 동작:

  • 독립형 리소스 (boundary: 'user' 또는 boundary: 'instance'): Authz::Boundary::NilBoundary를 반환합니다.

  • 유효한 프로젝트/그룹 리소스: 래핑된 경계를 반환합니다 (ProjectBoundary 또는 GroupBoundary).

  • 리소스를 찾을 수 없는 경우: nil을 반환합니다 (NilBoundary로 래핑되지 않음).

지원되는 경계 타입:

  • Authz::Boundary::ProjectBoundary - Project 리소스용

  • Authz::Boundary::GroupBoundary - Group 리소스용

  • Authz::Boundary::NilBoundary - 독립형 리소스용 (사용자 스코프 또는 인스턴스 전체)

추출기는 네 가지 전략 중 하나를 사용합니다:

전략 A: boundary_argument (뮤테이션 및 쿼리 필드용)

# 지시어: boundary_argument: 'project_path'
# 필드 인수: project_path: "gitlab-org/gitlab"

extract_from_argument('project_path')
  ↓
args[:project_path] = "gitlab-org/gitlab"
  ↓
resolve_path("gitlab-org/gitlab")
  ↓
Project.find_by_full_path("gitlab-org/gitlab") || Group.find_by_full_path("gitlab-org/gitlab")
  ↓
returns Project or Group instance

전략 B: boundary (해석된 객체가 있는 타입 필드용)

경계 메서드는 유효한 접근자 메서드 중 하나여야 합니다: project, group, 또는 itself. 다른 값을 사용하면 ArgumentError가 발생합니다.

# 지시어: boundary: 'project'
# Object: Issue 인스턴스

extract_from_method('project')
  ↓
unwrap_object(object)  # Issue
  ↓
object_matches_boundary_type?('project')  # false (Issue ≠ Project)
  ↓
VALID_BOUNDARY_ACCESSOR_METHODS.include?('project')  # true
  ↓
object.respond_to?(:project) # true
  ↓
object.project
  ↓
returns Project instance

boundary: 'itself'를 사용하면 객체 자신이 경계로 반환됩니다. 이는 자신이 Project 또는 Group인 타입에 유용합니다:

# 지시어: boundary: 'itself'
# Object: Project 인스턴스

extract_from_method('itself')
  ↓
unwrap_object(object)  # Project
  ↓
object_matches_boundary_type?('itself')  # false (Project ≠ Itself)
  ↓
VALID_BOUNDARY_ACCESSOR_METHODS.include?('itself')  # true
  ↓
object.itself  # Ruby의 Object#itself는 self를 반환
  ↓
returns Project instance

전략 C: ID 폴백 (GlobalID가 있는 쿼리 필드용)

다음 경우에 사용됩니다:

  • 지시어가 boundary: 'project'를 지정한 경우

  • 객체가 nil이거나 경계 메서드에 응답하지 않는 경우

  • 필드에 GlobalID가 있는 :id 인수가 있는 경우

# 쿼리: issue(id: "gid://gitlab/Issue/123")
# 지시어: boundary: 'project'
# Object: nil (쿼리 필드, 아직 해석되지 않음)

extract_from_id_argument
  ↓
args[:id] = "gid://gitlab/Issue/123"
  ↓
GlobalID.parse("gid://gitlab/Issue/123")
  ↓
GlobalID::Locator.locate(gid)  # Issue.find(123) - 추가 DB 쿼리
  ↓
extract_boundary_from_object(issue)
  ↓
issue.project
  ↓
returns Project instance

성능 참고: 이 전략은 레코드를 두 번 가져옵니다 - 인가를 위해 한 번, 필드 해석 중에 한 번. 단, 쿼리는 캐시됩니다.

전략 D: 독립형 경계 (사용자 스코프 또는 인스턴스 전체 리소스용)

다음 경우에 사용됩니다:

  • 지시어가 boundary: 'user'를 지정한 경우 (사용자 스코프 리소스)

  • 지시어가 boundary: 'instance'를 지정한 경우 (인스턴스 전체 리소스)

# 지시어: boundary: 'user'
# 리소스가 특정 프로젝트/그룹에 속하지 않음

standalone_boundary?('user')
  ↓
@boundary_accessor.to_sym  # :user
  ↓
Authz::Boundary.for(:user)
  ↓
returns Authz::Boundary::NilBoundary.new(:user)
  ↓
Authorization checks token has appropriate permissions

이 전략은 특정 프로젝트 또는 그룹 경계에 속하지 않지만 사용자 스코프이거나 인스턴스 전체인 리소스에 사용됩니다.

Step 4: 인가 검사

authorize_with_cache!(context, boundary, permissions)

이 메서드는 다음을 수행합니다:

  • 캐시 확인: 중복 검사를 방지하기 위해 context[:authz_cache]를 확인합니다.

  • 인가 서비스 호출:

::Authz::Tokens::AuthorizeGranularScopesService.new(
  boundaries: boundary,
  permissions: permissions,
  token: context[:access_token]
).execute
  • 검증: 토큰이 경계에 대해 필요한 권한을 가지고 있는지 확인합니다.

  • 인가 실패 시 오류 발생: raise_resource_not_available_error!(response.message).

  • 결과 캐싱: 중복 검사를 방지하기 위해 결과를 캐시합니다.

매칭된 지시어에 traversal: true가 있는 경우, 확장은 토큰에 경계가 표시되는지만 확인하는 별도의 인가 경로를 사용합니다. 자세한 내용은 traversal: true를 사용하는 진입점 필드를 참조하세요.

Step 5: 필드 해석

yield(object, arguments, **rest)

인가가 통과되면 필드 해석기가 실행되고 값을 반환합니다.

예시 시나리오#

시나리오 1: boundary_argument를 사용한 뮤테이션#

GraphQL 요청:

mutation {
  createIssue(input: {
    projectPath: "gitlab-org/gitlab",
    title: "New issue"
  }) {
    issue { id }
  }
}

지시어:

class Create < BaseMutation
  authorize_granular_token permissions: :create_issue, boundary_argument: :project_path, boundary_type: :project
end

타임라인:

  • createIssue 필드에 대해 확장이 호출됩니다.

  • object = nil (루트 뮤테이션 필드)

  • 뮤테이션 클래스에서 지시어 발견

  • arguments[:input][:project_path]에서 경계 추출

  • Project.find_by_full_path("gitlab-org/gitlab") → Project

  • 인가 서비스 검사: 토큰이 이 프로젝트에 대해 create_issue 권한을 가지고 있는가?

  • 예인 경우: 뮤테이션 실행

  • 아닌 경우: 오류 발생, 뮤테이션 실행되지 않음

시나리오 2: boundary를 사용한 타입 (중첩 필드)#

GraphQL 요청:

query {
  project(fullPath: "gitlab-org/gitlab") {
    issues {
      nodes {
        title        # ← 여기서 인가
        description  # ← 그리고 여기서도
      }
    }
  }
}

지시어:

class IssueType < BaseObject
  authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
end

타임라인 (title 필드의 경우):

  • title 필드에 대해 확장이 호출됩니다.

  • object = Issue 인스턴스 (이미 해석됨)

  • IssueType에서 지시어 발견 (title 필드의 소유자)

  • issue.project를 호출하여 경계 추출

  • 인가 서비스 검사: 토큰이 이 프로젝트에 대해 read_issue 권한을 가지고 있는가?

  • 후속 필드(description 등)에서 캐시 히트 - 추가 DB 쿼리 없음

  • 예인 경우: 필드가 해석되어 title 반환

  • 아닌 경우: 오류 발생

시나리오 3: ID 폴백을 사용한 쿼리 필드#

GraphQL 요청:

query {
  issue(id: "gid://gitlab/Issue/123") {
    title
  }
}

지시어:

class IssueType < BaseObject
  authorize_granular_token permissions: :read_issue, boundary: :project, boundary_type: :project
end

타임라인:

  • issue 필드에 대해 확장이 호출됩니다 (IssueType 반환).

  • object = nil (루트 쿼리 필드)

  • 반환 타입(IssueType)에서 지시어 발견

  • 경계 추출 감지: object가 nil이지만 :id 인수가 있음

  • ID 폴백 사용: GlobalID 추출 → Issue 찾기 → issue.project 가져오기

  • 인가 서비스 검사: 토큰이 이 프로젝트에 대해 read_issue 권한을 가지고 있는가?

  • 예인 경우: 필드 해석 (Issue가 해석기에 의해 다시 가져옴)

  • 아닌 경우: 필드 해석 전에 오류 발생

시나리오 4: traversal: true를 사용한 진입점 필드#

GraphQL 요청:

query {
  group(fullPath: "gitlab-org") {
    groupMembers {
      nodes {
        id
      }
    }
  }
}

Query.group에 대한 진입점 지시어:

field :group, Types::GroupType,
  resolver: Resolvers::GroupResolver,
  directives: granular_scope_directive(
    permissions: :read_group, boundary_argument: :full_path, boundary_type: :group,
    traversal: true
  )

타임라인:

  • group 필드에 대해 확장이 호출됩니다.

  • traversal: true인 지시어가 필드에서 해석됩니다.

  • arguments[:full_path] ("gitlab-org")에서 경계 추출.

  • 인가 서비스가 순회 모드에서 실행되어 token.can?(:read_boundary, boundary)를 검증합니다. read_group 권한은 적용되지 않습니다.

  • groupMembers 필드에 대해 확장이 호출됩니다. 소유자는 GroupType이며 (read_group 지시어를 가짐), 반환 타입은 GroupMemberType입니다 (read_member 지시어를 가짐). 순회 건너뜀이 적용되므로 토큰 검사가 실행되지 않습니다.

  • nodes 필드에 대해 확장이 호출됩니다. 엣지 래퍼로서 건너뜁니다.

  • GroupMemberid 필드에 대해 확장이 호출됩니다. 소유자는 GroupMemberType이며 read_member가 필요합니다. 그룹 경계에 대해 read_member를 토큰으로 검사합니다.

토큰은 read_member만으로 멤버 데이터에 도달하며, 이는 REST 엔드포인트 GET /api/v4/groups/:id/members와 일치합니다.

인가된 반환 타입으로의 순회#

세분화 토큰 인가 타입의 필드는 그렇지 않으면 소유자 타입의 지시어를 상속합니다. 필드의 언래핑된 반환 타입도 세분화 토큰 지시어를 가질 때 소유자 지시어는 중복됩니다. 반환 타입의 지시어는 자식 객체의 필드가 해석될 때 인가를 적용합니다. SkipRules 클래스는 이 경우를 감지하고 소유자 수준 검사를 건너뜁니다.

다음 조건이 모두 충족될 때 건너뜀이 적용됩니다:

  • 필드에 자체 세분화 토큰 지시어가 없습니다 (명시적인 필드 수준 지시어는 항상 우선합니다).

  • 필드의 소유자 타입이 세분화 토큰 지시어를 가집니다.

  • 필드의 언래핑된 반환 타입(list, non-null, connection 래퍼를 제거한 후)이 세분화 토큰 지시어를 가집니다.

  • 반환 타입에 자체 언래핑된 반환 타입이 세분화 토큰 지시어를 가지는 필드가 하나 이상 있습니다 (즉, 모든 필드가 일반 스칼라를 반환하는 "리프" 타입이 아닌 경우).

네 번째 조건은 안전을 위해 필요합니다: 반환 타입이 리프인 경우(모든 필드가 스칼라를 반환하는 경우, 예: RepositoryLanguageType 또는 PushRulesType), 빈 컬렉션이나 nil 결과에 대해 항목별 해석기가 실행되지 않습니다. 컬렉션 수준 검사를 건너뛰면 빈 결과가 인가를 완전히 우회할 수 있습니다. 리프 타입의 경우 컬렉션 수준 검사가 유일한 적용 지점이므로 건너뜀이 실행되지 않아야 합니다.

효과: 자식 리소스의 권한만 가진 토큰은 부모의 읽기 권한 없이도 부모를 통해 자식으로 순회할 수 있습니다. 스칼라나 다른 인가되지 않은 타입을 반환하는 부모의 데이터 필드는 여전히 부모의 권한이 필요합니다.

예시: Group.groupMembersGroupMemberType을 반환합니다. GroupTypeGroupMemberType 모두 세분화 토큰 지시어를 선언합니다. group.groupMembers를 해석할 때 read_group이 더 이상 필요하지 않습니다. 각 GroupMember의 모든 필드를 해석할 때는 read_member가 필요합니다. group.name (스칼라)을 해석할 때는 여전히 read_group이 필요합니다.

traversal: true를 사용하는 진입점 필드#

Query.group(fullPath:)Query.project(fullPath:)와 같은 최상위 필드는 경로 인수에서 경계를 해석하기 위해 존재합니다. 이 필드들은 자체적으로 데이터를 노출하지 않습니다. 다운스트림 필드가 실제 권한을 적용합니다. 이 의도를 선언하려면 지시어에 traversal: true를 설정하세요.

traversal: true인 경우:

  • 경계가 평소와 같이 boundary_argument에서 해석됩니다.

  • 인가 서비스가 순회 모드에서 실행되어 token.can?(:read_boundary, boundary)만 검사합니다. permissions 인수는 적용되지 않습니다. 문서화를 위해 지시어에 남아 있습니다.

  • 경계가 해석되지 않거나 토큰에게 경계가 표시되지 않으면, 서비스는 404 Not Found를 반환하고 필드는 오류와 함께 null을 반환합니다.

순회 캐시 키는 [:traversal, boundary.class, boundary.namespace&.id]이며, 권한 기반 캐시 키와 별개입니다.

traversal: trueprojectgroup 경계 타입에만 적용됩니다. 다른 모든 경계 타입의 경우, 확장은 일반 권한 검사로 폴백합니다.

성능 최적화#

1. 캐싱#

요청별 캐시:

context[:authz_cache] = Set.new
cache_key = [permissions&.sort, boundary&.class, boundary&.namespace&.id]

# `read_issue`에 대한 캐시 키 예시 (프로젝트의 경우):
# [["read_issue"], Authz::Boundary::ProjectBoundary, 123]
  • 인가 결과는 Set을 사용하여 요청별로 캐시됩니다.

  • 동일한 경계 및 권한에 대한 중복 인가 검사를 방지합니다.

  • 예시: 동일한 프로젝트에서 10개의 이슈 필드를 검사할 때 인가 서비스를 한 번만 호출합니다.

  • 캐시 키 구성 요소:

permissions&.sort: 소문자 권한 문자열의 정렬된 배열

  • boundary&.class: 경계 래퍼 클래스 (예: Authz::Boundary::ProjectBoundary)

  • boundary&.namespace&.id: 네임스페이스 ID (경계 타입에 따라 다름):

ProjectBoundary: project.project_namespace.id

  • GroupBoundary: group.id

  • NilBoundary: nil

2. 조기 반환#

return unless authorization_enabled?(context)
return if SkipRules.new(@field).should_skip?
  • 세분화되지 않은 토큰은 전체 시스템을 건너뜁니다 (오버헤드 없음)

  • 뮤테이션 응답 필드와 권한 메타데이터 필드는 자동으로 건너뜁니다 (자세한 내용은 Phase 3, Step 1 참조)

오류 처리#

인가 실패#

인가가 실패하면:

raise_resource_not_available_error!(response.message)

GraphQL의 경우:

  • errors 배열에 서비스 오류를 반환합니다.

  • 필드가 null을 반환합니다.

응답 예시:

{
  "data": { "issue": null },
  "errors": [{
    "message": "Insufficient permissions",
    "path": ["issue"]
  }]
}

엣지 케이스 및 오류 시나리오#

설정 누락 오류#

  • 지시어를 찾을 수 없는 경우 (세분화된 PAT 사용 시)

동작: boundary: nil, permissions: nil로 인가가 진행됩니다.

  • 결과: 인가 서비스가 오류를 반환합니다.

  • 오류 메시지: "Unable to determine boundaries and permissions for authorization"

  • 참고: 세분화된 PAT로 접근하는 모든 필드에는 지시어가 있어야 합니다.

  • 지시어에 빈 권한 배열이 있는 경우

동작: permissions: []로 인가가 진행됩니다 (경계는 제공됨).

  • 결과: 인가 서비스가 오류를 반환합니다.

  • 오류 메시지: "Unable to determine permissions for authorization"

  • 원인: permissions: []로 지시어가 정의됨

경계 해석 오류#

  • 경계 추출이 nil을 반환하는 경우 (리소스를 찾을 수 없음)

동작: boundary: nil로 인가가 진행됩니다 (권한은 여전히 제공됨).

  • 결과: 인가 서비스가 오류를 반환합니다.

  • 오류 메시지: "Unable to determine boundaries for authorization"

  • 원인:

리소스로 해석되지 않는 유효하지 않은 경로/GlobalID

  • 예상 연관을 누락한 객체 (예: issue.projectnil 반환)

  • 지시어에 boundaryboundary_argument 모두 설정되지 않은 경우

  • 참고: 이는 NilBoundary 객체를 반환하는 독립형 경계와 다릅니다.

  • 유효하지 않은 GlobalID 형식

동작: GlobalID.parse("invalid")nil을 반환합니다.

  • 결과: 경계 추출이 nil을 반환 → 인가 오류

  • 오류 메시지: "Unable to determine boundaries for authorization"

  • 참고: 예외를 발생시키지 않고 정상적으로 실패합니다.

  • 경계 메서드가 nil을 반환하는 경우

동작: issue.projectnil을 반환합니다.

  • 결과: nil 반환 → 인가 오류

  • 오류 메시지: "Unable to determine boundaries for authorization"

  • 일반적인 원인: 소프트 삭제된 연관, 고아 레코드

  • GlobalID가 존재하지 않는 레코드를 가리키는 경우

동작: GlobalID::Locator.locate(gid)ActiveRecord::RecordNotFound를 발생시키고 구조 처리되어 nil을 반환합니다.

  • 결과: 경계 추출이 nil을 반환 → 인가 오류

  • 오류 메시지: "Unable to determine boundaries for authorization"

설정 오류#

  • 유효하지 않은 경계 메서드

동작: ArgumentError: "Invalid boundary method: 'foo'"를 발생시킵니다.

  • 원인: 유효한 접근자 메서드 (project, group, itself)에 없는 boundary 값 사용

  • 참고: 이 유효성 검사는 객체가 메서드에 응답하는지 확인하기 전에 실행됩니다.

  • 객체가 경계 메서드에 응답하지 않는 경우

동작: ArgumentError: "Boundary method 'project' not found on Project"를 발생시킵니다.

  • 원인: 유효한 경계 메서드(예: boundary: 'project')를 사용하지만 객체에 해당 메서드가 없는 경우

  • 예외:

필드에 :id 인수가 있으면 대신 ID 폴백 사용

  • 객체 타입이 경계 이름과 일치하면 객체를 직접 반환

  • 예시:

# IssueType에는: boundary: 'project'가 있음
# 필드: project.issue(iid: "1")
# object = Project (Issue 아님)
# Project가 'project'와 일치 → Project 반환
  • 유효하지 않은 권한 이름

동작: gitlab:permissions:validate Rake 태스크로 감지됩니다.

  • 원인: Authz::PermissionGroups::Assignable.all_permissions에 존재하지 않는 권한 심볼 사용

  • 참고: 이 유효성 검사는 CI의 일부로 실행되어 모든 지시어 권한이 유효한 할당 가능한 권한을 참조하는지 확인합니다.

  • 여러 지시어가 발견된 경우

동작: 우선순위 순서(field, implementing type, return type, owner)에서 첫 번째 일치 항목을 사용합니다.

  • 결과: 여러 개가 적용되면 예상 지시어를 사용하지 않을 수 있습니다.

  • 모범 사례: 혼동을 피하기 위해 필드당 한 수준에만 지시어를 적용하세요.

  • 참고: 지시어 파인더는 첫 번째 일치에서 멈추며 이후 수준은 검사하지 않습니다. 소유자 수준 지시어는 필드가 더 깊은 인가된 하위 필드를 가진 인가된 반환 타입으로 순회할 때도 건너뜁니다. 자세한 내용은 인가된 반환 타입으로의 순회를 참조하세요.

참조#