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 | 그 축을 제한하지 않음 |
| 요청 경계 밖 (리셋 후) | -1 | col = -1 → 0행 |
"관리자라서 넓게 본다"와 "요청이 끝나서 아무것도 아니다"를 구분하는 장치입니다. 그래서 리셋은 빈 값이 아니라 비매칭 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 로 옮기기"가 아닙니다.