본문으로 건너뛰기

ADR-0031: SDK·MCP 계층 데이터 인가 게이트

  • 상태: 승인
  • 날짜: 2026-07-29
  • 관련: #2701 (본 결정), #2700 (워크스페이스 격리 Epic), #2673 (admin 마스킹 우회 제거), #2692 (gendpg 행 필터 FQN 우회), ADR-0030 (엔진 계층)
  • 상세 설계: docs/DESIGN_SDK_MCP_AUTHZ_GATE.md

배경

GenD 의 데이터 인가(DataGrant 가시성 필터·PII 컬럼 마스크·행 필터)는 REST 라우터에만 구현되어 있었다. gend_api.sdk 계층에는 인가가 전혀 없었고, MCP 도구 6종과 AI Agent 가 이 계층을 통과한다.

결과적으로 같은 SQL 을 /api/v1/query/execute 로 보내면 마스킹되고 /mcp 로 보내면 원본이 나왔다. sdk/catalog.py 의 함수들은 SDKContext 를 인자로 받아놓고 본문에서 한 번도 쓰지 않은 채 trino_client 를 직접 호출했다.

감사도 비어 있었다. MCPCallLogworkspace_id·params_hash·tool_version 컬럼을 갖고 있는데 log_call 이 채우지 않아, 남는 것이 user_id + tool_name 뿐이었다 — "어느 워크스페이스가 어떤 인자로 무엇을 읽었는가" 를 사후 재구성할 수 없었다.

이는 ADR-0030 이 다루는 엔진 계층 문제와 별개다. 엔진 ACL 이 켜지기 전까지 gend-api 프로세스가 유일한 신뢰 경계이므로, 그 안에 우회 경로가 있으면 상위 계층의 어떤 통제도 성립하지 않는다.

결정

1. 게이트는 SDK 함수 내부에 둔다

호출자(라우터 / MCP dispatcher / tool_executor)가 각자 가드를 붙이는 현행 구조는 "호출자마다 가드 유무가 다른" 상태 그 자체가 우회 경로다. 실제로 tool_executor 는 SDK 호출 전 SqlGuard.check_sql 을 넣지만 MCP dispatcher 는 넣지 않는다.

공용 SDKContext 팩토리가 없어(3곳에서 각자 인라인 생성) 진입점 한 곳에 두는 것도 불가능하므로, 게이트를 SDK 함수 본문으로 내린다.

2. 바이패스 키는 is_admin 이 아니라 caller_type is SYSTEM

CallerContext.system()is_admin=Trueis_admin 을 키로 쓰면 MCP 로 들어온 실제 admin 사용자까지 게이트를 건너뛴다. 그러면 정책 파이프라인에 도달조차 못 해 can_unmask 판정이 무의미해진다 — #2673 이 라우터에서 고친 것과 정확히 같은 함정이다.

is_control_plane(= caller_type is SYSTEM) 을 키로 쓰면 내부 시스템 호출만 통과하고, 실제 admin 은 정책을 타되 DataAccessService·select_enforced 내부의 is_admin 분기가 라우터와 동일한 강도로 면제를 담당한다.

SYSTEM 전권은 이번에 없애지 않는다 — 리포에 이미 3곳에서 규약으로 굳어 있고, scripts/·pipelines/ 는 SDK 를 import 하지 않아 deny 로 얻는 실이익이 0 이다. 대신 gend_sdk_authz_bypass_total 카운터와 사용처 allowlist 테스트로 동결한다. 완전 분리는 #2700 Phase 3.

3. 가시성 필터만 단계 시행, 마스킹·행필터는 무조건

GEND_SDK_AUTHZ_MODE (off/observe/enforce, 기본 observe) 는 DataGrant 가시성 필터에만 적용한다. 기존 MCP 클라이언트가 grant 부족으로 갑자기 빈 목록을 받는 회귀를 막고, 블라스트 반경을 prod 메트릭으로 먼저 재기 위해서다.

컬럼 마스크·행 필터는 플래그 없이 즉시 시행한다. 라우터에도 플래그가 없고, 면제는 unmask 역할 단독이며, 결과가 "빈 목록" 이 아니라 "마스킹된 값" 이라 회귀로 체감될 성질이 아니다. 플래그 뒤에 숨는 것은 한 축뿐이고, 이 PR 의 실제 보안 이득은 즉시 발생한다.

observe 는 "동작 변화 없음" 이 약속이므로 필터 조회가 실패해도 원본을 반환한다 — 관측 목적으로 켜둔 코드가 DB 순단만으로 호출을 깨뜨려서는 안 된다. enforce 에서는 예외를 전파한다(fail-closed).

4. 라우터와 SDK 는 같은 헬퍼를 쓴다

services/access/read_policy.enforce_read_policy 가 시행 순서를 단독 소유하고, 라우터도 이 헬퍼를 탄다. 복제를 기각한 이유는 순서가 조용히 깨지는 종류라서다.

  • enforce_row_filters 는 SQL 전체를 서브쿼리로 감싸고, apply_column_masks 는 첫 FROM 이전 구간만 치환한다. 순서를 뒤집으면 마스크가 래퍼 바깥의 SELECT * 에 적용되어 무효화되는데 예외도 경고도 나지 않는다.
  • grant 관측은 재작성본이 아니라 원본 SQL 을 받아야 한다. 래퍼 때문에 테이블 추출이 어긋나면 관측이 샌다.

라우터가 인라인으로 남으면 회귀 가드가 "SDK 에 호출이 있다" 까지만 보증하고 "두 평면이 같다" 는 보증하지 못한다. AST 가드로 두 함수 모두 헬퍼를 쓰고 원시 함수를 직접 부르지 않음을 고정한다.

5. 감사는 기존 빈 컬럼을 채운다 (신규 컬럼 없음)

대상 좌표는 MCPAccessHistory.datasets(#832)에 이미 기록되므로 MCPCallLog 에 중복 컬럼을 만들지 않는다. prod 는 alembic 자동 적용이 아니라 수동 ALTER 가 필요하므로, 이득 없는 스키마 변경은 피한다.

결과

  • MCP·CLI·Agent 경유 조회가 REST 와 동일한 마스킹·행필터·grant 판정을 받는다.
  • gendpg 크로스테넌트 구멍(#2692 와 동일 유형)이 MCP execute_query 에서 닫힌다.
  • MCP 감사 레코드에 워크스페이스와 인자 지문이 남는다.
  • /mcp 라우트에 Trino 신원 결속이 붙어 MCP 쿼리가 서비스 계정으로 위장되지 않는다.

이 결정이 닫지 않는

사유
query_history 미기록execute_and_storeCallerContext 에 없는 primary_group 을 요구
get_table_sample 마스킹라우터 sample 엔드포인트에도 없다. 한쪽만 조이면 MCP 가 UI 보다 엄격해지는 포크가 된다
governance·quality 등 SDK 도메인 필터본 결정 범위 밖
SYSTEM 전권 완전 분리호출부 분류표가 선행 (#2700 Phase 3)

엔진 계층(Trino ACL)은 ADR-0030 / #2683 소관이며 본 결정과 중복되지 않는다 — 본 결정은 gend-api 프로세스 의 우회 경로를 닫고, ADR-0030 은 프로세스 을 닫는다.

운영

GEND_SDK_AUTHZ_MODE 승격 절차는 query-grant-enforcement 와 같은 방식이다. gend_sdk_authz_filtered_total 이 1주간 0 에 수렴하면 enforce 로 올린다. 0 이 아니면 그 caller 에게 필요한 DataGrant 를 먼저 발급한다.