“현재 사용자의 tenant 데이터만 보라”는 문장을 시스템 프롬프트에 넣는 것은 유용한 행동 지침이다. 하지만 접근 통제는 아니다. 모델은 실수할 수 있고, 검색 결과에는 공격적인 문장이 포함될 수 있으며, 새로운 도구가 프롬프트 규칙을 빠뜨릴 수도 있다. 데이터 격리는 모델이 올바르게 행동하기를 기대하는 대신 잘못된 요청이 저장소까지 도달하지 못하도록 설계해야 한다.

이 레퍼런스에서는 tenant 문맥이 API에서 MCP 서비스와 데이터 쿼리까지 끊기지 않고 이동한다. 각 계층은 이전 계층을 무조건 신뢰하지 않고 자신이 소유한 경계에서 다시 확인한다.

요청 문맥을 명시적으로 운반한다

개발 환경에서는 FastAPI가 X-User-IDX-Tenant-ID 헤더를 받아 AuthContext를 만든다. 실제 운영에서는 이 부분을 검증된 OIDC/JWT claims로 교체해야 한다. 중요한 점은 모델이 대화 내용에서 tenant를 추측하거나 사용자가 도구 인자로 tenant를 선택하지 않는다는 것이다.

API는 요청 단위 MCP 클라이언트를 만들며 다음 문맥을 헤더로 전달한다.

  • 서비스 간 공유 비밀
  • 사용자 ID와 tenant ID
  • 대화 thread ID
  • 요청 ID
  • 쓰기 재개 시 idempotency key

MCP 서버는 이 헤더를 ToolIdentity로 변환한다. 이후 서비스 함수는 업무 인자와 별도로 신뢰된 identity를 받는다.

모든 조회 조건에 tenant를 포함한다

Records 서비스에서 고객을 조회할 때 조건은 customer_id 하나가 아니다. tenant_idcustomer_id를 함께 사용한다. 사례와 고객의 관계도 두 열을 포함한 foreign key로 묶여 있다. 애플리케이션 검증을 실수하더라도 데이터 모델이 교차 tenant 관계를 허용하지 않도록 한 단계 더 방어한다.

python
select(Customer).where(
    Customer.tenant_id == identity.tenant_id,
    Customer.customer_id == customer_id,
)

다른 tenant에 실제로 존재하는 사례를 요청해도 서비스는 그 존재를 알려주지 않고 not found로 처리한다. 권한 없음과 존재 여부를 구분해 공격자에게 정보를 주지 않는 선택이다.

검색 필터도 호출자에게 맡기지 않는다

OpenSearch 쿼리에는 서비스 코드가 tenant term filter를 삽입한다. 외부에서 허용되는 추가 필터는 문서 유형과 버전뿐이다. tenant_id라는 임의 필터를 전달하면 unknown filter로 거절한다.

검색 엔진이 tenant B의 문서를 잘못 섞어 반환하는 상황도 테스트한다. 서비스는 결과를 모델 계약으로 변환하기 전에 source의 tenant를 한 번 더 확인한다. 저장소 쿼리와 응답 처리에 방어층을 겹친 것이다.

테스트는 실패해야 하는 경계를 보여준다

정상 tenant가 자신의 고객과 열린 사례를 보는 테스트만으로는 부족하다. 더 중요한 테스트는 다음과 같다.

  • tenant A가 tenant B의 사례 ID를 조회하면 실패한다.
  • 검색 요청에서 허용되지 않은 tenant 필터를 보내면 실패한다.
  • 검색 결과에 다른 tenant 문서가 섞여도 반환하지 않는다.
  • 후속 작업의 고객과 사례가 같은 tenant에 속하지 않으면 쓰지 않는다.
보안 경계 테스트는 성공 경로의 부록이 아니라 시스템이 하지 않아야 할 일을 명세한다.

현재 데모 인증은 production 수준이 아니다. 클라이언트가 tenant 헤더를 직접 선택할 수 있기 때문이다. 다만 그 한계를 인증 dependency 한곳에 가둬두어 JWT 검증으로 교체할 위치가 분명하다. 이후에는 역할 기반 권한과 PostgreSQL row-level security를 추가해 identity가 올바른 경우에도 허용된 행동만 수행하도록 보강해야 한다.

멀티테넌트 에이전트를 검토할 때 가장 먼저 볼 곳은 프롬프트가 아니다. tenant가 어디서 만들어지고, 어떤 함수 인자로 이동하며, 모든 데이터 쿼리에 어떤 조건으로 들어가는지를 코드에서 끝까지 따라가야 한다.