본문으로 건너뛰기

RLS 정책 표준

MSK 의 데이터 격리는 두 층입니다.

무엇기본
앱 레벨 — Eloquent 글로벌 스코프쿼리에 소속 조건을 자동 주입항상 켜짐
DB 레벨 — PostgreSQL RLS앱이 실수해도 DB 가 막는 백스톱옵트인

RLS 는 유일 방어가 아닙니다. 앱 레벨 스코프가 1차 방어이고, RLS 는 그 아래에서 같은 경계를 물리적으로 재강제합니다. 그래서 RLS 를 켜도 꺼도 보이는 데이터가 같아야 합니다 — 달라지면 둘 중 하나가 잘못된 것입니다.

핵심: 상위 관리자는 접두만 가진다

행의 소속은 복합 키 (saas_product_id, tenant_id) 로 정해집니다. 그런데 요청자의 컨텍스트는 그 키의 접두(prefix) 입니다.

요청자컨텍스트복합 키로 보면
테넌트 관리자(5, 12)완전한 키
SaaS 관리자(5, ∅)접두만
Platform 관리자(∅, ∅)빈 접두

경로로 쓰면 /saas5/tenant12/row 입니다. SaaS 관리자는 /saas5/ 를 들고 그 아래 전부를 봐야 합니다.

그래서 완전 일치로 비교하면 안 됩니다

-- ⛔ 컨텍스트 존재를 요구하는 형태
saas_ctx > 0 AND tenant_ctx > 0
AND saas_product_id = saas_ctx AND tenant_id = tenant_ctx

SaaS 관리자는 테넌트 컨텍스트가 없으므로 두 번째 조건에서 탈락합니다. 결과는 관리페이지가 조용히 0행이 되는 것입니다. 실제로 이 형태 때문에 한 프로젝트의 SaaS 패널에 리소스를 붙이지 못한 사례가 있었습니다.

-- ✅ 표준형 — 축마다 "컨텍스트가 지정된 축만 제한한다"
(saas_ctx = 0 OR saas_product_id = saas_ctx)
AND (tenant_ctx = 0 OR tenant_id = tenant_ctx)

두 종류의 "빈 상태"

표준형에서 0(미지정)과 리셋 sentinel -1다르게 동작합니다. 이것이 설계의 핵심입니다.

상태세션변수 값결과
요청 중 · 컨텍스트 미설정 (상위 관리자)NULL / '' / 0그 축을 제한하지 않음
요청 경계 밖 (리셋 후)-1col = -10행

"관리자라서 넓게 본다"와 "요청이 끝나서 아무것도 아니다"를 구분하는 장치입니다. 그래서 리셋은 빈 값이 아니라 비매칭 sentinel 을 심습니다.

쓰는 법

정책 적용

use App\Core\Base\Tenant\Rls\{RlsPolicy, RlsAxis};

// 마이그레이션에서
RlsPolicy::apply('my_table', [RlsAxis::Saas, RlsAxis::Tenant]);

// 되돌리기
RlsPolicy::drop('my_table');

축은 테이블에 실제로 있는 컬럼만 넘깁니다. 없는 축을 넘기면 정책을 만들기 전에 분명한 예외로 끊습니다 — 그러지 않으면 FORCE ROW LEVEL SECURITY 만 켜진 채 정책이 없는 테이블이 남아 전체가 차단됩니다.

세션 컨텍스트 쓰기

use App\Core\Base\Tenant\Rls\RlsContext;

app(RlsContext::class)->write([
'app.current_tenant_id' => $tenant->id,
]);
SET x = ? 는 조용히 실패합니다

PostgreSQL 의 SET 은 utility 문이라 prepared statement 파라미터를 받지 못합니다.

DB::statement('SET app.current_tenant_id = ?', [$id]);  // SQLSTATE[42601]

호출부가 예외를 삼키면 세션변수를 세운 줄 알았는데 아무것도 안 쓰인 상태가 됩니다. RLS 는 전혀 적용되지 않고 실패 신호도 없습니다. 반드시 RlsContext::write() 를 쓰세요.

축은 SaaS·Tenant 둘뿐입니다

조직 계층(Organization / Workspace / Group)은 RLS 축으로 삼지 않습니다. 조직은 자기참조 트리라 "내 조직과 그 하위 전체"를 정책 술어로 표현하려면 재귀 CTE 를 행마다 평가해야 하고, 인덱스가 무력화됩니다.

하위 계층 격리는 앱 레벨 스코프와 권한 게이트가 담당합니다. RLS 의 목적은 "테넌트 경계를 DB 가 최종 보증"이지 "모든 권한 계층을 DB 로 옮기기"가 아닙니다.

활성 전 4대 전제

RLS 를 켜기 전에 네 가지가 모두 충족되어야 합니다. 하나라도 빠지면 켜도 의미가 없거나 운영이 깨집니다.

#전제
anon-superuser 접속 rolesuperuser 는 FORCE RLS 도 우회합니다 — 켜도 아무것도 강제되지 않는 보안 착시
b정책 적용플래그만 켜고 정책이 없으면 보호되지 않습니다
c컨텍스트 주입 동작세션변수가 실제로 세팅되는지
d리셋 보장값이 요청 경계를 넘으면 앞 테넌트로 조회됩니다

특히 정책 대상 role 과 실제 접속 role 이 같아야 합니다. FORCE ROW LEVEL SECURITY 는 테이블 소유자에게도 적용되므로, 어긋나면 매칭 정책이 없어 전체가 차단됩니다.

진단

php artisan rls:doctor

플래그, 접속 role 과 superuser 여부, 정책 인벤토리, 술어 형태(표준/컨텍스트필수), 컨텍스트 주입 실동작, 리셋 대칭성을 보고 4대 전제를 판정합니다.

정적 검사는 DB 없이 돌아 CI 에 넣을 수 있습니다.

bash ops/scripts/audit-rls-posture.sh --strict

함께 지킬 것

tenant/saas 컬럼을 가진 모델에는 BelongsToTenant / BelongsToSaasProduct반드시 붙입니다. RLS 는 백스톱일 뿐이고, RLS 가 꺼진 환경에서도 격리는 앱 레벨이 보장해야 합니다.