본문으로 건너뛰기

RLS 정책 표준

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

층무엇기본
앱 레벨 — Eloquent 글로벌 스코프쿼리에 소속 조건을 자동 주입항상 켜짐
DB 레벨 — PostgreSQL RLS앱이 실수해도 DB 가 막는 백스톱프로젝트별 순차 활성 (기본 OFF)

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 은 다르게 동작합니다. 이것이 설계의 핵심입니다.

상태세션변수 값결과 (v2 표준 정책)
리졸버가 명시적으로 bypass 판정 (Platform/SaaS 관리자)0그 축을 제한하지 않음
미설정 — 요청 경계 밖(리셋 후), 주체 판정 불가-1 / NULL / ''0행 (거부)

"관리자라서 넓게 본다"(0) 와 "아무도 판정하지 않았다"(미설정) 를 구분하는 장치입니다. 미설정을 통과로 보던 구세대(v1) 정책은 rls:doctor 가 passes_when_unset 으로 보고하고 rls:sync 가 v2 로 교체합니다. 컨텍스트 값은 앱 코드가 아니라 격리 컨텍스트 리졸버가 앱 스코프와 같은 규칙으로 결정합니다 — 그래서 RLS 를 켜도 꺼도 보이는 행이 같습니다.

쓰는 법 — 정책도 컨텍스트도 손으로 쓰지 않습니다​

정책: 축 컬럼만 만들면 됩니다​

테이블에 saas_product_id / tenant_id 컬럼이 있으면 그 테이블은 RLS 대상입니다. 마이그레이션에 정책 DDL 을 두지 않습니다.

php artisan rls:sync --plan     # 정책이 없거나 표준이 아닌 테이블(drift) 보고 — DDL 0
php artisan rls:sync --apply # 활성 게이트를 통과한 프로젝트에서만 v2 표준 정책으로 수렴

make migrate 가 마이그레이션 뒤 rls:sync --apply --after-migrate 를 자동으로 한 번 호출하므로, 새 테이블을 만든 개발자가 정책을 잊어도 수렴됩니다. 정책을 두지 말아야 할 테이블은 설정에 사유와 함께 등록합니다.

// config/core.php
'tenant' => [
'rls_policy_exemptions' => [
'my_lookup_table' => '공용 코드표 — 소속 없음',
],
],

구세대 방식인 마이그레이션의 RlsPolicy::apply() 는 레거시로만 남아 있습니다. 새 코드에서 쓰면 정적 감사가 경고합니다.

컨텍스트: 리졸버가 결정합니다​

(saas, tenant) 값은 격리 컨텍스트 리졸버 한 곳이 앱 스코프와 같은 규칙으로 결정하고, 단일 writer 가 세션변수에 씁니다. 미들웨어나 서비스가 set_config 를 직접 부르지 않습니다.

  • 요청 경로: 아무것도 할 필요가 없습니다. 미들웨어와 Eloquent 스코프가 리졸버 값을 자동으로 동기화합니다.
  • 큐/스케줄러에서 소유자 범위가 필요한 경우: 허용 목록(RlsWriterAllowlist)에 사유와 함께 등록된 클래스가 scoped() 로 한 번 범위를 세웁니다.
  • 그 밖의 직접 쓰기는 정적 감사가 "리졸버 우회" 로 잡아 CI 를 실패시킵니다.
SET x = ? 는 조용히 실패합니다

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

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

호출부가 예외를 삼키면 세션변수를 세운 줄 알았는데 아무것도 안 쓰인 상태가 됩니다. 애초에 앱 코드에서 세션변수를 쓰지 마세요.

축은 SaaS·Tenant 둘뿐입니다​

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

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

켜기 전 — readiness 게이트​

RLS 는 플랫폼 공통 코드에 준비돼 있고(Track A), 프로젝트별로 순차 활성합니다(Track B). 켜기 전에 아래가 모두 충족돼야 하며, rls:doctor 가 기계적으로 판정합니다. 하나라도 빠지면 rls:sync --apply 는 정책을 만들지 않고 보고만 합니다.

#전제왜
anon-superuser · non-BYPASSRLS 접속 rolesuperuser 는 FORCE RLS 도 우회합니다 — 켜도 아무것도 강제되지 않는 보안 착시
bDDL/마이그레이션 role 은 BYPASSRLS마이그레이션이 정책에 걸려 멈추지 않도록
capplication group role 설정정책은 그룹 role 에 걸고, 접속 role 이 그 그룹의 USAGE 멤버로 상속받습니다(role 이름이 바뀌어도 정책은 그대로)
d컨텍스트 주입·리셋 동작세션변수가 리졸버 값으로 세팅되고 요청 경계에서 sentinel 로 돌아가는지

정책 대상 role 과 실제 접속 role 의 멤버십이 어긋나면 FORCE ROW LEVEL SECURITY 아래서 매칭 정책이 없어 전체가 차단됩니다 — 그래서 게이트가 먼저입니다.

진단​

php artisan rls:doctor

readiness(켤 수 있는가) 와 effective(실제로 강제되는가) 를 나눠 판정하고, 정책이 없는 축 테이블(missing_policies), 미설정 컨텍스트에서 통과하는 구세대 정책(passes_when_unset), v1/v2 공존, permissive OR 결합을 보고합니다. --json 으로 기계 판독할 수 있습니다.

정적 검사는 DB 없이 돌아 CI 에 넣을 수 있습니다. 주석과 문자열을 코드로 오인하지 않는 tokenizer 분석기이며, 검토가 끝난 기존 부채는 baseline 에 두고 새로 늘어난 High 만 실패시킵니다.

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

잡는 것: 직접 CREATE POLICY, 직접 set_config, 리졸버 우회 write(), SET x = ?, 신규 v1 apply(), 축 컬럼이 있는데 스코프 trait 이 없는 모델.

함께 지킬 것​

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