이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 위협 모델링 결과를 기능 명세와 API 계약에 어떻게 반영하는가?
- “안전하게 처리한다” 같은 문장을 검증 가능한 보안 요구사항으로 어떻게 바꾸는가?
- Spring 코드, DB 조건, 로그, 테스트가 같은 요구사항을 바라보게 하려면 무엇이 필요한가?
개요
위협 모델링은 표를 만든 뒤 끝나는 일이 아니다. 결과가 요구사항, 코드, 테스트, 로그로 내려와야 한다.
나쁜 요구사항은 “관리자는 팀원을 안전하게 초대할 수 있다”처럼 쓴다. 이 문장은 안전의 기준, 허용 actor, 금지 action, 로그, 테스트 방법을 말하지 않는다.
좋은 요구사항은 다음처럼 쓴다.
SEC-TEAM-ROLE-003
Only a team owner with MFA verified within 10 minutes can invite a member as MAINTAINER.
The requester cannot assign OWNER to another account through the invite API.
All denied role assignments must be audited with actor_id, team_id, requested_role, reason, trace_id.이 정도로 적어야 구현자가 무엇을 막아야 하는지 알 수 있다.
공격 시나리오
공격자는 팀의 일반 멤버 계정을 탈취한다.
그 계정은 화면에서 초대 버튼이 보이지 않는다. 그러나 브라우저 bundle이나 API 문서를 통해 초대 endpoint를 찾는다.
공격자는 다음을 시도한다.
role을OWNER로 지정한다.invitedByRole을OWNER로 넣는다.- 초대 이메일을 자신의 다른 계정으로 보낸다.
- 만료 시간이 긴 초대 링크를 여러 개 만든다.
- 실패 응답과 로그 누락을 이용해 낮은 빈도로 반복한다.
요구사항이 “팀 관리자는 초대 가능” 수준이면 이 공격을 막는 근거가 코드 곳곳에 흩어진다.
취약한 요청/응답 예시
POST /teams/42/invites HTTP/1.1
Host: api.example.com
Cookie: SESSION=member-session
Content-Type: application/json
{
"email": "attacker@example.net",
"role": "OWNER",
"invitedByRole": "OWNER",
"expiresInDays": 365
}HTTP/1.1 201 Created
Content-Type: application/json
{
"inviteId": 9001,
"teamId": 42,
"role": "OWNER",
"inviteUrl": "https://app.example.com/invites/raw-token"
}문제는 role 값을 받는 것 자체가 아니다. 요구사항에 role 부여 조건, 만료 시간, 응답 민감값, 감사 로그가 없어서 구현이 모두 허용 쪽으로 기울어진 점이다.
주제별 핵심 판단
팀 협업 서비스에서 팀원을 초대하고 역할을 지정하는 기능을 만든다고 가정한다.
asset:
- team membership
- team owner role
- billing settings
- repository access token
actor:
- team_member
- team_maintainer
- team_owner
- organization_admin
entry point:
- POST /teams/{teamId}/invites
- PATCH /teams/{teamId}/members/{memberId}/role역할 변경은 단순 CRUD가 아니다. 권한 상승과 비용 변경, 데이터 접근 범위가 함께 바뀐다.
개선된 요청/응답 예시
다음 명세는 흔하지만 부족하다.
팀 관리자는 팀원을 초대할 수 있다.
초대받은 사용자는 이메일 링크로 가입한다.
역할은 MEMBER, MAINTAINER, OWNER 중 선택할 수 있다.빠진 질문이 많다.
- 팀 관리자가 누구인가?
- OWNER를 초대할 수 있는가?
- 자기보다 높은 역할을 부여할 수 있는가?
- 초대 링크는 얼마나 오래 유효한가?
- 같은 이메일을 반복 초대하면 어떻게 되는가?
- 권한 변경 로그는 누가 볼 수 있는가?
- 실패한 권한 상승 시도도 로그에 남는가?
이 질문이 요구사항에 없으면 구현자가 화면 흐름만 보고 API를 만들게 된다.
방어 설계
위협 모델링 결과는 다음처럼 요구사항 번호를 붙여 관리한다.
| ID | 요구사항 | 검증 방법 |
|---|---|---|
| SEC-TEAM-ROLE-001 | 초대 API는 인증된 team_owner 또는 organization_admin만 호출할 수 있다. | member, maintainer, owner 테스트 계정으로 403/201 확인 |
| SEC-TEAM-ROLE-002 | 초대 role은 MEMBER 또는 MAINTAINER만 허용하고 OWNER는 초대 API에서 금지한다. | role=OWNER 요청 시 403과 감사 로그 확인 |
| SEC-TEAM-ROLE-003 | 초대 생성 전 10분 이내 MFA 검증이 필요하다. | MFA 미검증 session으로 403 확인 |
| SEC-TEAM-ROLE-004 | 초대 token 원문은 응답과 로그에 남기지 않는다. | 응답 schema와 로그 검색 |
| SEC-TEAM-ROLE-005 | 모든 거부 이벤트는 actor, team, requested_role, reason, trace_id를 남긴다. | 감사 로그 assertion |
요구사항은 개발자가 테스트할 수 있는 문장이어야 한다.
Spring/HTTP 예시
@PostMapping("/teams/{teamId}/invites")
public ResponseEntity<TeamInviteResponse> invite(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable long teamId,
@Valid @RequestBody TeamInviteRequest request) {
TeamInviteCommand command = new TeamInviteCommand(
principal.userId(),
teamId,
request.email(),
request.role());
TeamInvite invite = teamInviteService.createInvite(command);
return ResponseEntity.status(HttpStatus.CREATED).body(TeamInviteResponse.safe(invite));
}@Transactional
public TeamInvite createInvite(TeamInviteCommand command) {
TeamRole actorRole = teamMemberRepository.findRole(command.teamId(), command.actorUserId())
.orElseThrow(() -> new AccessDeniedException("team access denied"));
SecurityRequirement requirement = SecurityRequirement.TEAM_INVITE_ROLE_ASSIGNMENT;
InviteDecision decision = invitePolicy.decide(
actorRole,
command.requestedRole(),
mfaVerifier.verifiedRecently(command.actorUserId(), Duration.ofMinutes(10)));
audit.teamInviteDecision(requirement.id(), command, decision);
if (decision.denied()) {
throw new AccessDeniedException("team invite denied");
}
InviteToken token = inviteTokenGenerator.generate(Duration.ofHours(24));
return inviteRepository.save(command.teamId(), command.email(), command.requestedRole(), token.hash());
}응답은 초대 ID, 마스킹된 이메일, role, 만료 시각만 반환한다. 초대 URL 원문을 응답해야 하는 제품 요구가 있다면 로그와 trace에는 원문이 남지 않도록 별도 필터를 둔다.
운영 로그와 감사 지점
level=WARN event=security_requirement_denied requirement_id=SEC-TEAM-ROLE-002 actor_id=1004 team_id=42 action=create_invite requested_role=OWNER result=denied reason=owner_role_not_invitable trace_id=42cf81b0level=INFO event=team_invite_created requirement_id=SEC-TEAM-ROLE-001 actor_id=77 team_id=42 invite_id=9001 requested_role=MAINTAINER result=allowed token_hash_prefix=9d81 trace_id=42cf81b1요구사항 ID를 로그에 남기면 사고 후에 “어떤 정책이 동작했는가”를 추적할 수 있다.
실전 판단 기준
- MEMBER와 MAINTAINER가 초대 API를 호출하면 403인가?
- 요구사항은 “보안을 강화한다”가 아니라 허용 actor, 금지 입력, 실패 응답, 감사 로그로 검증 가능해야 한다.
- 구현 전 요구사항에 테스트 방법이 없으면 배포 후에도 충족 여부를 판단하기 어렵다.
테스트 포인트
- OWNER 초대 시도가 403이고
SEC-TEAM-ROLE-002로그가 남는가? - MFA 미검증 OWNER가 초대하면 403인가?
- 초대 token 원문이 응답, 로그, trace, metric label에 남지 않는가?
- 같은 이메일 반복 초대가 멱등하게 처리되는가?
- role 정책 변경 시 기존 초대 token이 어떻게 처리되는지 정의되어 있는가?
위험 신호!
- 요구사항에 “안전하게”, “적절히”, “관리자만” 같은 말만 있고 검증 조건이 없다.
- API contract에는
roleenum이 열려 있는데 서버 정책은 문서화되어 있지 않다. - 거부 조건이 테스트보다 UI 숨김에 의존한다.
- 권한 변경과 초대가 일반 CRUD 로그로만 남는다.
- 감사 로그에 요구사항 ID나 decision reason이 없다.
- token 원문을 응답, 로그, 알림 메시지에 반복 노출한다.
확인 질문
확인 질문
- 보안 요구사항은 왜 기능 요구사항과 분리하지 말고 함께 써야 하는가?
- 권한, 로그, 테스트, 응답 schema가 기능의 일부로 구현되어야 실제 방어가 동작하기 때문이다.
- “관리자만 가능”이라는 요구사항이 부족한 이유는 무엇인가?
- 관리자 종류, 허용 action, 금지 role, MFA, 승인, 로그 조건이 없어 구현과 테스트 기준이 모호하기 때문이다.
- 요구사항 ID를 로그에 남기면 어떤 장점이 있는가?
- 사고 분석 때 어떤 정책이 허용 또는 거부 결정을 만들었는지 코드와 테스트까지 추적할 수 있다.