GraphQL 인가
GitLab v19.1인가는 다음 위치에 적용할 수 있습니다: 객체(::Types::BaseObject를 상속하는 모든 클래스) Enum(::Types::BaseEnum을 상속하는 모든 클래스) 필드 리졸버(::Types::BaseResolver를 상속하는 모든 클래스)
인가는 다음 위치에 적용할 수 있습니다:
-
타입:
객체(
::Types::BaseObject를 상속하는 모든 클래스) -
Enum(
::Types::BaseEnum을 상속하는 모든 클래스) -
리졸버:
필드 리졸버(
::Types::BaseResolver를 상속하는 모든 클래스) -
뮤테이션(
::Types::BaseMutation을 상속하는 모든 클래스) -
필드(
fieldDSL 메서드를 사용하여 선언된 모든 필드)
추상 타입(인터페이스 및 유니온)에는 인가를 지정할 수 없습니다. 추상 타입은 멤버 타입에 인가를 위임합니다. 정수와 같은 기본 내장 스칼라 타입에는 인가가 없습니다.
저희 인가 시스템은 애플리케이션 전반에 걸쳐 사용되는 동일한 DeclarativePolicy 시스템을 사용합니다.
-
단일 값(예:
Query.project)의 경우, 현재 인증된 사용자가 인가 검사를 통과하지 못하면 해당 필드는null로 반환됩니다. -
컬렉션(예:
Project.issues)의 경우, 사용자의 인가 검사에 실패한 객체를 제외하도록 컬렉션이 필터링됩니다. 이 필터링 과정(redaction이라고도 함)은 페이지네이션 이후에 발생하므로, 삭제된 객체가 제거됨에 따라 일부 페이지의 크기가 요청한 페이지 크기보다 작을 수 있습니다.
뮤테이션에서 리소스 인가하기도 참고하세요.
인가에 의존하여 레코드를 필터링하는 방식보다, 기존 파인더(finder)를 사용하여 현재 인증된 사용자가 볼 수 있는 데이터만 먼저 로드하는 것이 모범 사례입니다. 이렇게 하면 데이터베이스 쿼리와 로드된 레코드에 대한 불필요한 인가 검사를 최소화할 수 있습니다. 또한 기밀 리소스의 존재를 노출할 수 있는 짧은 페이지와 같은 상황도 방지할 수 있습니다.
여기서 설명한 모든 인가 체계의 예시는 authorization_spec.rb를 참고하세요.
타입 인가#
authorize 메서드에 ability를 전달하여 타입을 인가합니다.
동일한 타입을 사용하는 모든 필드는 현재 인증된 사용자가 필요한 ability를 가지고 있는지 확인하여 인가됩니다.
예를 들어, 다음 인가는 현재 인증된 사용자가 read_project ability를 가진 프로젝트만 볼 수 있도록 보장합니다(Types::ProjectType을 사용하는 필드에서 프로젝트가 반환되는 한):
module Types
class ProjectType < BaseObject
authorize :read_project
end
end
여러 ability에 대해 인가할 수도 있으며, 이 경우 모든 ability 검사를 통과해야 합니다.
예를 들어, 다음 인가는 현재 인증된 사용자가 프로젝트를 보기 위해 read_project와 another_ability ability를 모두 가져야 함을 보장합니다:
module Types
class ProjectType < BaseObject
authorize [:read_project, :another_ability]
end
end
리졸버 인가#
리졸버는 자체 인가를 가질 수 있으며, 이는 부모 객체 또는 해석된 값에 적용할 수 있습니다.
부모에 대해 인가하는 리졸버의 예시는 Resolvers::BoardListsResolver로, 실행되기 전에 부모가 :read_list를 만족해야 합니다.
해석된 리소스에 대해 인가하는 예시는 Resolvers::Ci::ConfigResolver로, 해석된 값이 :read_pipeline을 만족해야 합니다.
부모에 대해 인가하려면, 리졸버가 authorizes_object!로 선언하여 opt in해야 합니다(초기에는 기본값이 아니었기 때문입니다):
module Resolvers
class MyResolver < BaseResolver
authorizes_object!
authorize :some_permission
end
end
해석된 값에 대해 인가하려면, 리졸버가 어느 시점에서 인가를 적용해야 하며, 일반적으로 #authorized_find!(**args)를 사용합니다:
module Resolvers
class MyResolver < BaseResolver
authorize :some_permission
def resolve(**args)
authorized_find!(**args) # calls find_object
end
def find_object(id:)
MyThing.find(id)
end
end
end
두 가지 접근 방식 중, 객체를 인가하는 방식이 불필요한 쿼리를 방지하는 데 도움이 되므로 더 효율적입니다.
인자 정의에 loads: 사용 금지#
GraphQL 인자를 정의할 때 loads: 옵션을 사용하지 마세요.
loads:를 사용하면 graphql-ruby가 Global ID를 객체로 자동 해석하지만, 객체가 존재하지 않는 경우와 사용자에게 권한이 없는 경우에 따라 다른 오류 메시지를 생성합니다.
이는 공격자가 유효한 리소스 ID를 열거할 수 있게 합니다. 클라이언트는 레코드가 없는 경우와 접근 권한이 없는 레코드가 있는 경우를 구분할 수 없어야 합니다.
대신, Global ID를 일반 인자로 받고 리졸버에서 authorized_find!를 사용하여 수동으로 객체를 로드하고 인가하세요:
# bad
argument :milestone_id, ::Types::GlobalIDType[::Milestone],
loads: Types::MilestoneType,
description: 'Global ID of the milestone.'
# good
argument :milestone_id, ::Types::GlobalIDType[::Milestone],
required: false,
description: 'Global ID of the milestone.'
def resolve(milestone_id:)
milestone = authorized_find!(id: milestone_id)
# ...
end
이 패턴은 리소스가 없거나 사용자에게 권한이 없는 경우 모두 동일한 ResourceNotAvailable 오류가 반환되도록 보장합니다.
Graphql/ForbiddenLoadsArgument RuboCop 검사기가 이 규칙을 강제합니다.
필드 인가#
필드는 authorize 옵션으로 인가할 수 있습니다.
필드 인가는 현재 객체에 대해 검사되며, 인가는 해석 이전에 발생합니다. 즉, 필드는 해석된 리소스에 접근할 수 없습니다. 필드에 인가 검사를 적용해야 하는 경우, 리졸버에 인가를 추가하거나, 이상적으로는 타입에 추가하는 것이 좋습니다.
예를 들어, 다음 인가는 인증된 사용자가 secretName 필드를 보기 위해 프로젝트에 대한 관리자 수준 접근 권한을 가져야 함을 보장합니다:
module Types
class ProjectType < BaseObject
field :secret_name, ::GraphQL::Types::String, null: true, authorize: :owner_access
end
end
다음 예시에서는 비용이 많이 드는 쿼리를 피하기 위해 필드 인가(예: Ability.allowed?(current_user, :read_transactions, bank_account))를 사용합니다:
module Types
class BankAccountType < BaseObject
field :transactions, ::Types::TransactionType.connection_type, null: true,
authorize: :read_transactions
end
end
필드 인가는 다음 경우에 권장됩니다:
-
다른 필드와 다른 수준의 접근 제어가 필요한 스칼라 필드(문자열, 불리언, 숫자).
-
필드 해석을 절약하고 해석된 각 객체에 대한 개별 정책 검사를 방지하기 위해 부모에 접근 검사를 적용할 수 있는 객체 및 컬렉션 필드.
필드 인가는 객체가 부모 프로젝트의 접근 수준과 정확히 일치하지 않는 한 객체 수준 검사를 대체하지 않습니다.
예를 들어, 이슈는 부모의 접근 수준과 관계없이 기밀일 수 있습니다.
따라서 Project.issue에는 필드 인가를 사용하지 않아야 합니다.
여러 ability에 대해 필드를 인가할 수도 있습니다. 단일 값 대신 ability를 배열로 전달하세요:
module Types
class MyType < BaseObject
field :hidden_field, ::GraphQL::Types::Int,
null: true,
authorize: [:owner_access, :another_ability]
end
end
MyType.hiddenField의 필드 인가는 다음 검사를 의미합니다:
Ability.allowed?(current_user, :owner_access, object_of_my_type) &&
Ability.allowed?(current_user, :another_ability, object_of_my_type)
타입 및 필드 인가 함께 사용#
인가는 누적됩니다. 즉, 현재 인증된 사용자는 필드와 필드의 타입 모두에서 인가 요건을 통과해야 할 수 있습니다.
다음 단순화된 예시에서 현재 인증된 사용자는 이슈의 작성자를 보기 위해 사용자에 대한 first_permission과 이슈에 대한 second_permission 모두 필요합니다.
class UserType
authorize :first_permission
end
class IssueType
field :author, UserType, authorize: :second_permission
end
UserType의 객체 인가와 IssueType.author의 필드 인가의 조합은 다음 검사를 의미합니다:
Ability.allowed?(current_user, :second_permission, issue) &&
Ability.allowed?(current_user, :first_permission, issue.author)
특정 필드에 대한 타입 인가 건너뛰기#
일부 시나리오에서는 특정 필드가 전용 resolver로 해석되고, 해당 리졸버가 해석된 객체의 인가를 처리합니다.
이런 경우, 특히 필드가 객체 컬렉션을 해석할 때, Type 수준 인가를 건너뛰고 싶을 수 있습니다.
GraphQL 쿼리에 따라 이러한 중복 인가 검사는 상당한 오버헤드를 추가할 수 있습니다.
이런 상황을 위해, 특정 필드의 skip_type_authorization을 통해 ability 목록을 지정하여 Type 수준에서 건너뛸 ability를 지정할 수 있습니다.
이 옵션은 모든 하위 필드에도 적용됩니다.
실제 사용 예시는 field :discussions, Types::Notes::DiscussionType를 참고하세요.
해당 예시에서 authorize :read_note를 지정하는 DiscussionType이 있습니다.
Discussion은 NoteType 타입의 여러 notes로 구성되며, NoteType도 authorize: :read_note를 지정합니다.
이 notes 중 일부는 시스템 노트일 수 있으며 SystemNoteMetadataType 타입의 특정 메타데이터를 가질 수 있습니다.
SystemNoteMetadataType도 authorize: :read_note를 지정합니다.
각 노트는 이모지를 가질 수 있으며, 이는 이 경우 read_note와 동일한 read_emoji로 인가됩니다.
이를 GraphQL 예시로 표현하면 다음과 같은 타입이 됩니다:
class SomeType < BaseObject
field :discussions, Types::Notes::DiscussionType.connection_type, null: true, resolver: SomeResolver
end
class DiscussionType < BaseObject
authorize :read_note
field :notes, Types::Notes::NoteType.connection_type, null: true
end
class NoteType < BaseObject
authorize :read_note
field :system_note_metadata, SystemNoteMetadataType
field :award_emoji, AwardEmojiType
end
class SystemNoteMetadataType < BaseObject
authorize :read_note
end
class AwardEmojiType < BaseObject
authorize :read_emoji
end
그리고 다음과 같은 쿼리가 있습니다:
query {
someType(identified: ID) {
discussions {
nodes {
notes {
nodes {
award_emoji {
name
}
}
}
}
}
}
}
예를 들어, SomeType 타입의 루트 객체에 10개의 discussions이 있다고 가정합니다.
10개의 discussions 각각에 10개의 notes가 있으며, 각 discussion의 첫 번째 note에 이모지가 하나 있습니다.
이 경우, SomeResolver에서 discussions을 인가하면 10번의 인가 호출이 발생합니다.
그런 다음 각 discussion을 DiscussionType으로 표현할 때 각 discussion 객체를 다시 인가하면 10번의 호출이 추가됩니다.
이 특정 호출은 같은 객체를 인가하기 때문에 리졸버 인가 중에 요청 저장소에 캐시되므로 괜찮을 수 있습니다.
다음으로, 이 10개의 discussions에 대한 각 note를 인가하면 10*10 = 100번의 인가 호출이 발생합니다.
마지막으로 각 discussion의 첫 번째 note에 대해 이모지 하나를 인가하면 10번의 호출이 추가됩니다.
따라서 총 130번의 인가 호출이 발생합니다:
-
리졸버에서 인가된 10개의 discussions
-
DiscussionType을 통해 인가된 10개의 (캐시된) discussions -
NoteType을 통해 인가된 100개의 notes -
EmojiType을 통해 인가된 10개의 이모지
discussions 필드에 skip_type_authorization을 지정하여 이 130번의 호출을 단 10번으로 줄일 수 있습니다.
이를 위해 SomeType 정의를 다음과 같이 변경합니다:
class SomeType < BaseObject
field :discussions, Types::Notes::DiscussionType.connection_type, null: true, resolver: SomeResolver,
skip_type_authorization: [:read_note, :read_emoji]
end
이 경우 skip_type_authorization으로 인가 호출을 최적화할 수 있는 이유:
-
SomeResolver에서 이미 discussions을 인가하고 있음 -
하나의 note 또는 모든 notes를 읽을 수 있는 권한은 discussion 내에서 동일함
-
note를 읽을 수 있는 권한과 이모지를 읽을 수 있는 권한은 동일함