
1. 테넌트(Tenant)란?
- 하나의 서비스를 여러 고객사(회사)가 같이 쓰는 구조에서, 각 고객사를 "테넌트"라고 부름
- 보통 DB/테이블을 회사별로 나누지 않고, 하나의 테이블에
tenant_id 컬럼으로 소유 회사를 구분해서 같이 저장
- 핵심 요구사항: A회사가 로그인했을 때 B회사 데이터를 절대 봐서는 안 됨
2. RLS(Row-Level Security)란?
- "어떤 세션이 어떤 행(row)을 볼 수 있는가"를 애플리케이션 코드가 아니라 DB 자체가 강제하는 PostgreSQL 보안 기능
- 기존 방식(애플리케이션에서 매번
WHERE tenant_id = ? 작성)은 개발자 실수로 조건을 빠뜨리면 전체 데이터가 노출되는 위험이 있음
- RLS는 이런 실수에도 DB가 마지막 안전장치 역할을 함 (심층 방어, defense in depth)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id'));
3. RLS를 "제대로" 적용하기 위한 4가지 원칙
3-1. BYPASSRLS 없는 전용 계정
- PostgreSQL 슈퍼유저나
BYPASSRLS 속성이 있는 롤은 RLS 정책을 무시하고 전체 데이터를 다 봄
- 애플리케이션은 반드시 이 속성이 없는 계정으로 접속해야 RLS가 실제로 동작함
CREATE ROLE app_user LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS;
3-2. FORCE ROW LEVEL SECURITY
- PostgreSQL은 기본적으로 테이블 소유자(owner)에게는 RLS를 적용하지 않음 (관리자 편의를 위한 설계)
- 운영/관리 계정이 실수로 소유자 권한으로 접속해 전체 데이터를 긁어가는 사고를 막기 위해 강제 적용 필요
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
3-3. USING vs WITH CHECK
| 구분 | 적용 시점 | 대상 명령어 |
|---|
| USING | 이미 존재하는 행을 대상으로 작업할 때 ("건드릴 수 있는 행인가") | SELECT, DELETE, UPDATE(대상 선정) |
| WITH CHECK | 새로 쓰이거나 변경된 결과값을 검증할 때 ("결과물이 유효한가") | INSERT, UPDATE(결과 검증) |
CREATE POLICY tenant_select ON orders
FOR SELECT USING (tenant_id = current_setting('app.tenant_id'));
CREATE POLICY tenant_delete ON orders
FOR DELETE USING (tenant_id = current_setting('app.tenant_id'));
CREATE POLICY tenant_insert ON orders
FOR INSERT WITH CHECK (tenant_id = current_setting('app.tenant_id'));
CREATE POLICY tenant_update ON orders
FOR UPDATE
USING (tenant_id = current_setting('app.tenant_id'))
WITH CHECK (tenant_id = current_setting('app.tenant_id'));
- UPDATE는 USING + WITH CHECK 둘 다 필요
- USING: 원래 내 테넌트 행만 수정 대상으로 삼음
- WITH CHECK: 수정 후에도 여전히 내 테넌트여야 함 (안 그러면 UPDATE로 tenant_id를 바꿔서 다른 테넌트로 행을 넘겨버릴 수 있음)
- UPSERT(
INSERT ... ON CONFLICT DO UPDATE)는 내부적으로 INSERT + UPDATE 양쪽 체크를 다 받음
3-4. tenant_id 변경 금지 트리거
- WITH CHECK과 별개로, UPDATE로 tenant_id 자체를 바꿔서 소유권을 이전하는 것을 이중으로 막는 안전장치
CREATE OR REPLACE FUNCTION prevent_tenant_id_change()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.tenant_id <> OLD.tenant_id THEN
RAISE EXCEPTION 'tenant_id cannot be changed';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_prevent_tenant_id_change
BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION prevent_tenant_id_change();
4. 요약표
| 요소 | 막는 시나리오 |
|---|
| BYPASSRLS 없는 계정 | 앱이 아예 RLS를 우회하는 상황 |
| FORCE RLS | 테이블 소유자/관리 계정도 RLS 통과하게 강제 |
| USING | 남의 테넌트 행을 읽거나 삭제 |
| WITH CHECK | 남의 테넌트로 행을 새로 쓰거나, UPDATE로 넘겨버리기 |
| 트리거 | UPDATE로 tenant_id 자체를 바꿔서 소유권 이전 |
5. Go/Java 테스트 코드 검증 방향 (다음 단계)
- 테스트 DB에 실제 RLS 적용된 스키마 반영 (마이그레이션 스크립트 포함)
SET app.tenant_id = 'tenant-A' 세션에서 tenant-B 데이터 조회/수정/삭제 시도 → 0건 반환 또는 예외 확인
- tenant-A 세션에서 tenant_id를 B로 바꾸는 UPDATE 시도 → 트리거 예외 발생 확인
- 애플리케이션 커넥션이 BYPASSRLS 없는 계정을 쓰는지 별도 검증
SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user;