
AI 에이전트에게 cdk deploy 권한을 주었지만, 사용하지 못하도록 조치한 이유
요약
AI 에이전트가 인프라 변경을 직접 실행할 때 발생할 수 있는 위험을 방지하기 위해, IAM 권한 분리를 통한 '제안 및 승인' 워크플로우를 구축한 사례를 소개합니다. 에이전트에게는 최소한의 권한만 부여하고, 실제 배포는 사람의 승인을 거쳐 별도의 역할로 실행되도록 설계했습니다.
핵심 포인트
- 에이전트의 시스템 프롬프트는 보안 경계가 될 수 없으므로 IAM 계층에서 물리적 분리가 필요함
- CDK diff를 실행하는 '제안' 역할과 실제 배포를 수행하는 '실행' 역할을 엄격히 분리
- 에이전트에게는 최소 권한 원칙(PoLP)을 적용하여 직접적인 CloudFormation 쓰기 권한을 차단
- Slack 버튼 클릭과 같은 인간의 개입(Human-in-the-loop)을 통해 안전한 배포 프로세스 구축
이 프로젝트의 첫 번째 버전은 광고된 대로 정확하게 작동했고, 바로 그 점이 저를 걱정하게 만들었습니다. 저는 에이전트에게 Lambda 함수에 대한 디버그 로깅 (debug logging)을 켜달라고 요청했고, 에이전트는 cdk deploy를 실행했으며, 30초 후 설정이 라이브 상태가 되었습니다. 검토도, 차이점 확인 (diff)도, 제2의 의견도 없었습니다. 에이전트는 제가 요청한 것을 정확히 수행했습니다. 이것이 배포가 가능한 에이전트가 가진 문제입니다. 실패 모드는 에이전트가 잘못 행동하는 것이 아니라, 서두르고 불완전하게 형성된 요청이 지시한 대로 에이전트가 정확히 수행하는 것입니다.
그래서 저는 그 반대의 것을 만들었습니다. 인프라를 살펴보고 변경 사항을 제안할 수는 있지만, 물리적으로는 변경을 실행할 수 없는 에이전트입니다. "제안됨 (proposed)"과 "배포됨 (deployed)" 사이를 가로막는 유일한 것은 Slack에서 버튼을 클릭하는 사람뿐입니다.
두 개의 CodeBuild 프로젝트, 두 개의 역할, 하나의 공유 소스
제가 계속해서 고민했던 설계 문제는 정확히 어디에 선을 그어야 하는가였습니다. 에이전트의 시스템 프롬프트 (system prompt)에 체크 로직을 넣는 것은 결코 선택지가 될 수 없었습니다. 프롬프트는 모델이 설득당해 바꿀 수 있는 지침이지, 경계 (boundary)가 아니기 때문입니다. 따라서 분리는 IAM 계층에서 이루어지며, 동일한 CDK 앱을 대상으로 실행되지만 서비스 역할 (service roles) 측면에서는 거의 공통점이 없는 두 개의 CodeBuild 프로젝트를 통해 구현됩니다.
deploy-gate-plan은 cdk diff --method=template을 실행하며, 이는 합성된 템플릿 (synthesized template)을 배포된 상태와 직접 비교하며 변경 세트 (change set)를 포함하지 않습니다. 이 역할에는 cloudformation:GetTemplate, cloudformation:DescribeStacks, 그리고 ssm.StringParameter.valueFromLookup이 합성 시점에 설정 값을 확인할 수 있도록 CDK 부트스트랩 조회 역할 (CDK bootstrap lookup role)에 대한 정확히 하나의 sts:AssumeRole 권한이 필요합니다. 이것이 정책의 전부입니다. 이 역할은 변경 세트를 생성할 수 없고, 배포 역할 (deploy role)을 맡을 수 없으며, 파일 게시 역할 (file-publishing role)을 맡을 수도 없습니다.
deploy-gate-execute는 cdk deploy --require-approval never를 실행합니다. 이 역할 (role)은 실제 배포에 필요한 세 가지 CDK 부트스트랩 역할 (bootstrap roles)을 모두 맡을 수 있습니다. 이 시스템 내에서 CloudFormation 쓰기 권한으로 가는 경로를 가진 유일한 IAM ID (identity)이며, 빌드를 시작할 수 있는 유일한 요소는 Slack 서명을 검증하고 자체적으로는 CloudFormation 권한을 전혀 가지지 않은 Lambda입니다.
에이전트는 Claude Sonnet 4.6이 탑재된 Strands Agents SDK 위에서 실행되며, 이 모든 것의 앞단에서 가장 최소한의 권한만을 가집니다: 하나의 SSM 파라미터 (parameter) 쓰기, plan 프로젝트 시작, CloudWatch 로그 읽기, 그리고 Slack에 게시하기입니다. 이것이 해당 역할에 연결된 전체 정책 (policy)입니다. 에이전트는 deploy-gate-execute가 존재한다는 사실을 알 수 없는데, 에이전트가 시도할 경우 IAM이 이를 거부한다는 의미에서 그렇습니다.
실제로 무엇이 배포되는가
데모 대상은 의도적으로 작게 설정되었습니다: LOG_LEVEL이 SSM 파라미터로부터 오는 하나의 Lambda 함수이며, 이는 valueFromLookup을 통해 CDK 템플릿에 포함되어 있어 파라미터 변경 시 배포 시점에 해결되는 무의미한 작업 (no-op)이 아니라 실제 눈에 보이는 차이 (diff)로 나타납니다. 에이전트에게 디버그 로깅 (debug logging)을 켜달라고 요청하면, 나타나는 Slack 카드는 다음과 같습니다:
[~] AWS::Lambda::Function TargetFunction TargetFunctionBA89AD45
└─ [~] Environment
└─ [~] .Variables:
...
Approve(승인)를 클릭하면, deploy-gate-execute가 동일한 SSM 값을 가져와 실제 배포(deploy)를 실행합니다. Deny(거부)를 클릭하거나 무시하면 아무 일도 일어나지 않습니다. SSM 파라미터는 이미 변경되었지만, 실행 중인 스택(stack)에서 이를 읽어들이는 과정이 없기 때문입니다. Slack 측을 디버깅하는 동안 실수로 무시(ignore) 경로를 여러 번 테스트해 보았는데, 설계한 대로 정확하게 작동했습니다. 네 개의 별도 대기 중인 요청(pending requests)이 DynamoDB에 아무런 수정 없이 머물러 있었고, 그 중 어떤 것도 두 번 승인될 수 없었습니다. 상태 전이(status transition)가 Lambda가 맹목적으로 신뢰하는 플래그(flag)가 아니라 조건부 쓰기(conditional write)로 이루어져 있기 때문입니다.
실제로 시간을 잡아먹은 부분
IAM 설계 때문에 오후 시간을 허비한 것은 아닙니다. 그 시간은 이전에 겪어보지 못한 Slack과 Lambda의 세 가지 작업에 소요되었습니다.
첫째, 2025년 10월 이후에 생성된 Lambda Function URL은 리소스 정책(resource policy)에 lambda:InvokeFunctionUrl과 일반 lambda:InvokeFunction 권한이 모두 필요합니다. 저는 첫 번째 권한만 가지고 있었기에, 모든 요청이 제 코드에 도달하기도 전에 403 에러를 반환했습니다. 로그조차 남지 않아 그 자체로 매우 혼란스러운 상황이었습니다.
둘째, 완전히 새로운 Slack 앱은 기본적으로 Socket Mode가 켜져 있으며, 이로 인해 Interactivity Request URL 필드가 조용히 숨겨집니다. 해당 필드에 접근하기 위해 Socket Mode를 끄면 Interactivity 토글 자체가 다시 꺼짐(off) 상태로 리셋됩니다. 따라서 Socket Mode를 비활성화한 후 URL을 추가하려는 자연스러운 순서를 따르면, 아무것도 변하지 않은 것처럼 보이는 페이지에 도달하게 됩니다. 하나의 토글이 아니라 두 개의 토글이 작동하는 셈입니다.
셋째, 그리고 실제로 가장 찾아내는 데 오래 걸린 부분입니다. Lambda Function URL은 application/x-www-form-urlencoded 본문(body)을 base64 인코딩합니다. 이는 Slack이 인터랙티브 페이로드(interactive payloads)를 보낼 때 사용하는 콘텐츠 타입(content type)과 정확히 일치합니다. 저의 서명 검증(signature verification) 로직은 디코딩된 바이트(decoded bytes) 대신 base64 래퍼(wrapper)를 해싱하고 있었습니다. 이로 인해 모든 요청이 HMAC 비교에서 실패했는데, 이는 서명 비밀키(signing secret)가 틀려서 발생하는 문제와는 전혀 다른 차원의 차이였습니다. 저는 isBase64Encoded 값과 원시 본문(raw body) 접두사(prefix)를 나란히 로그로 남겨 확인한 끝에, 접두사가 JSON이 아닌 유효한 base64 텍스트라는 점을 발견하고 나서야 문제를 찾아낼 수 있었습니다.
이 중 어느 것도 특별한 것은 아닙니다. 지나고 나면 당연해 보이지만, 다른 단서 없이 401 에러를 마주하고 있을 때는 보이지 않는 그런 종류의 문제들입니다. 아마도 Slack 상호작용(interactivity)을 Bolt나 Socket Mode를 거치지 않고 Function URL로 직접 연결하려는 다음 사람에게도 똑같이 나타날 세 가지 문제 중 하나일 것이라고 추측합니다.
보일러플레이트(Boilerplate)가 핵심인 이유
단일 Lambda의 로그 레벨을 변경하는 것은 이를 보호하기 위해 이토록 많은 인프라를 구축할 가치가 있는 변경 사항이 아닙니다. 규모를 작게 유지하는 것 자체가 항상 목적이었습니다. 동일한 계획/실행(plan/execute) 분리 방식은 수십 개의 리소스를 가진 CDK 스택이나, 직접 만든 Strands 루프 대신 Amazon Q Developer CLI 또는 Kiro를 구동하는 에이전트에게도 똑같이 적용됩니다. 변하는 것은 cdk deploy 뒤에 있는 것들의 폭발 반경(blast radius)뿐입니다. 변하지 않는 것은 해결책의 형태입니다. 즉, 에이전트가 제안하고, 사람이 승인하며, CloudFormation을 실제로 건드릴 수 있는 권한은 모델의 출력이 스스로 도달할 수 있는 그 어디에도 위치하지 않는다는 점입니다.
코드는 GitHub에서 확인할 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
