병원 관리자 역할을 가진 사용자가 검수 목록을 조회한다고 하자. 로그인은 정상이어도 목록 query에 병원 범위가 빠지면 다른 병원의 행이 섞일 수 있다. 역할은 관리자 기능에 들어갈 수 있는지 설명하지만 특정 병원의 객체를 읽을 수 있는지까지 결정하지 않는다.

질문은 현재 사용자의 조직 관계를 객체를 읽고 쓰는 경로에 어떻게 유지할 것인가다. 병원별 tenant와 활성 membership을 사용하는 모델로 설명한다. OWASP의 객체 단위 인가 실패는 객체 ID 형태와 관계없이 각 객체의 접근 권한을 검사해야 하는 이유를 다룬다. UUID로 바꾸는 것만으로 관계 검증을 대신하지 않는다.

요청의 병원 ID를 접근 권한으로 해석하지 않는다

GET /hospitals/200/reviews

경로의 200은 사용자가 바꿀 수 있는 입력이다. 역할만 확인하고 다음 조회로 넘기면 병원을 지정하는 것과 해당 병원에 접근할 권한이 있다는 것을 혼동한다.

fun getReviews(hospitalId: Long): List<ReviewDto> =
    reviewRepository.findAllByHospitalId(hospitalId)

현재 주체와 요청 병원의 관계를 인증 컨텍스트·membership에서 확인한다. 여러 병원을 관리할 수 있다면 선택한 병원이 활성 membership 집합에 속하는지 검증한다.

@Transactional(readOnly = true)
fun getReviews(actor: Actor, requestedHospitalId: Long): List<ReviewDto> {
    if (!membershipRepository.existsActive(actor.userId, requestedHospitalId)) {
        throw ResourceNotFoundException()
    }

    return reviewRepository.findVisibleReviews(
        hospitalId = requestedHospitalId,
        viewerId = actor.userId,
    )
}

서비스의 검사와 함께 실제 query에도 범위를 남긴다. Actor와 membership·repository는 이 모델을 표현하는 예제 타입이다.

SELECT r.id, r.status, r.requested_at
FROM review_request r
JOIN hospital_membership m
  ON m.hospital_id = r.hospital_id
 AND m.user_id = :viewer_id
 AND m.status = 'ACTIVE'
WHERE r.hospital_id = :hospital_id
ORDER BY r.requested_at DESC;

다른 서비스나 배치가 repository를 호출해도 범위가 명시되어야 한다. 상세도 findById(id) 대신 ID와 병원 범위를 함께 받는 형태로 의도를 드러낸다. 페이지 조회에서 count query가 별도로 생성된다면 같은 범위를 적용한다.

역할만 확인한 조회의 타 조직 노출 경로와 현재 소속 검증 후 tenant 범위를 적용한 조회 경로

역할 확인 이후에도 현재 소속과 대상 객체의 조직 범위를 조회에 연결한다.

로그인과 역할 확인은 사용자의 신원을 알려 줄 뿐 특정 조직의 객체를 볼 권한까지 증명하지 않는다. 현재 membership과 tenant 조건을 행동 시점의 조회에 함께 적용해야 한다.

목록의 인가를 상세와 변경으로 이어간다

목록에서 보이지 않는 객체도 상세 ID·첨부파일·승인 API로 접근할 수 있다. 검색·count, 상세·파일, 수정·삭제·검수, export·알림 링크에 같은 규칙을 적용한다. count만 범위를 놓쳐도 다른 조직 데이터의 존재를 드러낼 수 있다. 파일 서명 URL은 발급 직전에 객체 소유권을 확인한다.

변경은 화면을 열었을 때의 역할이나 병원 선택을 그대로 믿지 않는다. 행동 시점의 membership과 객체 범위를 변경 조건에 넣는다.

UPDATE review_request r
SET status = :next_status,
    reviewed_at = now()
WHERE r.id = :review_id
  AND r.hospital_id = :hospital_id
  AND r.status = :expected_status
  AND EXISTS (
      SELECT 1
      FROM hospital_membership m
      WHERE m.user_id = :viewer_id
        AND m.hospital_id = r.hospital_id
        AND m.status = 'ACTIVE'
  );

도메인은 expected_status에서 next_status로 전이가 허용되는지 먼저 검사한다. query는 대상 조직·기대 상태·활성 membership이 맞는 행만 변경한다. 권한 회수의 동시성 요구는 트랜잭션이 보는 상태와 회수 절차까지 함께 정한다.

영향 행 0개는 없음·권한 부족·상태 변경 중 여러 조건을 뜻할 수 있다. 내부 감사 기록에 필요한 원인을 분류하고 외부 응답의 403/404 정책은 객체 존재 공개 범위에 맞춰 정한다.

Spring method security는 메서드 전후의 인가를 제공한다. 진입 권한과 객체 관계를 검사할 수 있지만 목록의 실제 query 범위를 생략하는 근거가 되지는 않는다. 같은 규칙을 호출 경계와 데이터 범위에서 표현한다.

