이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 위협 모델링 결과를 기능 명세와 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를 찾는다.

공격자는 다음을 시도한다.

  • roleOWNER로 지정한다.
  • invitedByRoleOWNER로 넣는다.
  • 초대 이메일을 자신의 다른 계정으로 보낸다.
  • 만료 시간이 긴 초대 링크를 여러 개 만든다.
  • 실패 응답과 로그 누락을 이용해 낮은 빈도로 반복한다.

요구사항이 “팀 관리자는 초대 가능” 수준이면 이 공격을 막는 근거가 코드 곳곳에 흩어진다.

취약한 요청/응답 예시

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=42cf81b0
level=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에는 role enum이 열려 있는데 서버 정책은 문서화되어 있지 않다.
  • 거부 조건이 테스트보다 UI 숨김에 의존한다.
  • 권한 변경과 초대가 일반 CRUD 로그로만 남는다.
  • 감사 로그에 요구사항 ID나 decision reason이 없다.
  • token 원문을 응답, 로그, 알림 메시지에 반복 노출한다.

확인 질문

확인 질문

  • 보안 요구사항은 왜 기능 요구사항과 분리하지 말고 함께 써야 하는가?
    • 권한, 로그, 테스트, 응답 schema가 기능의 일부로 구현되어야 실제 방어가 동작하기 때문이다.
  • “관리자만 가능”이라는 요구사항이 부족한 이유는 무엇인가?
    • 관리자 종류, 허용 action, 금지 role, MFA, 승인, 로그 조건이 없어 구현과 테스트 기준이 모호하기 때문이다.
  • 요구사항 ID를 로그에 남기면 어떤 장점이 있는가?
    • 사고 분석 때 어떤 정책이 허용 또는 거부 결정을 만들었는지 코드와 테스트까지 추적할 수 있다.

참고 문서