AI 에이전트에게 건물의 열쇠를 주되, 건물 자체는 주지 않는 방법: Laravel에서 RBAC + org-scoped MCP 도구 구현
요약
멀티테넌트 Laravel 애플리케이션에서 MCP(Model Context Protocol) 도구를 사용할 때 발생할 수 있는 보안 문제를 해결하는 방법을 다룹니다. RBAC와 조직 범위(org-scoped)를 적용하여 AI 에이전트가 권한이 없는 데이터에 접근하는 것을 방지하는 구현 전략을 제시합니다.
핵심 포인트
- MCP 도구 호출 시 권한 부여(Authorization)와 범위 지정(Scope)의 필수성
- 세션이 없는 MCP 환경에서 수동 쿼리 스코프를 통한 테넌트 격리 구현
- 데이터 유출 방지를 위해 자동 증가 ID 대신 UUID 사용 권장
- 도구별로 명확한 권한(Ability)을 선언하여 보안 계약 체결
MCP를 통해 앱을 AI 에이전트에 노출하는 것은 기본적으로 누군가에게 마스터 키링을 넘겨주고 그 사람이 맡은 문만 열도록 신뢰하는 것과 같습니다. 그리고 그 신뢰는 언제든 문제가 될 수 있는 버그입니다. 이번 주 저는 멀티테넌트 Laravel 애플리케이션에 MCP 도구들을 연결했고, 이 모든 실험의 핵심 질문은 단 하나였습니다. 어떻게 에이전트가 앱을 구동하게 하되, 다른 사람의 데이터를 건드리지 못하게 할 수 있을까?
MCP 도구들에 대해 알아야 할 점은, 각각이 하나의 엔드포인트라는 것입니다. 에이전트는 list_events, publish_event, check_in_participant 같은 함수를 호출하고, 서버는 호출자를 대신하여 코드를 실행합니다. 테넌트가 하나 이상인 순간부터, 모든 도구는 어떤 작업을 수행하기 전에 두 가지 질문에 답해야 합니다: 이것을 할 권한이 있는가, 그리고 이것을 여기에서 할 권한이 있는가? 이것이 바로 권한 부여(Authorization)와 범위 지정(Scope)입니다. 둘 중 하나라도 빠지면 혼란스러운 대리인(confused deputy) 패턴을 만들게 됩니다.
트랩: 토큰 인증 하에서는 주변 범위(ambient scope)가 존재하지 않는다
일반적인 웹 요청에서는 멀티테넌시를 구현하기가 수월합니다. 로그인한 사용자가 있고, 모델에 전역 범위(global scope)가 있어 where organization_id = ?를 조용히 추가해주며, 우리는 그 존재조차 잊고 살아갑니다. 모든 것이 작동하는 것처럼 보이는 이유는 세션에 '현재 조직'이라는 주변적인 컨텍스트가 존재하기 때문입니다.
하지만 MCP 도구들은 그렇지 않습니다. 호출자는 토큰으로 인증하며, 세션도 없고, 현재 테넌트 컨텍스트를 설정해주는 미들웨어 스택도 없습니다. 만약
특별한 기교는 없습니다. 그리고 그것이 핵심입니다. 조직(org) 필터는 활성화되기를 기대해야 하는 글로벌 스코프(global scope)가 아닙니다. 그것은 단 하나의 트레이트(trait) 안에 존재하며, 수동으로 적용되는 이름이 지정된 쿼리 스코프(withOrganization)입니다. UUID로 이벤트를 조회하는 모든 도구는 이 과정을 거칩니다. 만약 조회 결과가 null을 반환하면, 도구는 "귀하의 조직 내에서 찾을 수 없습니다"라고 응답하고 중단합니다. 다른 테넌트의 UUID를 찔러보는 에이전트는 존재하지 않는 UUID를 조회했을 때와 동일한 응답을 받게 됩니다. 즉, 정보 유출(leak)이나 추측(oracle)이 불가능합니다.
조회가 자동 증가 ID(auto-increment ID)가 아닌 UUID로 이루어진다는 점에 주목하세요. 공개 식별자는 추측할 수 없어야 합니다. 에이전트(또는 프롬프트 주입(prompt-injected)된 에이전트)가 event/1, event/2, event/3와 같이 열거(enumerate)할 수 있어서는 안 됩니다. 내부적인 숫자 키는 데이터베이스를 절대 벗어나지 않습니다.
권한 부여(Authorization): 도구당 하나의 권한, 웹 앱과 동일한 방식으로 확인
스코프(Scope)가 당신을 테넌트 안에 머물게 한다면, 권한 부여(Authorization)는 그 안에서 무엇을 할 수 있는지를 결정합니다. 저는 모든 도구에 단 하나의 선언된 권한(ability)을 부여했습니다:
#[Name('event_readiness_check')]
#[Description('이벤트가 발행될 준비가 되었는지 확인합니다. ready=true/false 및 차단 요소를 반환합니다.')]
#[IsReadOnly]
...
여기에는 몇 가지 의도적인 선택이 담겨 있습니다.
ability() 메서드는 도구의 계약(contract)입니다. 이는 한 줄로 "나를 호출하려면 이 권한이 필요합니다"라고 말하는 것과 같습니다. 베이스 클래스인 McpKitTool이 authorizedUser()에서 게이트 체크(gate-check)를 수행하므로, 권한 로직을 모든 handle() 메서드에 복사해서 붙여넣을 필요가 없습니다. 그리고 결정적으로, 이는 **웹 앱과 기반 액션(action)이 이미 사용 중인 것과 동일한 권한 문자열(ability string)**입니다. 준비 상태 확인(readiness check)은 events.view.details 권한에 의존하며, 발행 흐름(publish flow)은 라이프사이클 액션이 강제하는 것과 동일한 게이트에 의존합니다. 하나의 권한 모델, 세 개의 진입점(웹, 액션, MCP). 저는 실제 모델과 동기화되지 않고 어긋나는, "에이전트가 무엇을 할 수 있는가"에 대한 별도의 병렬 매트릭스를 유지하지 않습니다. 그러한 불일치(drift)야말로 에이전트가 그 뒤에 있는 인간보다 더 높은 권한을 갖게 되는 바로 그 원인이기 때문입니다.
#[IsReadOnly] 어노테이션 (annotation)은 작은 정직성 신호 (honesty signal)입니다. 읽기 도구 (read tools)와 쓰기 도구 (write tools)가 다르게 표시되므로, 클라이언트가 어떤 호출이 부수 효과 (side effects)를 일으키는지 추론할 수 있습니다. list_events와 event_readiness_check는 읽기 전용 (read-only)이며, publish_event는 그렇지 않습니다. 어노테이션을 다는 비용은 저렴하면서도 파괴적인 인터페이스 (destructive surface)를 명시적으로 만들어 줍니다.
또한 입력값은 여전히 $request->validate()를 거칩니다. 에이전트는 다른 클라이언트와 마찬가지로 신뢰할 수 없는 클라이언트입니다. UUID 필드에 max:36을 설정하는 것은 편집증이 아니라, 공개 폼 요청 (public form request)에 적용하는 것과 동일한 위생 (hygiene) 조치입니다.
나타난 형태
몇 가지 도구가 존재하게 되자 패턴이 결정화되었고, 나머지는 거의 기계적인 작업이 되었습니다:
- 먼저 목록을 나열합니다.
list_events가 진입점입니다. 이는 UUID를 받지 않는 유일한 도구인데, 에이전트가 UUID를 얻는 방법이기 때문입니다. 이 역시 동일하게 명시적인 방식으로 조직 (org) 필터링이 적용되므로, 에이전트의 세계 전체는 첫 번째 호출부터 자신의 테넌트 (tenant)로 제한됩니다. - 트레이트 (trait)를 통해 해결합니다. 모든 이벤트 범위 (event-scoped) 도구는
resolveOrgEvent를 통해 해결됩니다. 울타리 (fence)는 단 하나의 파일에 존재합니다. - 실제 권한에 기반하여 게이트를 설정합니다. 에이전트 전용 권한을 새로 만들지 말고, 앱의 기존 권한 문자열을 재사용하세요.
- 부수 효과를 어노테이션합니다. 읽기 대 쓰기는 암시되는 것이 아니라 선언됩니다.
이러한 통일성은 개별 도구 하나보다 더 중요합니다. 15개의 도구가 모두 동일한 4가지 규칙을 따른다면, 15개의 handle() 메서드를 일일이 감사 (audit)하는 대신 규칙을 감사하면 됩니다.
테스트할 가치
조직 울타리 (org-fencing) 설정은 오늘 구현하기는 쉽지만, 6개월 뒤 누군가 쿼리 (query)를 "최적화"할 때 조용히 깨지기 쉬운 종류의 것입니다. 따라서 "정상 경로 (happy path)가 작동하는가"가 아니라 "잘못된 테넌트가 아무것도 받지 못하는가"를 직접적으로 단언 (assert)하는 Pest 테스트를 작성합니다:
it('never resolves an event from another organization', function () {
$mine = Event::factory()->for($orgA)->create();
$theirs = Event::factory()->for($orgB)->create();
...
두 번째 사례 — 조직(org) 컨텍스트가 없는 사용자 — 는 사람들이 흔히 잊어버리는 부분입니다. 토큰 인증 (token auth) 환경에서는 테넌트 (tenant)에 연결되지 않은 인증된 주체 (principal)가 발생할 수 있으며, "조직 없음"은 "실수로 인한 모든 권한 허용"이 아니라 반드시 "접근 권한 없음"을 의미해야 합니다.
핵심 요약 (Takeaway)
MCP는 앱을 에이전트 (agent)에게 노출하는 것을 진정으로 쉽게 만들어 줍니다. 어쩌면 너무 쉽게 만들어 줄지도 모릅니다. 도구 (tool)를 등록하는 메커니즘은 사소합니다. 핵심적인 규율은 경계 (boundaries) 설정에 있습니다. 모든 도구를 신뢰할 수 없는 엔드포인트 (untrusted endpoint)로 취급하십시오. 명시적인 테넌트 범위 (tenant scope) 설정 (토큰 인증 환경에서는 절대 암시적(ambient)으로 처리하지 말 것), 기존 권한 모델을 재사용하는 도구당 하나의 선언된 능력 (ability), 열거 가능한 ID (enumerable IDs) 대신 UUID 사용, 그리고 내장된 읽기/쓰기 정직성 (read/write honesty)을 준수해야 합니다. 울타리를 하나의 트레이트 (trait)에 담아, 올바르게 구현하고 테스트할 수 있는 단 한 곳의 지점을 만드십시오.
에이전트는 자신이 속한 테넌트 내에서, 그 뒤에 있는 인간이 할 수 있는 모든 것을 정확히 수행할 수 있어야 하며, 그 이상의 것은 할 수 없어야 합니다. 이것은 AI의 문제가 아닙니다. 우리가 항상 필요로 했던 멀티 테넌시 (multi-tenancy) 및 권한 부여 (authorization) 규율과 동일한 문제입니다. MCP는 단지 이를 소홀히 다루는 것에 대한 모든 변명을 제거했을 뿐입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기