보안 요청의 기본 흐름은 인증 문맥 확정 → 주체·행동·대상으로 정책 결정 → 실제 접근 직전 강제 → 변경 시점의 조건 재검사입니다. 로그인 성공은 주체를 확인하는 출발점일 뿐이며, 자원 소유권·테넌트·현재 상태·정책 세대가 실제 허용 여부를 결정합니다.
인증 성공과 자원별 접근 거절
사용자 A가 로그인한 뒤 주문 상세 주소의 ID를 사용자 B의 주문 ID로 바꾸었다고 해 보겠습니다. 서버가 유효한 로그인만 검사한다면, A는 다른 사람의 주문을 볼 수 있습니다. 이때 로그인 검증이 잘못된 것은 아닙니다. 누가 요청했는지 확인한 뒤, 그 주문을 볼 수 있는지 묻는 단계가 빠진 것입니다.
누구인지 확인하는 과정을 인증, 특정 대상에 특정 행동을 허용할지 결정하는 과정을 인가라고 합니다. 이 노트는 서버 API가 인증된 요청을 받아 자원별 권한을 판단하는 흐름에 집중합니다. OAuth 로그인 절차나 토큰 회전·계정 복구는 이와 연결되지만 별도로 공부할 주제입니다.
A의 유효한 세션과 B의 주문 ID를 trace로 두면 인증 단계는 성공하지만 자원 조회 범위에서 거절되어야 합니다. 이 두 결과를 하나의 “인증 실패”로 기록하면 로그인 문제와 객체 수준 인가 누락을 구분할 수 없으므로, 관측에도 인증 결과와 인가 결과를 별도 필드로 둡니다.
권한 판단의 세 가지 입력
“관리자인가?”라는 질문만으로 모든 권한을 정하기는 어렵습니다. 어떤 주체가, 어떤 행동을, 어떤 대상에 하려는지를 함께 보아야 합니다. 다중 테넌트 서비스라면 소속 조직과 대상 조직도 비교해야 합니다.
| 입력 | 주문 취소 예제 | 신뢰할 근거 |
|---|---|---|
| 주체 | 현재 사용자 ID와 소속 조직 | 서버가 검증한 인증 문맥 |
| 행동 | 주문 취소 | 호출된 API가 정한 작업 |
| 대상 | 주문 소유자·조직·현재 상태 | 서버가 읽은 실제 주문 |
| 정책 | 소유자이며 아직 배송 전 | 서버의 현재 권한·업무 규칙 |
요청 본문의 user_id나 tenant_id를 그대로 주체로 삼으면 안 됩니다. 클라이언트가 보내는 값은 “이 사람이라고 주장한다”는 입력이지 신원의 증명이 아닙니다. 대상 ID도 마찬가지로 검색 조건일 뿐, 그 대상에 접근할 권한을 주지는 않습니다.
그림을 글로 읽기
- 검증된 주체: 사용자 A · 조직 T1
- 실제 주문: 소유자 B · 조직 T1
- 취소 권한 검사: 같은 조직인가? · 소유자 또는 허용된 역할인가?
- 거절: A의 로그인은 유효 · B의 주문 취소는 허용 안 됨
- 검증된 주체 → 취소 권한 검사: 주체
- 실제 주문 → 취소 권한 검사: 대상
- 취소 권한 검사 → 거절: 소유권 불일치
그림에서 조직이 같다는 조건만으로 통과시키지 않았습니다. 조직 내부의 모든 주문을 취소할 수 있는 정책인지, 자기 주문만 가능한 정책인지에 따라 결과가 달라집니다. 권한 모델을 먼저 정하고 코드가 그 모델을 빠짐없이 검사해야 합니다.
정책을 함수로 생각하면 authorize(principal, action, resource, policyVersion)의 모든 인자가 서버가 검증·조회한 값이어야 합니다. 요청 body의 user_id와 tenant_id는 주장일 뿐 policy input이 아닙니다. 조직이 같아도 자기 주문만 취소 가능한지 조직 관리자도 가능한지는 별도 정책 결정입니다.
조회 단계의 대상 범위 제한
다음은 소유자만 자신의 주문을 취소할 수 있고, 조직 간 접근은 허용하지 않는 서비스의 설명용 슈도코드입니다. 관리자 예외는 없는 정책입니다.
principal = authenticate(request.credentials)
if principal is absent:
return authentication_required
order = load_order(
id = request.order_id,
tenant_id = principal.tenant_id,
owner_id = principal.user_id
)
if order is absent:
return not_found
if order.status != "pending":
return state_conflict
changed = cancel_order_if_unchanged(
id = order.id,
tenant_id = principal.tenant_id,
owner_id = principal.user_id,
expected_version = order.version,
expected_status = "pending"
)
if changed != 1:
return state_conflict
return cancelled
조회 조건에 조직과 소유자를 넣으면 다른 사람의 주문을 애초에 반환하지 않습니다. 다만 이 한 가지 패턴만이 정답은 아닙니다. 복잡한 정책은 정책 함수나 권한 서비스로 분리할 수 있습니다. 어느 구조든 최종 데이터 접근까지 주체와 대상 범위가 보존되어야 합니다. 목록·다운로드·일괄 처리 API에도 같은 원칙이 필요합니다.
존재하지만 접근할 수 없는 자원을 403으로 응답할지 404로 숨길지는 서비스의 정보 공개 정책입니다. 위 예제는 404를 선택했습니다. 오류 코드를 바꾸는 것 자체가 권한 검사를 대신하지는 않으며, 응답 본문·검색 결과·첨부 파일에서도 불필요한 정보가 새지 않아야 합니다.
조회에서 tenant와 owner를 함께 조건에 넣으면 B의 주문을 먼저 로드한 뒤 권한을 검사하는 실수를 줄입니다. 목록·다운로드·batch API도 같은 범위 조건을 적용해야 하며, 404를 선택하더라도 응답 시간·오류 본문·검색 인덱스에서 대상 존재가 새지 않는지 별도로 확인합니다.
검사 시점과 실행 시점의 상태 변화
A가 주문을 조회할 때는 아직 배송 전이었지만, 취소를 저장하기 직전에 배송 작업이 상태를 변경할 수 있습니다. 읽은 순간에는 허용됐어도, 변경 순간에는 더 이상 허용되지 않는 요청입니다.
그래서 조회한 주문의 상태만 믿고 갱신하지 않고, 마지막 갱신에서도 같은 version과 status를 조건으로 확인합니다. 조회 뒤 배송 작업이 버전을 올렸거나 상태를 바꿨다면 조건부 갱신 결과가 1이 아니게 되어 취소를 성공으로 처리하지 않습니다. 소유권 이전을 지원하는 경우에는 owner_id도 조건에 남겨야 하며, 정책에 영향을 주는 변경마다 버전을 올리거나 바뀐 조건을 직접 다시 검사해야 합니다.
다만 대상 행의 버전만으로 외부 권한 서비스의 역할 변경까지 감지하는 것은 아닙니다. 즉시 회수가 필요한 작업이라면 권한 정보의 최신성을 어디서 확인할지, 권한 확인과 실행 사이의 변경을 어떻게 다룰지 별도 계약이 필요합니다. 단순히 “한 번 검사했으니 안전하다”고 넘길 수 없는 경계입니다.
조회 때 version=12, status=pending이었지만 배송 worker가 먼저 version=13으로 바꾼 trace에서는 조건부 UPDATE가 0행이어야 합니다. 이 0행은 재시도 신호일 수도 있고 금지 전이일 수도 있으므로 최신 상태와 논리 요청 ID를 확인한 뒤 결과를 결정합니다. 역할 회수처럼 대상 row version에 없는 정책 변화는 별도 정책 세대나 실행점 검사가 필요합니다.
정책 결정과 실행 강제의 분리
정책 결정점인 PDP는 주체·행동·대상·정책 버전으로 허용 여부를 계산하고, 정책 집행점인 PEP는 실제 API·DB·파일 접근 직전에 그 결과를 강제합니다. 중앙 정책 서버를 두어도 직접 DB 접근이나 일괄 처리 경로가 PEP를 우회하면 권한 모델에 구멍이 생깁니다.
권한 결정 결과를 캐시에 저장할 때 사용자 ID만 키로 쓰면, A의 주문 조회 허용 결과가 B의 주문 삭제 요청에 재사용될 수 있습니다. 따라서 캐시 키에는 주체·행동·대상·테넌트와 정책 버전·자원 버전·인증 강도처럼 결정에 영향을 주는 문맥을 함께 넣고, 그 결과가 유효한 기간도 정해야 합니다. 어떤 속성이 바뀌었는데 버전이 갱신되지 않으면 버전까지 키에 넣어도 낡은 허용 결과를 다시 쓰게 됩니다. 정책이 거절한 경우와 정책 서버가 장애 난 경우는 서로 다른 결과로 처리·관측하되, 장애를 허용으로 바꾸는 우회는 두지 않습니다.
캐시를 없애고 매번 원격 PDP를 호출해도, 허용 응답을 받은 뒤 DB에 쓰기 전까지의 틈은 남습니다. 역할 버전 7로 허용받은 직후 역할이 8로 회수되면, 자원 버전 12가 그대로여도 주문 쓰기는 이미 낡은 권한으로 실행될 수 있습니다. 엄격한 회수 순서가 필요하다면 역할과 자원을 같은 권위 거래에서 다시 확인하거나, 실행점이 정책 세대와 만료 기한을 강제하는 검증된 권한 계약을 사용해야 합니다. 원격 허용 응답의 TTL을 짧게 설정하는 것만으로 검사와 실행을 한 원자 작업으로 만들 수는 없습니다.
테스트에서 PDP 허용 직후 실행을 멈추고 소유권·역할을 바꾼 뒤 재개합니다. 기대 결과를 정책에 맞게 거절·충돌·정해진 짧은 유효 기간 내 허용으로 명시합니다. 허용 지연을 택한 설계를 즉시 회수라고 부르지 않는 것이 중요합니다.
서비스·에이전트의 사용자 대리 호출 권한
백엔드 작업이나 AI 에이전트가 사용자를 대신해 API를 호출해도 원래 사용자의 권한이 저절로 넓어지면 안 됩니다. 서비스 계정은 기술적으로 많은 데이터에 접근할 수 있어도, 개별 요청의 실행 범위는 사용자·대상·허용 작업으로 제한할 수 있습니다.
예를 들어 “내 주문 상태를 조회해 줘”라는 요청은 전체 주문을 내보낼 권한이나 주문을 취소할 권한이 아닙니다. 실행기에 전달할 때 사용자 신원, 조직, 허용 행동, 대상 범위를 유지하고 서버에서 강제해야 합니다. 모델이 도구 인자를 만들었다는 사실은 권한의 근거가 아닙니다.
대리 호출에는 원래 principal, 허용 action, 대상 범위, 요청 세대를 함께 전달하고 실행기에서 다시 강제합니다. 모델이나 backend가 tool argument를 만들었다는 사실은 권한 부여가 아닙니다. “내 주문 조회”가 전체 주문 export나 취소 권한으로 확장되지 않는지 action별 테스트를 둡니다.
허용 사례와 거절 사례의 대조 시험
| 시험 입력 | 이 예제 정책의 기대 결과 |
|---|---|
| A가 자기 조직의 자기 주문 취소 | 배송 전이며 조건 갱신 성공 시 허용 |
| A가 B의 주문 ID 전달 | 주문 내용 없이 거절 |
A가 본문에 B의 user_id 전달 |
주체를 B로 바꾸지 않음 |
| A가 다른 조직의 주문 ID 전달 | 조직 경계를 넘지 않고 거절 |
| 조회 뒤 배송 작업이 버전을 변경 | 취소 갱신 0행, 성공으로 보고하지 않음 |
| 인증 시스템 또는 권한 조회 장애 | 허용으로 우회하지 않고 장애 정책 적용 |
실험은 테스트 계정과 테스트 데이터로 수행합니다. 유효한 다른 사용자 계정을 사용하는 거절 시험이 특히 중요합니다. 가짜 토큰만 시험하면 인증 실패만 확인하고, 실제로 빠지기 쉬운 자원별 인가 검사는 지나칠 수 있습니다.
로그에는 판단한 주체 ID·행동·대상·정책 결과와 추적 ID를 남길 수 있지만, 비밀번호나 원문 토큰을 남기지 않습니다. 나중에 거절 원인을 조사할 수 있을 만큼의 근거와, 자격·개인정보의 최소 기록 원칙을 함께 지켜야 합니다.
가장 정보가 많은 시험은 실제 테스트 계정 A가 B의 주문을 요청하는 경우입니다. 기대 결과는 인증은 성공하고 자원 접근은 정책에 따라 거절되며, 주문 내용은 반환되지 않는 것입니다. PDP 허용 직후 role을 회수하고 실행을 재개하는 TOCTOU 시험도 포함해 “짧은 TTL”과 “즉시 회수”를 구분합니다.