캐시 결과에도 권한 범위가 남아야 한다

다음 키는 tenant를 구분하지 않는다.

review:list:page=1:status=REQUESTED

A 병원의 결과가 저장된 뒤 B 병원의 요청이 같은 키를 읽을 수 있다. 적어도 조직과 결과에 영향을 주는 조건을 포함한다.

review:list:tenant=200:page=1:status=REQUESTED

사용자별 필드 마스킹이 있으면 tenant만으로 충분하지 않을 수 있다. 공통 DTO와 사용자별 권한 결과를 분리하거나 권한 조건을 반영하고 membership 변경 시 무효화도 정한다. DB query가 안전하다는 사실만으로 공유 캐시의 안전성을 판단하지 않는다.

Next.js를 관리자 화면 서버나 BFF로 사용한다면 layout에서 역할을 한 번 검사하는 것으로 끝내지 않는다. Authentication 가이드는 데이터 소스 가까운 검사와 DAL을 설명하고 부분 렌더링에서 layout 검사가 매 탐색마다 실행되지 않을 수 있음을 짚는다. Server Action·Route Handler·조회 함수의 데이터 접근도 같은 관계를 확인한다.

공용 캐시·정적 결과에 사용자별 데이터가 섞이는지, cache tag와 revalidation이 조직을 구분하는지도 본다. 민감한 동적 결과를 공용으로 재사용할 이유와 범위를 명확히 한다.

worker에는 요청 주체와 조직을 전달한다

queue consumer에는 웹 세션의 현재 사용자가 없다. ThreadLocal의 tenant가 비거나 재사용 스레드에 이전 값이 남는다면 범위가 흐려진다. job에 조직·요청 주체·업무 목적을 명시한다.

{
  "jobId": "job-2026-001",
  "tenantId": 200,
  "requestedBy": 9001,
  "operation": "REVIEW_EXPORT"
}

worker는 이 입력도 검증하고 tenant 범위 query를 사용한다. 전체 조직을 처리하는 서비스 계정은 별도 권한과 감사 범위를 둔다. 사용자 요청 export는 완료 시점의 membership 정책을 정하고 다운로드할 때도 다시 검사한다. 작업을 등록할 수 있었다는 이유만으로 회수 이후 과거 링크의 접근을 계속 허용하지 않는다.

RLS를 추가하려면 연결의 조직 상태도 관리한다

PostgreSQL RLS는 query 실수에 대한 DB의 추가 방어선이다.

ALTER TABLE review_request ENABLE ROW LEVEL SECURITY;

CREATE POLICY review_tenant_policy
ON review_request
USING (
  hospital_id = nullif(current_setting('app.tenant_id', true), '')::bigint
)
WITH CHECK (
  hospital_id = nullif(current_setting('app.tenant_id', true), '')::bigint
);

트랜잭션에 검증된 tenant를 주입하면 정책의 USING과 WITH CHECK로 읽기·쓰기 범위를 제한할 수 있다. PostgreSQL 17 RLS 문서는 적용 가능한 정책이 없을 때 기본 거부와 superuser·BYPASSRLS·소유자의 우회 조건을 설명한다. 실제 애플리케이션 role이 정책 대상인지 확인한다.

연결 풀의 세션에 tenant를 남기면 다음 요청에 누출될 수 있다. 트랜잭션 범위의 SET LOCAL app.tenant_id = '200'을 사용하고 종료 경계를 관리한다. SET 문서에 따라 LOCAL 값은 해당 트랜잭션 종료까지 적용된다. migration·배치·보정 계정의 우회 권한도 구분한다.

RLS는 캐시·외부 파일·필드 마스킹이나 복잡한 업무 전이를 대신하지 않는다. 애플리케이션의 객체 인가를 유지하면서 행 정책을 추가하는 선택이다.

두 조직의 허용·거절을 함께 확인한다

A와 B 병원, 양쪽 사용자를 준비한다. A 사용자는 A의 목록·상세·파일·변경에 접근하고, B의 ID를 path·query·body에 넣어도 범위를 넘지 않아야 한다. 목록과 count·export가 같은 범위를 쓰는지 확인한다.

membership 회수 후 열린 화면의 action, A 결과가 캐시된 다음 B 요청, 지정 tenant 밖의 worker 처리, RLS 적용 role과 우회 계정도 별도 조건으로 본다. 성공 응답 하나보다 거절 경로와 공유 자원의 재사용을 함께 확인해야 누락을 찾기 쉽다.

객체를 조회·변경할 때마다 주체의 현재 관계와 대상 조직이 데이터 흐름에 남아야 한다. endpoint의 역할 표식에서 끝내지 않고 query·캐시·비동기 실행까지 이 범위를 이어가는 것이 설계의 기준이다.