refactor: 문서 개선 중

This commit is contained in:
donghyeon-ka
2026-09-21 14:30:55 +09:00
parent c93cdea150
commit 805a18f486
1497 changed files with 525837 additions and 59152 deletions
@@ -1,7 +1,7 @@
---
kind: CASE
slug: three-ways-rls-does-nothing
title: RLS가 아무것도 하지 않는 세 가지 방법
title: RLS 검증에서 구분해야 할 우회와 미적용 경로
topic: multitenancy-isolation
project: clean-architecture-backend-template
status: 게시 전
@@ -17,28 +17,26 @@ source:
- 원본 분석 절은 final/document.md#4-1 · final/document.md#a05 §13.1, §17 P8 이다.
---
# RLS가 아무것도 하지 않는 세 가지 방법
# RLS 검증에서 구분해야 할 우회와 미적용 경로
정책 검증기가 행 수준 보안이 조용히 무력화되는 세 경로를 전부 확인한다. 다만 검증기 자신이 보호되어야 할 테이블의 부재를 성공으로 인정하는 결함을 갖는다.
`RlsPolicyVerifier`는 RLS 활성 여부, 적용 가능한 policy, 런타임 role의 우회 속성, table owner 여부와 `FORCE ROW LEVEL SECURITY` 상태를 확인한다. 이 값들은 하나의 ‘세 전제’가 아니다. 일반 role에서 RLS가 활성화됐는데 적용 가능한 policy가 없으면 PostgreSQL은 default deny를 적용하고, owner는 FORCE 여부에 따라 policy 적용 여부가 갈린다. 검증기 자체에는 보호 대상 테이블의 부재를 성공으로 인정하는 별도 결함이 있다.
## 관계
- **RLS가 성립하기 위한 세 전제**
이 검증기가 확인하는 세 조건이다.
- **PostgreSQL RLS 적용 여부를 가르는 분기**
이 검증기가 읽는 값들을 PostgreSQL의 실제 적용 규칙에 맞춰 해석한 개념이다.
- **Hibernate filter는 보안 경계가 아니다**
같은 격리 문제를 ORM 기능으로 풀려 할 때의 규칙이다.
- **tenant 컬럼이 있는 테이블의 모든 unique 제약에 그 컬럼이 들어가야 한다**
같은 마이그레이션이 함께 다룬 축이다.
- **tenant 내부 유일성과 전역 유일성은 구분한다**
tenant 내부에서만 유일해야 하는 값은 tenant 식별자를 포함하고, 전역 유일성이 요구사항이면 global unique를 유지할 수 있다.
## 문제
행 수준 보안은 설정이 올바르게 보이면서 아무것도 하지 않을 수 있다. 그 경로가 셋이다.
행 수준 보안은 조건에 따라 서로 다른 결과를 만든다.
정책이 없거나 테이블에 RLS 가 활성화되지 않았다
런타임 롤이 우회 속성을 갖는다
런타임 롤이 그 테이블을 소유한다
RLS가 비활성화되어 있으면 policy가 적용되지 않는다. RLS가 활성화됐지만 현재 role에 적용 가능한 policy가 없으면 일반 role에는 default deny가 적용된다. superuser나 `BYPASSRLS` role은 RLS를 우회한다. table owner는 기본적으로 policy를 우회하지만 `FORCE ROW LEVEL SECURITY`가 켜지면 policy 대상이 된다.
셋 다 같은 결과를 만든다. 모든 쿼리가 모든 테넌트의 행을 돌려주면서 정책은 올바르게 설정된 것처럼 보인다.
따라서 이 검증에서 봐야 할 것은 ‘셋 중 하나가 틀리면 모두 허용’이 아니라 현재 role이 어느 분기에 속하고 그 분기에서 policy가 실제로 적용되는지다.
## 결론
@@ -48,19 +46,19 @@ source:
세 번째가 가장 놓치기 쉽다. 소유자는 기본적으로 자기 정책에서 면제되고, 소유자는 흔히 마이그레이션 롤이며, 그것이 사람들이 테스트하는 롤이다.
같은 축의 문제가 유니크 인덱스에도 있고 마이그레이션 주석이 그것을 적는다. 테넌트 격리가 컬럼에 의존하면 그 테이블의 모든 유일성 요구에 그 컬럼이 들어가야 한다. 값만으로 걸린 유니크 인덱스는 다른 테넌트가 그 값을 이미 썼다는 이유로 한 테넌트의 삽입을 실패시키고, 그것은 버그이자 정보 유출이다.
같은 축의 문제가 유니크 인덱스에도 있고 마이그레이션 주석이 그것을 적는다. tenant 내부에서만 유일해야 하는 값은 tenant 식별자를 포함해야 한다. 반대로 시스템 전체에서 유일해야 하는 값은 global unique로 둘 수 있다. 따라서 `(value)`만의 unique index가 문제인지는 그 값의 유일성 범위가 tenant 내부인지 전역인지에 따라 판단한다.
다만 검증기 자신에게 결함이 있다. 반드시 보호되어야 할 테이블이 조회 결과에 없을 때 그것을 성공으로 인정한다. 즉 테이블 이름이 바뀌거나 조회 조건이 어긋나면 검증이 통과한다.
## 검증 환경
데이터베이스 : PostgreSQL
확인 방식 : 검증기가 검사하는 세 조건과 마이그레이션 주석 확인
확인 방식 : 검증기가 읽는 RLS 분기 값과 마이그레이션 주석 확인
소스 수정 : x
## 재현 조건
1. 정책 검증기의 클래스 javadoc 을 읽는다. 세 경로가 열거되어 있다.
1. 정책 검증기의 클래스 javadoc을 읽어 기존 구현이 열거한 세 경우를 확인한다.
2. 검증기가 실행하는 카탈로그 조회를 확인한다.
3. 마이그레이션의 유니크 인덱스 주석을 읽는다.
4. 보호 대상 테이블이 조회 결과에 없을 때의 처리를 확인한다.
@@ -69,7 +67,7 @@ source:
<!-- body:start -->
`RlsPolicyVerifier` RLS가 조용히 무력화되는 세 경로를 전부 확인한다 — policy 없음/RLS 미활성 · 런타임 롤이 `BYPASSRLS` 보유 · **런타임 롤이 테이블을 소유**(FORCE 없으면 면제).
`RlsPolicyVerifier` RLS 활성 여부와 policy 상태, runtime role의 `BYPASSRLS`, table owner 여부와 `FORCE ROW LEVEL SECURITY`를 함께 본다. 이 값들은 PostgreSQL이 policy를 적용할지 결정하는 서로 다른 분기다.
## RlsPolicyVerifier 참조 위치
@@ -78,11 +76,11 @@ source:
## 세 번째가 가장 놓치기 쉽다
소유자는 정책을 우회하는 것이 아니라 애초에 적용 대상이 아니다.
table owner는 기본적으로 RLS policy를 우회한다. `FORCE ROW LEVEL SECURITY`를 켜면 owner도 policy 적용 대상이 다.
## unique index 에도 같은 축의 주석이 있다
tenant 격리가 컬럼에 의존하면 그 테이블의 **모든 uniqueness 요구**에 그 컬럼이 들어가야 하고, `(value)`만의 unique index는 다른 tenant가 그 값을 썼다는 이유로 insert 실패시켜 **버그이자 정보 유출**이 된다.
tenant 내부 유일성을 표현하는 uniqueness 요구에는 tenant 식별자를 포함해야 한다. `(value)`만의 unique index는 전역 유일성을 뜻하므로, 요구사항이 tenant 내부 유일성이라면 다른 tenant의 값 때문에 insert 실패한다. 반대로 전역 유일성이 실제 요구사항이면 global unique 자체가 잘못은 아니다.
## verifier 자신에게도 결함이 있다
@@ -90,6 +88,6 @@ tenant 격리가 컬럼에 의존하면 그 테이블의 **모든 uniqueness 요
## 확인하지 못한 것
세 조건을 하나씩 깨서 검증기가 실제로 잡는지 확인하지 않았다. 실제 RLS 환경을 세우지 않았다.
RLS 비활성, 우회 role, owner/FORCE, applicable policy 부재를 실제 PostgreSQL 환경에서 각각 재현하지 않았다.
<!-- body:end -->
@@ -1,7 +1,7 @@
---
kind: CONCEPT
slug: rls-three-preconditions
title: RLS가 성립하기 위한 세 전제
title: PostgreSQL RLS 적용 여부를 가르는 분기
topic: multitenancy-isolation
project: clean-architecture-backend-template
status: 게시 전
@@ -20,14 +20,14 @@ source:
- 원본 분석 절은 final/document.md#4-1 · final/document.md#a05 §13.1 이다.
---
# RLS가 성립하기 위한 세 전제
# PostgreSQL RLS 적용 여부를 가르는 분기
PostgreSQL 의 행 수준 보안이 실제로 격리하려면 세 가지가 동시에 참이어야 한다. 셋 중 하나만 어긋나도 정책은 올바르게 설정된 것처럼 보이면서 모든 쿼리가 모든 테넌트의 행을 돌려준다.
PostgreSQL의 행 수준 보안`ENABLE ROW LEVEL SECURITY`, role의 우회 권한, table owner 여부, `FORCE ROW LEVEL SECURITY`, 적용 가능한 policy 유무에 따라 동작이 갈린다. 이 값들을 항상 동시에 참이어야 하는 세 전제로 묶으면 default deny와 owner 예외를 잘못 설명하게 된다.
## 관계
- **RLS가 아무것도 하지 않는 세 가지 방법**
세 전제를 검증기가 실제로 확인하는 사례다.
- **RLS 검증에서 구분해야 할 우회와 미적용 경로**
검증기가 읽는 값이 PostgreSQL의 어느 적용 분기에 해당하는지 확인한 사례다.
- **Hibernate filter는 보안 경계가 아니다**
ORM 기능을 격리 경계로 쓸 때의 문제를 다룬 규칙이다.
- **격리 설정은 트랜잭션 로컬이어야 한다**
@@ -37,18 +37,21 @@ PostgreSQL 의 행 수준 보안이 실제로 격리하려면 세 가지가 동
<!-- body:start -->
PostgreSQL RLS가 실제로 격리하려면 세 가지가 동시에 참이어야 한다.
PostgreSQL RLS는 동시에 만족해야 할 세 조건이 아니라 적용 여부를 결정하는 분기로 보는 편이 정확하다.
## 격리가 성립하는 세 조건
## RLS 적용 여부를 결정하는 순서
:::evidence key="rls-three-preconditions-diagram" alt="ENABLE RLS 와 FORCE RLS 와 BYPASSRLS 없는 롤이 격리 성립 조건 안에 나란히 놓인다" caption="격리가 성립하는 세 조건" zoom="false"
:::evidence key="rls-three-preconditions-diagram" alt="RLS 활성 여부에서 시작해 BYPASSRLS·superuser 우회, table owner와 FORCE RLS, applicable policy 유무를 구분하는 분기" caption="PostgreSQL RLS 적용 여부를 가르는 분기" zoom="false"
:::
1. `ENABLE ROW LEVEL SECURITY` — 테이블에 policy를 켠다.
2. `FORCE ROW LEVEL SECURITY`**테이블 OWNER에게도** 적용한다. 없으면 owner는 자기 policy에서 면제되고, **owner는 흔히 마이그레이션 롤이며 그것이 사람들이 테스트하는 롤이다.**
3. 런타임 롤이 `BYPASSRLS`를 갖지 않는다 — 이것은 테이블 속성이 아니라 롤 속성이라 startup에서 assert해야 한다.
1. `ENABLE ROW LEVEL SECURITY`가 꺼져 있으면 policy는 적용되지 않는다.
2. RLS가 켜져 있어도 superuser와 `BYPASSRLS` role은 우회한다.
3. 일반 non-owner role은 policy 대상이다.
4. table owner는 기본적으로 우회하지만 `FORCE ROW LEVEL SECURITY`를 켜면 policy 대상이 된다.
5. policy 대상인데 적용 가능한 policy가 없으면 default deny가 적용되어 row가 보이거나 수정되지 않는다.
6. 적용 가능한 policy가 있으면 `USING``WITH CHECK`가 실제 row 가시성과 쓰기를 결정한다.
## 동시에 참이어야 하는 세 가지
## 검증기가 읽는 값과 PostgreSQL 의미를 분리한다
:::evidence key="rls-three-preconditions" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
@@ -59,11 +62,11 @@ policy가 `current_setting('app.tenant_id', true)`를 쓴다 — 두 번째 인
:::note
실제 RLS 환경을 세워 세 전제를 하나씩 깨보지 않았다
실제 RLS 환경에서 각 적용 분기를 하나씩 재현하지 않았다
:::
## 세 전제
## 기존 verifier javadoc이 적은 세 경우
```java
/**
@@ -83,23 +86,24 @@ policy가 `current_setting('app.tenant_id', true)`를 쓴다 — 두 번째 인
*/
```
세 번째가 이 개념의 핵심이다.
위 javadoc은 현재 검증기가 의도한 모델을 보여 주지만, ‘세 경우 모두 모든 row를 반환한다’는 일반화는 PostgreSQL 의미와 맞지 않는다. 특히 RLS가 켜진 상태에서 applicable policy가 없으면 일반 role에는 default deny가 적용된다.
| 전제 | 무엇인가 | 왜 놓치는가 |
|---|---|---|
| 정책과 RLS 활성화 | 테이블 속성 | 가장 명시적이라 잘 보인다 |
| 런타임 롤이 `BYPASSRLS` 를 갖지 않음 | 롤 속성 | 테이블을 봐서는 알 수 없다 |
| 테이블 소유자에게도 강제 | 테이블 속성 | 소유자가 대개 마이그레이션 롤이고, 사람들이 그 롤로 테스트한다 |
| 확인 항목 | PostgreSQL에서의 의미 |
|---|---|
| RLS 활성 여부 | 꺼져 있으면 policy가 적용되지 않는다 |
| superuser / `BYPASSRLS` | RLS를 우회한다 |
| table owner | 기본적으로 우회하며 `FORCE ROW LEVEL SECURITY`가 owner 동작을 바꾼다 |
| applicable policy | 없으면 policy 대상 role에는 default deny가 적용된다 |
## 소유자 면제가 왜 함정인가
테이블 소유자는 자기 정책에서 면제된다. `FORCE ROW LEVEL SECURITY` 를 켜야 소유자에게도 적용된다.
테이블 소유자는 기본적으로 RLS policy를 우회한다. owner도 policy 대상이어야 한다면 `FORCE ROW LEVEL SECURITY`를 켠다. 이 설정은 superuser나 `BYPASSRLS` role의 우회를 없애는 옵션이 아니다.
그리고 소유자는 흔히 마이그레이션 롤이다. 그 롤이 스키마를 만들었기 때문이다. 검증할 때 쓰는 롤도 대개 그것이다.
스키마를 만든 migration role이 해당 table owner가 되는 경우가 있다. 이때 중요한 것은 `migration role`이라는 이름이 아니라 owner 여부다. superuser나 `BYPASSRLS` 여부는 별도의 role 속성이고, 검증에 같은 owner role을 쓰면 owner bypass가 policy 적용 여부를 가릴 수 있다.
:::danger
마이그레이션 롤로 테스트하면 정책이 전혀 적용되지 않은 상태에서도 통과한다. 그리고 그 롤은 정책이 잘 붙어 있다고 보고한다.
migration role이 해당 table owner이고 `FORCE ROW LEVEL SECURITY`가 꺼진 상태에서 그 롤로 테스트하면 owner bypass 때문에 policy가 적용되지 않은 채 테스트가 통과할 수 있다. `migration role`이라는 이름 자체는 bypass 조건이 아니다. superuser나 `BYPASSRLS`라면 그 속성 때문에 별도로 우회한다.
:::