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 를 직접 호출했다.
감사도 비어 있었다. MCPCallLog 는 workspace_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=True 라 is_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 와 동일 유형)이 MCPexecute_query에서 닫힌다.- MCP 감사 레코드에 워크스페이스와 인자 지문이 남는다.
/mcp라우트에 Trino 신원 결속이 붙어 MCP 쿼리가 서비스 계정으로 위장되지 않는다.
이 결정이 닫지 않는 것
| 갭 | 사유 |
|---|---|
query_history 미기록 | execute_and_store 가 CallerContext 에 없는 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 를 먼저 발급한다.