콘텐츠로 이동

(KO) PostgreSQL 행 수준 보안 — 정책과 리라이트 시점 자격 조건 주입

목차

관계형 데이터베이스의 접근 제어는 전통적으로 테이블과 열 단위에서 작동했다. GRANT SELECT ON accounts TO alice는 Alice에게 테이블 전체를 주거나 아무것도 주지 않는다. 이것이 SQL 표준의 GRANT/REVOKE 핵심인 임의 접근 제어(Discretionary Access Control, DAC) 모델이며, Database System Concepts(Silberschatz, ch. 4 “Intermediate SQL” 인가 절, ch. 9 애플리케이션 설계와 보안)에서 설명하는 객체 단위 모델이다. 기밀 경계가 테이블과 일치할 때는 충분하다. 두 테넌트가 같은 테이블을 공유하거나 관리자가 자기 부서 행만 볼 수 있어야 하는 순간, 기밀 경계는 열을 따라 세로가 아니라 행을 따라 가로로 그어진다.

“주체가 어떤 행을 볼 수 있는가”에 대한 고전적 답은 세 가지다.

  1. 애플리케이션 수준 필터링. 애플리케이션이 내보내는 모든 쿼리에 WHERE tenant_id = current_tenant() 술어를 붙인다. 단순하지만 개발자의 규율에만 의존한다. WHERE 절 하나를 빠뜨리면 테이블 전체가 노출되고, psql이나 리포팅 연결 같은 임시 도구는 이를 완전히 우회한다.

  2. 갱신 가능 보안 뷰. CREATE VIEW my_rows AS SELECT * FROM t WHERE owner = current_user를 정의하고 기반 테이블 접근을 철회한 뒤 뷰에만 접근 권한을 부여한다. 이렇게 하면 술어가 스키마 안으로 이동하여 잊힐 수 없다. 고전적 어려움은 Rizvi 등이 “Extending Query Rewriting Techniques for Fine-Grained Access Control”(SIGMOD 2004)에서 연구했다. 단순 뷰는 쿼리 내 사용자 제공 부작용 함수가 WHERE 절이 숨겨야 할 행을 관찰하는 것을 막지 못한다. 옵티마이저가 보안 술어보다 먼저 그 함수를 평가할 수 있기 때문이다. 해결책이 보안 배리어(security-barrier) 개념이다. 사용자 함수가 leakproof하다고 증명되지 않는 한 플래너가 그 아래로 내릴 수 없는 자격 조건이다.

  3. 행 수준 보안(RLS) / 레이블 기반 / 세밀한 접근 제어. DBMS 자체가 테이블에 행 단위 술어를 투명하게 부착하여 그 테이블을 건드리는 모든 문장에 적용한다. 보안 뷰 기법을 일반화한 것이다. 사용자를 이름 붙은 뷰로 강제하는 대신 엔진이 리라이트 시점에 뷰의 술어를 사용자 쿼리 자체에 주입한다. Bertino & Sandhu의 “Database Security — Concepts, Approaches, and Challenges”(IEEE TDSC 2005)는 이를 내용 기반 세밀한 접근 제어로 분류하며, 어떤 시스템이든 독립적으로 내려야 하는 두 결정을 지적한다. 주체가 기존 행을 읽거나 잠글 수 있는지(가시성 술어)와 새로운 행 값을 쓸 수 있는지(무결성 술어)다. PostgreSQL은 이 두 결정을 각각 USINGWITH CHECK로 명명한다.

RLS의 이론적 핵심은 접근 제어를 위한 쿼리 재작성이다. 보안 술어는 이그제큐터에 덧붙인 런타임 게이트가 아니라 쿼리 트리의 변환이다. 옵티마이저와 이그제큐터는 금지된 행을 이미 제외한, 완전히 구성된 단일 쿼리를 본다. RLS가 src/backend/rewrite/에 있는 이유가 여기 있다. 뷰와 룰을 확장하는 동일한 장치를 접근 제어 자격 조건 주입에 특화한 것이다. 리라이터가 지켜야 하는 정확성 의무는 두 가지다. (a) 완전성 — 행을 반환하는 모든 경로(SELECT, RETURNING, FOR UPDATE 잠금)는 필터되어야 하고, 행을 쓰는 모든 경로(INSERT, UPDATE)는 검사되어야 한다. (b) 우회 불가 — 주입된 술어는 옵티마이저가 사용자 함수를 그 아래로 내릴 수 없는 보안 배리어여야 한다.

행 수준 / 세밀한 접근 제어를 제공하는 상용 및 오픈소스 데이터베이스는 소수의 설계 요소로 수렴한다. 이를 명명해 두면 PostgreSQL의 구체적 선택이 공유된 설계 공간 안의 한 점으로 읽힌다.

(릴레이션, 명령) 키로 인덱스된 정책 카탈로그

섹션 제목: “(릴레이션, 명령) 키로 인덱스된 정책 카탈로그”

모든 구현은 정책 정의를 보호 대상 객체와 연산으로 키를 삼는 시스템 카탈로그에 저장한다. Oracle의 VPD(Virtual Private Database)는 DBMS_RLS.ADD_POLICY로 (테이블, 문장 타입) 쌍마다 정책 함수를 등록하고, 함수가 반환하는 술어 문자열을 서버가 문장에 덧붙인다. SQL Server의 보안 정책은 인라인 테이블 반환 함수를 FILTER 술어(읽기 경로) 또는 BLOCK 술어(쓰기 경로)로 바인딩한다. PostgreSQL은 선언적 표현식(함수가 아닌)을 pg_policy에 저장하며, 이름 붙은 정책 한 튜플이 명령 클래스·역할 목록·두 자격 조건을 담는다. 공통분모: 보호 대상 객체와 연산 클래스가 조회 키이고, 값은 행에 대한 불리언 술어다.

두 종류의 술어: 읽기/가시성 대 쓰기/무결성

섹션 제목: “두 종류의 술어: 읽기/가시성 대 쓰기/무결성”

읽기 술어는 기존 행이 가시적인지(잠금 읽기의 경우 잠금 가능한지) 결정하고, 쓰기 술어는 새로운 행 값이 허용되는지 결정한다. SQL Server의 FILTER-BLOCK 구분이 정확히 이것이다. Oracle VPD는 statement_types를 구분하고 열 민감 마스킹을 위한 sec_relevant_cols도 제공한다. PostgreSQL은 읽기 술어를 USING, 쓰기 술어를 WITH CHECK로 명명한다. 중요한 인체공학적 선택으로, USING만 주어지면 WITH CHECKUSING으로 기본 설정된다. 작성자가 의도적으로 분리하지 않으면 “볼 수 있는 것”과 “쓸 수 있는 것”을 단일 술어가 모두 관장한다.

복수 정책 결합: 허가의 OR, 거부의 AND

섹션 제목: “복수 정책 결합: 허가의 OR, 거부의 AND”

여러 정책이 적용될 때 시스템은 조합 방식을 정의해야 한다. 사실상 보편적 관례는 허용적(permissive) 정책을 OR 결합하고 — 하나라도 허가하면 충분 — 제한적(restrictive) 정책을 AND 결합하는 것이다. 허가는 가산적이고 제한은 연언적이라는 직관을 반영한다. PostgreSQL은 정책마다 이 구분을 명시적으로 표시하고(AS PERMISSIVE가 기본, AS RESTRICTIVE로 AND 의미론 선택), 결합 규칙을 리라이터에 직접 구워 넣는다.

기능이 활성화되었지만 미설정 시 기본 거부

섹션 제목: “기능이 활성화되었지만 미설정 시 기본 거부”

미묘하지만 중요한 관례가 있다. 테이블에 RLS가 활성화되면, 일치하는 정책이 없을 때의 결과는 “모든 행 없음”이어야지 “모든 행”이면 안 된다. 그렇지 않으면 RLS를 활성화하고 정책 작성을 잊었을 때 모든 것이 조용히 노출된다. PostgreSQL은 허용 정책이 없을 때 항상 거짓인 상수 하나를 내보내는 방식으로 기본 거부를 구현한다.

소유자/슈퍼유저 우회와 강제 모드

섹션 제목: “소유자/슈퍼유저 우회와 강제 모드”

테이블 소유자와 높은 권한의 역할은 유지관리·백업·스키마 작업이 RLS 필터를 받지 않도록 통상 RLS를 우회한다. PostgreSQL은 소유자에게 묵시적 우회를, 슈퍼유저가 항상 보유하는 BYPASSRLS 역할 속성을 제공하며, 더불어 FORCE ROW LEVEL SECURITY 스위치로 소유자의 우회권을 박탈한다. 소유자가 일반 데이터 사용자이기도 하거나 논리 복제·pg_dump 정확성을 위할 때 중요하다.

마지막으로, 구현은 술어를 어디서 강제할지 결정해야 한다. Oracle VPD와 PostgreSQL 모두 파스/리라이트 시점에 주입하여 옵티마이저가 술어를 보고 계획을 세울 수 있게 한다. 이 방식은 유효 술어가 바뀔 때(역할, 세션 설정) 플랜 무효화를 요구한다. SQL Server의 술어 함수도 마찬가지로 쿼리 플랜에 통합된다. 비용 기반 옵티마이저가 보안 자격 조건을 일반 술어처럼 — leakproofness 규칙이 배리어를 유지하는 조건 아래 — 다룰 수 있으므로 리라이트 시점 방식이 지배적이다.

flowchart TB
  subgraph Author["DDL — 정책 작성"]
    CP["CREATE POLICY p ON t<br/>FOR cmd TO roles<br/>USING (qual)<br/>WITH CHECK (wc)"]
    AT["ALTER TABLE t<br/>ENABLE / FORCE<br/>ROW LEVEL SECURITY"]
  end
  subgraph Cat["시스템 카탈로그"]
    PGP["pg_policy 행<br/>polrelid, polcmd, polpermissive,<br/>polroles, polqual, polwithcheck"]
    PGC["pg_class 플래그<br/>relrowsecurity / relforcerowsecurity"]
  end
  subgraph RW["쿼리 리라이트"]
    GRSP["get_row_security_policies()<br/>릴레이션 RTE별 호출"]
    SQ["securityQuals<br/>USING -> 가시성"]
    WCO["withCheckOptions<br/>WITH CHECK -> 쓰기 검사"]
  end
  CP --> PGP
  AT --> PGC
  PGP --> GRSP
  PGC --> GRSP
  GRSP --> SQ
  GRSP --> WCO
  SQ --> PLAN["플래너 + 이그제큐터<br/>(보안 배리어 스캔)"]
  WCO --> EXEC["이그제큐터<br/>ExecWithCheckOptions"]

PostgreSQL의 RLS는 pg_policy 위의 얇은 DDL 표면과 리라이트 시점 자격 조건 주입 패스다. 메커니즘은 세 부분으로 나뉜다. 카탈로그 표현, 쿼리별 활성화 결정, 그리고 자격 조건 구성 논리다. 인접 장치 — securityQuals가 플래너 안에서 보안 배리어 서브쿼리가 되는 방식, 나머지 DDL 실행이 relrowsecurity를 설정하는 방식 — 는 postgres-rewriter.mdpostgres-ddl-execution.md에서 다룬다. 이 문서는 정책 카탈로그와 rowsecurity.c 주입에 집중한다.

카탈로그 표현: pg_policy와 릴캐시 디스크립터

섹션 제목: “카탈로그 표현: pg_policy와 릴캐시 디스크립터”

정책 하나는 pg_policy의 튜플 하나다. 릴레이션 OID, 단일 문자 명령 클래스, 허용 플래그, 역할 배열, 두 자격 조건이 pg_node_tree로 직렬화되어 저장된다.

// FormData_pg_policy — src/include/catalog/pg_policy.h
CATALOG(pg_policy,3256,PolicyRelationId)
{
Oid oid;
NameData polname; /* Policy name. */
Oid polrelid BKI_LOOKUP(pg_class); /* relation with policy */
char polcmd; /* One of ACL_*_CHR, or '*' for all */
bool polpermissive; /* restrictive or permissive policy */
#ifdef CATALOG_VARLEN
Oid polroles[1] BKI_LOOKUP_OPT(pg_authid) BKI_FORCE_NOT_NULL;
pg_node_tree polqual; /* Policy quals. (USING) */
pg_node_tree polwithcheck; /* WITH CHECK quals. */
#endif
} FormData_pg_policy;

polcmd는 권한 시스템과 같은 단일 문자 인코딩을 쓴다. ACL_SELECT_CHR = r, ACL_INSERT_CHR = a, ACL_UPDATE_CHR = w, ACL_DELETE_CHR = d, 그리고 ALL 정책을 위한 '*'다. (polrelid, polname) 유니크 인덱스가 테이블별 이름 유일성을 강제하며, 릴캐시 로더에 결정적 이름 순서를 제공한다.

pg_class.relrowsecurity가 설정된 릴레이션을 처음 접촉할 때 릴캐시는 pg_policy를 스캔하고 각 자격 조건을 표현 트리로 역직렬화하여 RowSecurityDesc를 구성한다. 디스크립터는 RowSecurityPolicy 구조체의 단순 리스트다.

// RowSecurityPolicy — src/include/rewrite/rowsecurity.h
typedef struct RowSecurityPolicy
{
char *policy_name;
char polcmd; /* command this policy is for */
ArrayType *roles; /* roles policy applies to */
bool permissive; /* permissive vs restrictive */
Expr *qual; /* USING expression (filter rows) */
Expr *with_check_qual; /* WITH CHECK expression (limit rows) */
bool hassublinks; /* either expression has sublinks */
} RowSecurityPolicy;

파싱된 표현식을 릴캐시에 캐시해 두는 덕분에 리라이트 시점 비용이 낮다. get_row_security_policies는 쿼리마다 카탈로그 텍스트를 다시 파싱하지 않고 이미 역직렬화된 트리를 복사한다.

활성화 결정: check_enable_rls와 세 가지 상태

섹션 제목: “활성화 결정: check_enable_rls와 세 가지 상태”

쿼리에서 릴레이션, 역할의 조합에 RLS가 적용되는지는 정적 속성이 아니다. row_security GUC와 현재 역할에 따라 달라진다. check_enable_rls는 이 모든 것을 세 값(RLS_NONE, RLS_NONE_ENV, RLS_ENABLED) 중 하나로 압축한다.

// check_enable_rls — src/backend/utils/misc/rls.c (condensed)
relrowsecurity = classform->relrowsecurity;
relforcerowsecurity = classform->relforcerowsecurity;
/* Nothing to do if the relation does not have RLS */
if (!relrowsecurity)
return RLS_NONE;
/* BYPASSRLS (and superuser) always bypass; depends on role -> _ENV */
if (has_bypassrls_privilege(user_id))
return RLS_NONE_ENV;
/* Owner bypasses, unless FORCE RLS and not an RI check */
amowner = object_ownercheck(RelationRelationId, relid, user_id);
if (amowner)
{
if (!relforcerowsecurity || InNoForceRLSOperation())
return RLS_NONE_ENV;
}
/* RLS applies; user may have turned off the GUC to get an error instead */
if (!row_security && !noError)
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
errmsg("query would be affected by row-level security policy ...")));
return RLS_ENABLED;

RLS_NONERLS_NONE_ENV의 구분이 플랜 캐시 정확성의 핵심이다. RLS_NONE은 RLS가 구조적으로 부재한다는 뜻이다. 테이블에 relrowsecurity가 없으므로 캐시된 플랜은 RLS 때문에 재검토할 필요가 없다. RLS_NONE_ENV는 RLS가 테이블에 있지만 지금 이 순간 요청자의 신원 때문에 우회된다는 뜻이다. 역할(SET ROLE)이나 row_security GUC가 바뀌면 답이 뒤집힌다. 따라서 자격 조건이 추가되지 않더라도 리라이터는 환경이 바뀔 때 재수립을 강제하기 위해 hasRowSecurity = true를 설정한다.

// get_row_security_policies — src/backend/rewrite/rowsecurity.c
if (rls_status == RLS_NONE_ENV)
{
/* may involve RLS later; force replan if env changes, add nothing now */
*hasRowSecurity = true;
return;
}

row_security GUC는 세 번째, 의도적인 동작을 가진다. 기본값 on인 불리언이다. off로 설정해도 일반 사용자에게는 RLS가 조용히 비활성화되지 않는다. 그랬다면 보안 구멍이 생긴다. 대신 check_enable_rls가 쿼리가 필터를 받았을 경우에 오류를 발생시킨다. row_security = off가 실제로 RLS를 건너뛰게 하는 주체는 어쨌거나 우회하는 자들 뿐이다. 소유자(FORCE 없이), BYPASSRLS, 슈퍼유저는 GUC 검사 전에 RLS_NONE_ENV에 도달하기 때문이다. pg_dump --enable-row-security=off가 이 동작에 의존한다. 덤프는 모든 행을 보거나 크게 실패해야 하며, 조용한 부분 집합을 받아선 안 된다.

flowchart TD
  S["check_enable_rls(relid, role)"] --> A{relrowsecurity<br/>테이블에 설정?}
  A -- no --> N1["RLS_NONE<br/>구조적 부재"]
  A -- yes --> B{BYPASSRLS 또는<br/>슈퍼유저?}
  B -- yes --> NE1["RLS_NONE_ENV<br/>역할 의존"]
  B -- no --> C{테이블 소유자?}
  C -- yes --> D{FORCE RLS 설정<br/>and RI 검사 아님?}
  D -- no --> NE2["RLS_NONE_ENV<br/>소유자 우회"]
  D -- yes --> E
  C -- no --> E{row_security<br/>GUC = on?}
  E -- on --> EN["RLS_ENABLED<br/>자격 조건 주입"]
  E -- off --> ERR["ERROR:<br/>쿼리가 RLS 정책의<br/>영향을 받음"]

자격 조건 구성: USING, WITH CHECK, 허용, 제한

섹션 제목: “자격 조건 구성: USING, WITH CHECK, 허용, 제한”

get_row_security_policies는 릴레이션 range-table 항목마다 한 번 호출된다. 명령 유형을 결정하고 — 대상 릴레이션은 쿼리의 명령 유형을 받고, 나머지 릴레이션은 SELECT 소스로 처리된다 — get_policies_for_relation으로 일치하는 허용·제한 정책을 가져와 두 출력 목록으로 라우팅한다. securityQuals(가시성 필터)와 withCheckOptions(쓰기 검사)다. 결합 규칙은 OR-of-permissive / AND-of-restrictive 관례를 구체적으로 실현한다.

// add_security_quals — src/backend/rewrite/rowsecurity.c (condensed)
foreach(item, permissive_policies)
{
RowSecurityPolicy *policy = lfirst(item);
if (policy->qual != NULL)
permissive_quals = lappend(permissive_quals, copyObject(policy->qual));
}
if (permissive_quals != NIL)
{
/* restrictive USING quals: each appended -> implicit AND */
foreach(item, restrictive_policies) { ... list_append_unique(securityQuals, qual); }
/* all permissive USING quals: OR'd into one expression */
if (list_length(permissive_quals) == 1)
rowsec_expr = linitial(permissive_quals);
else
rowsec_expr = makeBoolExpr(OR_EXPR, permissive_quals, -1);
*securityQuals = list_append_unique(*securityQuals, rowsec_expr);
}
else
/* no permissive policy -> default deny: a single FALSE */
*securityQuals = lappend(*securityQuals,
makeConst(BOOLOID, -1, InvalidOid, sizeof(bool),
BoolGetDatum(false), false, true));

이 코드에서 세 가지 속성이 도출된다. 첫째, 기본 거부: RLS가 활성화되어 있지만 역할과 명령에 일치하는 허용 정책이 없으면 유일한 자격 조건이 FALSE이므로 스캔이 아무것도 반환하지 않는다. 둘째, 허용 정책의 OR 결합: PERMISSIVE 정책 여러 개는 접근을 넓힌다. 어떤 허용 USING이라도 참이면 행이 가시적이다. 셋째, 제한 정책의 AND 결합: 모든 RESTRICTIVE 정책은 별도 연언으로 반드시 성립해야 하므로 제한 정책은 허용 집합이 부여한 것을 좁힐 수만 있다. 각 자격 조건은 대상 RTE의 range-table 인덱스에 맞게 Var 참조를 재작성하는 ChangeVarNodes를 거친 뒤 붙여진다.

쓰기 경로 add_with_check_options는 구조적으로 대칭이지만 이그제큐터가 각 제안된 행을 검사하는 WithCheckOption 노드를 내보낸다. 중요한 뉘앙스는 어떤 표현식을 쓰느냐다.

// add_with_check_options — src/backend/rewrite/rowsecurity.c
#define QUAL_FOR_WCO(policy) \
( !force_using && \
(policy)->with_check_qual != NULL ? \
(policy)->with_check_qual : (policy)->qual )

정책이 명시적 WITH CHECK를 제공하면 그 표현식이 쓰기를 관장한다. 그렇지 않으면 USING 표현식이 재사용된다(“WITH CHECK가 USING으로 기본 설정” 규칙). force_using 플래그는 이를 재정의한다. SELECT/ALL 정책을 RETURNING이나 ON CONFLICT DO UPDATE의 가시성 보장만을 목적으로 쓰기 경로에 접을 때 쓴다. 쓰인 행이 가시적으로 남아 RLS가 조용히 행을 삭제하는 대신 오류를 내도록 하기 위해서다. 제한적 WCO는 정책당 하나씩 polname과 함께 내보내므로 위반된 제한적 쓰기 검사는 오류에서 위반 정책을 명시한다. OR 결합된 허용 WCO는 이름이 없다(실패가 “특정 위반”이 아닌 “아무 정책도 이 쓰기를 허가하지 않음”을 의미하기 때문이다).

DDL 측: CREATE POLICY가 자격 조건을 pg_node_tree로 파싱

섹션 제목: “DDL 측: CREATE POLICY가 자격 조건을 pg_node_tree로 파싱”

CreatePolicy(policy.c)는 리라이트 시점 주입의 명령 측 대응이다. 명령/절 조합을 검증하고, 역할 목록을 해석하고, USINGWITH CHECK 표현식을 대상 테이블의 range table에 맞게 파싱한 뒤 pg_policy에 직렬화한다.

// CreatePolicy — src/backend/commands/policy.c (condensed)
polcmd = parse_policy_command(stmt->cmd_name);
/* SELECT and DELETE forbid WITH CHECK; INSERT forbids USING */
if ((polcmd == ACL_SELECT_CHR || polcmd == ACL_DELETE_CHR)
&& stmt->with_check != NULL)
ereport(ERROR, ... "WITH CHECK cannot be applied to SELECT or DELETE");
if (polcmd == ACL_INSERT_CHR && stmt->qual != NULL)
ereport(ERROR, ... "only WITH CHECK expression allowed for INSERT");
/* parse each clause against the target relation's RTE */
qual = transformWhereClause(qual_pstate, stmt->qual, EXPR_KIND_POLICY, "POLICY");
with_check_qual = transformWhereClause(with_check_pstate, stmt->with_check,
EXPR_KIND_POLICY, "POLICY");
...
if (qual)
values[Anum_pg_policy_polqual - 1] = CStringGetTextDatum(nodeToString(qual));
else
isnull[Anum_pg_policy_polqual - 1] = true;

두 절 형태 규칙은 문법이 아닌 이 코드에서 강제된다. SELECT·DELETE 정책은 WITH CHECK를 가질 수 없고(검사할 새 행이 없다), INSERT 정책은 WITH CHECK만 가질 수 있다(필터할 기존 행이 없다). 표현식은 분석 후 파스 트리의 텍스트 nodeToString 형태로 저장된다. RelationBuildRowSecurity가 나중에 stringToNode에 다시 넣는 동일한 pg_node_tree 표현이다. CreatePolicy는 테이블에 AccessExclusiveLock을 잡고(소유권 검사도 수행하는 RangeVarCallbackForPolicy 경로로), 테이블에 대한 AUTO 의존성과 자격 조건이 참조하는 모든 객체에 NORMAL 의존성을 기록하며, CacheInvalidateRelcache로 마무리하여 다음 접촉 시 RowSecurityDesc가 재구성되게 한다.

디스크립터 로딩: RelationBuildRowSecurity

섹션 제목: “디스크립터 로딩: RelationBuildRowSecurity”

카탈로그 튜플에서 캐시된 RowSecurityPolicy 리스트로 가는 다리가 RelationBuildRowSecurity다. 무효화 후 relrowsecurity가 설정된 릴레이션을 처음 열 때 릴캐시가 지연 호출한다.

// RelationBuildRowSecurity — src/backend/commands/policy.c (condensed)
/* scan pg_policy by the (polrelid, polname) index -> deterministic name order */
sscan = systable_beginscan(catalog, PolicyPolrelidPolnameIndexId, true,
NULL, 1, &skey);
while (HeapTupleIsValid(tuple = systable_getnext(sscan)))
{
Form_pg_policy policy_form = (Form_pg_policy) GETSTRUCT(tuple);
policy->polcmd = policy_form->polcmd;
policy->permissive = policy_form->polpermissive;
policy->policy_name = MemoryContextStrdup(rscxt, NameStr(policy_form->polname));
...
/* deserialize the stored pg_node_tree back into an Expr */
str_value = TextDatumGetCString(datum);
policy->qual = (Expr *) stringToNode(str_value);
...
policy->hassublinks = checkExprHasSubLink((Node *) policy->qual) ||
checkExprHasSubLink((Node *) policy->with_check_qual);
/* list built in reverse via lcons; the index gives name order */
rsdesc->policies = lcons(policy, rsdesc->policies);
}

디스크립터 전체가 CacheMemoryContext 아래에 재부모된 전용 메모리 컨텍스트에 살기 때문에 릴캐시 플러시 한 번으로 해제된다. hassublinks 플래그를 여기서 미리 계산해 두면 리라이터가 트리를 다시 순회하지 않고도 쿼리의 hasSubLinks에 OR할 수 있다. 스캔이 PolicyPolrelidPolnameIndexId를 쓰기 때문에 get_policies_for_relation은 허용 정책을 정렬하지 않고도 안정적인 릴레이션별 이름 순서에 의존할 수 있다(결정적 오류 순서가 필요한 제한 집합만 정렬한다).

역할 매칭: PUBLIC 빠른 경로와 역할 상속

섹션 제목: “역할 매칭: PUBLIC 빠른 경로와 역할 상속”

check_role_for_policy는 정책의 polroles 배열이 요청 사용자에게 적용되는지 결정한다. PUBLIC 단축과 has_privs_of_role(동일성이 아닌 멤버십) 사용이 핵심이다.

// check_role_for_policy — src/backend/rewrite/rowsecurity.c
Oid *roles = (Oid *) ARR_DATA_PTR(policy_roles);
/* Quick fall-thru for policies applied to all roles */
if (roles[0] == ACL_ID_PUBLIC)
return true;
for (i = 0; i < ARR_DIMS(policy_roles)[0]; i++)
{
if (has_privs_of_role(user_id, roles[i]))
return true;
}
return false;

검사가 user_id == roles[i]가 아닌 has_privs_of_role이기 때문에, TO managers로 부여된 정책은 managers 역할을 상속하는 누구에게나 적용된다. SQL 권한 검사가 역할 멤버십을 해석하는 방식과 동일하다. policy_role_list_to_array(DDL 측)는 빈 역할 목록이나 명시적 PUBLIC을 이 빠른 경로가 키를 삼는 단일 요소 {ACL_ID_PUBLIC} 배열로 접고, PUBLIC이 명명된 역할과 혼합되면 경고한다(PUBLIC은 이미 모두를 포함하기 때문이다).

get_policies_for_relation이 라우팅 핵심이다. 주어진 명령 유형을 기준으로 캐시된 디스크립터를 순회하며 모든 ALL('*') 정책과 polcmd가 명령과 일치하는 정책을 허가하고, 역할로 필터링하여 생존자를 허용·제한 목록으로 분리한다. 그 뒤 두 확장 훅이 기여하는 정책을 덧붙인다.

// get_policies_for_relation — src/backend/rewrite/rowsecurity.c (condensed)
foreach(item, relation->rd_rsdesc->policies)
{
RowSecurityPolicy *policy = (RowSecurityPolicy *) lfirst(item);
bool cmd_matches = false;
if (policy->polcmd == '*') /* ALL policy: always applies */
cmd_matches = true;
else switch (cmd) {
case CMD_SELECT: cmd_matches = (policy->polcmd == ACL_SELECT_CHR); break;
case CMD_UPDATE: cmd_matches = (policy->polcmd == ACL_UPDATE_CHR); break;
... /* INSERT, DELETE; MERGE derives from the others, no own policy */
}
if (cmd_matches && check_role_for_policy(policy->roles, user_id))
{
if (policy->permissive)
*permissive_policies = lappend(*permissive_policies, policy);
else
*restrictive_policies = lappend(*restrictive_policies, policy);
}
}
sort_policies_by_name(*restrictive_policies); /* deterministic WCO order */

CMD_MERGE는 자체 polcmd가 없다. switch의 MERGE case는 의도적으로 비어 있으며, get_row_security_policiesif (commandType == CMD_MERGE) 블록이 UPDATE/DELETE/INSERT/SELECT 정책을 WCO_RLS_MERGE_*_CHECK WithCheckOption으로 조립한다. 사용자가 업데이트·삭제할 수 없는 행은 조용히 건너뛰지 않고 오류를 낸다. row_security_policy_hook_permissive / _restrictive 두 훅은 확장(예: sepgsql 계열 레이블 보안)이 추가 정책을 주입하도록 한다. 훅 제공 제한 정책도 이름 정렬되어 내장 제한 집합 이후에 검사된다.

flowchart TD
  GR["get_row_security_policies(root, rte, rt_index)"] --> CE["check_enable_rls()"]
  CE --> ST{rls_status?}
  ST -- RLS_NONE --> R0["return: 자격 조건 없음"]
  ST -- RLS_NONE_ENV --> R1["hasRowSecurity = true<br/>return: 재수립 강제"]
  ST -- RLS_ENABLED --> CMD{rt_index ==<br/>resultRelation?}
  CMD -- yes --> TC["commandType = 쿼리 cmd"]
  CMD -- no --> SC["commandType = CMD_SELECT"]
  TC --> GP["get_policies_for_relation()<br/>허용 / 제한 분리"]
  SC --> GP
  GP --> AQ["add_security_quals()<br/>USING -> rte->securityQuals"]
  GP --> WC{INSERT / UPDATE /<br/>MERGE?}
  WC -- yes --> AW["add_with_check_options()<br/>WITH CHECK -> withCheckOptions"]
  WC -- no --> DONE
  AQ --> DONE["setRuleCheckAsUser()<br/>hasRowSecurity = true"]
  AW --> DONE

메커니즘은 세 파일로 깔끔하게 나뉜다. 명령 측(policy.c)은 DDL을 pg_policy 튜플로 변환하고 릴캐시 디스크립터를 재구성한다. 활성화 오라클(rls.c)은 “지금 이 역할에게 RLS가 적용되는가”에 답한다. 리라이트 측(rowsecurity.c)은 자격 조건을 주입한다. 아래 심볼에 닻을 내린다.

  • 카탈로그 & 디스크립터. FormData_pg_policy(디스크 위 튜플), RowSecurityPolicy / RowSecurityDesc(캐시 형태), RelationBuildRowSecurity(카탈로그 → 캐시, PolicyPolrelidPolnameIndexId 스캔과 stringToNode 역직렬화 경유), RemovePolicyById(삭제, relrowsecurity가 단순히 “정책 존재”가 아닌 이유에 대한 CacheInvalidateRelcache 참고사항 포함).

  • DDL 실행. CreatePolicy, AlterPolicy, parse_policy_command(절 문자열 → polcmd 문자), policy_role_list_to_array(PUBLIC 접기), RangeVarCallbackForPolicy(AccessExclusiveLock 아래 소유권 + relkind 검사), RemoveRoleFromObjectPolicy(DROP ROLE 정리).

  • 활성화. check_enable_rls(세 값 오라클: relrowsecurityhas_bypassrls_privilegeobject_ownercheck + relforcerowsecurity + InNoForceRLSOperationrow_security GUC), row_security_active(SQL 호출 가능 래퍼, noError = true), CheckEnableRlsResult 열거형(RLS_NONE / RLS_NONE_ENV / RLS_ENABLED).

  • 리라이트 주입. get_row_security_policies(RTE별 드라이버, FOR UPDATE·RETURNING·ON CONFLICT DO UPDATE·MERGE 부분 case 포함), get_policies_for_relation(명령/역할 라우팅 + 훅), add_security_quals(USING → securityQuals, OR-of-permissive / AND-of-restrictive / 기본 거부 FALSE), add_with_check_options(WITH CHECK → WithCheckOption, QUAL_FOR_WCO 매크로와 force_using), check_role_for_policy(ACL_ID_PUBLIC 빠른 경로 + has_privs_of_role), sort_policies_by_name / row_security_policy_cmp.

  • 확장 표면. row_security_policy_hook_permissive / row_security_policy_hook_restrictive(확장이 정책을 기여하도록 설정하는 함수 포인터), WCOKind(WCO_RLS_INSERT_CHECK, WCO_RLS_UPDATE_CHECK, WCO_RLS_CONFLICT_CHECK, WCO_RLS_MERGE_UPDATE_CHECK, WCO_RLS_MERGE_DELETE_CHECK).

위치 힌트 (2026-06-05 기준, REL_18 273fe94):

심볼파일
get_row_security_policiessrc/backend/rewrite/rowsecurity.c97
get_policies_for_relationsrc/backend/rewrite/rowsecurity.c540
sort_policies_by_namesrc/backend/rewrite/rowsecurity.c664
row_security_policy_cmpsrc/backend/rewrite/rowsecurity.c673
add_security_qualssrc/backend/rewrite/rowsecurity.c699
add_with_check_optionssrc/backend/rewrite/rowsecurity.c795
QUAL_FOR_WCO (매크로)src/backend/rewrite/rowsecurity.c808
check_role_for_policysrc/backend/rewrite/rowsecurity.c915
row_security_policy_hook_permissivesrc/backend/rewrite/rowsecurity.c86
check_enable_rlssrc/backend/utils/misc/rls.c52
row_security_activesrc/backend/utils/misc/rls.c136
CheckEnableRlsResult (열거형)src/include/utils/rls.h41
row_security (GUC bool)src/backend/utils/misc/guc_tables.c1696
parse_policy_commandsrc/backend/commands/policy.c107
policy_role_list_to_arraysrc/backend/commands/policy.c136
RelationBuildRowSecuritysrc/backend/commands/policy.c192
RemovePolicyByIdsrc/backend/commands/policy.c331
RemoveRoleFromObjectPolicysrc/backend/commands/policy.c415
CreatePolicysrc/backend/commands/policy.c568
AlterPolicysrc/backend/commands/policy.c767
RangeVarCallbackForPolicysrc/backend/commands/policy.c(static, CreatePolicy 이후)
FormData_pg_policysrc/include/catalog/pg_policy.h(CATALOG 블록)
RowSecurityPolicysrc/include/rewrite/rowsecurity.h(구조체)
  • 정책 하나는 polrelid·단일 문자 polcmd·polpermissive·polroles OID 배열·두 pg_node_tree 컬럼 polqual(USING)과 polwithcheck(WITH CHECK)를 담는 pg_policy 튜플 하나다. 2026-06-05에 pg_policy.hCATALOG(pg_policy,...) 블록을 읽어 확인. polcmdACL_*_CHR 권한 문자(r/a/w/d)와 ALL을 위한 '*'를 재사용한다. policy.cparse_policy_command에서 확인.

  • check_enable_rls는 세 값 결과를 반환하며 우회를 고정 순서로 해석한다: BYPASSRLS/슈퍼유저, 이후 소유자(FORCE가 아니고 RI 검사가 아닌 경우), 이후 row_security GUC. rls.c에서 검증(has_bypassrls_privilegeobject_ownercheck + !relforcerowsecurity || InNoForceRLSOperation()!row_security && !noErrorERRCODE_INSUFFICIENT_PRIVILEGE 발생). 내장 릴레이션(OID < FirstNormalObjectId)은 RLS_NONE으로 단락.

  • RLS_NONERLS_NONE_ENV가 플랜 캐시 경첩이다. get_row_security_policies에서 검증: RLS_NONEhasRowSecurity를 false로 두고 반환, RLS_NONE_ENV는 자격 조건 추가 없이 *hasRowSecurity = true를 설정하고 반환하여 역할이나 GUC 변경 시 재수립을 강제한다. rls.h 열거형 주석도 같은 계약을 명시.

  • 허용 USING 자격 조건은 OR 결합, 제한 자격 조건은 AND 결합, 허용 정책 부재 시 항상 거짓 Const 하나(기본 거부). add_security_quals에서 검증: 제한 자격 조건은 하나씩 추가(묵시적 AND), 허용 자격 조건은 makeBoolExpr(OR_EXPR, ...)로 접히며, else 분기가 makeConst(BOOLOID, ..., BoolGetDatum(false), ...)를 내보낸다. add_with_check_optionsWithCheckOption 노드로 이를 반영.

  • WITH CHECK는 force_using이 재정의하지 않으면 USING으로 기본 설정. QUAL_FOR_WCO 매크로에서 검증: !force_using && with_check_qual != NULL ? with_check_qual : qual. SELECT/ALL 정책은 RETURNING / ON CONFLICT DO UPDATE / MERGE 경로에서 force_using = true로 쓰기 경로에 접혀 비가시 쓰기 행이 조용히 사라지지 않고 오류를 낸다.

  • 제한적 WCO는 polname을 담고, OR 결합된 허용 WCO는 이름이 없다. add_with_check_options에서 검증: 허용 wco->polname = NULL, 각 제한 wco->polname = pstrdup(policy->policy_name)이므로 위반된 제한 쓰기 검사가 오류에서 정책을 명명한다.

  • 역할 매칭은 has_privs_of_role(멤버십)를 쓰며 ACL_ID_PUBLIC 빠른 경로가 있다. check_role_for_policy에서 검증. DDL 측(policy_role_list_to_array)이 빈/PUBLIC 역할 목록을 단일 {ACL_ID_PUBLIC} 요소로 접고 PUBLIC이 명명된 역할과 혼합되면 경고.

  • MERGE는 자체 정책이 없으며 동작이 파생된다. get_policies_for_relation(빈 CMD_MERGE switch case)와 get_row_security_policiesCMD_MERGE 블록에서 검증. UPDATE/DELETE/INSERT/SELECT 정책을 WCO_RLS_MERGE_*_CHECK 검사로 조립.

  • 릴캐시 디스크립터는 pg_policy(polrelid, polname) 인덱스 순서로 스캔하고 각 자격 조건을 stringToNode로 역직렬화해 구성. RelationBuildRowSecurity에서 검증(PolicyPolrelidPolnameIndexId 사용, lcons로 리스트 구성, CacheMemoryContext 아래 컨텍스트 재부모, hassublinks 미리 계산).

  1. 많은 허용 정책의 OR 접기가 플래너에 미치는 비용. 행이 어떤 허용 USING이라도 참이면 가시적이므로 N개 허용 정책은 보안 배리어 자격 조건으로 붙여진 N-way BoolExpr(OR)이 된다. 대규모 N에서 플래너의 선택도 추정과 보안 배리어 서브쿼리 래핑(postgres-rewriter.md)이 어떻게 상호작용하는지, OR 분기의 leakproofness가 분기별로 평가되는지는 rowsecurity.c만으로는 명확하지 않다. 추적 경로: 다중 정책 테이블을 securityQuals에서 expand_security_quals까지 추적.

  2. RLS_NONE_ENV 아래 재수립 빈도. hasRowSecurity = true는 역할/GUC 변경 시 플랜 캐시 재검증을 강제한다. 플랜캐시가 RLS 기반 무효화를 다른 무효화와 어떻게 구분하는지, SET ROLE 왕복이 항상 플랜을 버리는지는 플랜캐시에 있다. 추적 경로: plancache.cCheckCachedPlanhasRowSecurity 플래그와 교차 독해.

  3. 소유자에 대한 FORCE RLS와 참조 무결성 트리거의 상호작용. InNoForceRLSOperation()은 소유자의 RI 검사 중 FORCE RLS를 억제한다. 그 컨텍스트에 진입하는 연산의 정확한 집합과 모든 FK 유지 보수 경로가 그 경로에 도달하는지는 트리거 기계에 정의되어 있다. 추적 경로: ri_triggers.cInNoForceRLSOperation 호출 위치를 열거.

PostgreSQL 너머 — 비교 설계와 연구 프런티어

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프런티어”
  • Oracle VPD(Virtual Private Database). Oracle의 DBMS_RLS.ADD_POLICY는 (테이블, 문장 타입)마다 서버가 문장에 덧붙이는 술어 문자열을 반환하는 PL/SQL 정책 함수를 등록한다. 열 민감 마스킹을 위한 선택적 sec_relevant_cols도 있다. PostgreSQL은 선언적 표현식(함수가 아닌)을 pg_policy에 저장하며 내장 열 마스킹 대응물이 없다. 열 권한과 뷰가 그 축을 담당한다. VPD의 함수-반환-술어 모델과 PostgreSQL의 저장된 pg_node_tree 모델을 나란히 놓으면 유연성(임의 PL/SQL)과 플랜 캐시 가능성(옵티마이저가 추론할 수 있는 고정 파스 트리) 사이의 트레이드오프가 선명해진다.

  • SQL Server 행 수준 보안. SQL Server는 인라인 테이블 반환 술어 함수FILTER 술어(읽기 경로) 또는 BLOCK 술어(쓰기 경로)로 바인딩한다. PostgreSQL이 USING/WITH CHECK로 명명하는 것과 구조적으로 동일한 분리지만 표현식이 아닌 함수로 표현된다. FILTERsecurityQuals, BLOCKwithCheckOptions 매핑이 공유된 설계 공간을 구체화한다.

  • 세밀한 접근 제어를 위한 쿼리 재작성 (Rizvi 등, SIGMOD 2004). *“Extending Query Rewriting Techniques for Fine-Grained Access Control”*은 PostgreSQL이 일반화한 보안 뷰 접근법의 학문적 계보다. 핵심 문제 — 부작용 있는 사용자 함수가 보안 술어가 숨겨야 할 행을 관찰하는 것 — 는 PostgreSQL의 보안 배리어 + leakproofness 장치가 답하는 바로 그 문제다. 리라이트 대 런타임 배치가 동일한 결정이다. 논문의 “인가 투명” 재작성과 expand_security_quals의 관계를 연결하는 집중 노트가 이론과 rowsecurity.c를 잇는다.

  • Bertino & Sandhu, Database Security(IEEE TDSC 2005). 이 설문의 내용 기반 세밀한 접근 제어 분류법과 읽기(가시성)·쓰기(무결성) 술어 분리는 USING/WITH CHECK 이분법 그 자체다. 레이블 기반/강제 접근 제어(MAC)도 다루는데, PostgreSQL은 이를 의도적으로 내장하지 않고 두 정책 훅으로 확장에 위임한다. sepgsql SELinux 레이블 모델이 정식 예다.

  • Leakproofness와 은채널 프런티어. RLS의 보안 보장은 옵티마이저가 leakproof하지 않은 사용자 함수를 보안 자격 조건 아래로 내리지 않는다는 것에 달려 있다. 경계 사례들 — pg_proc.proleakproof 표시, 오류 메시지와 EXPLAIN 비용 부채널, 타이밍 채널 — 은 활성 강화 영역이다. PostgreSQL이 포스트 필터가 아닌 보안 배리어로 술어를 만드는 선택이 구조적 방어다. 잔여 채널 정량화는 연구 수준 과제다.

  • CUBRID와 행 수준 접근 제어. CUBRID에는 pg_policy에 상응하는 일급 RLS 기능이 없다. 동등한 기밀성을 갱신 가능 뷰와 GRANT로 구현한다. 두 설계를 비교하면 리라이트 시점 자격 조건 주입이 수동 보안 뷰 패턴 대비 무엇을 주는지 — 주로 우회 불가(사용자가 강제되지 않는 뷰를 우회할 수 없음)와 기본 거부 — 가 드러난다. (cubrid 트리의 CUBRID 분석 참조.)

트리 내 소스 파일 (REL_18_STABLE, commit 273fe94)

섹션 제목: “트리 내 소스 파일 (REL_18_STABLE, commit 273fe94)”
  • src/backend/rewrite/rowsecurity.c — 리라이트 시점 주입: get_row_security_policies, get_policies_for_relation, add_security_quals, add_with_check_options, check_role_for_policy, 두 확장 훅, QUAL_FOR_WCO 매크로.
  • src/backend/commands/policy.c — DDL 측: CreatePolicy, AlterPolicy, RemovePolicyById, RelationBuildRowSecurity(릴캐시 로더), parse_policy_command, policy_role_list_to_array, RangeVarCallbackForPolicy, RemoveRoleFromObjectPolicy.
  • src/backend/utils/misc/rls.c — 활성화 오라클: check_enable_rls, row_security_active.
  • src/include/utils/rls.hCheckEnableRlsResult 열거형(RLS_NONE / RLS_NONE_ENV / RLS_ENABLED)과 row_security GUC extern.
  • src/include/catalog/pg_policy.hFormData_pg_policy, 카탈로그 스키마와 polcmd 인코딩.
  • src/include/rewrite/rowsecurity.hRowSecurityPolicy, RowSecurityDesc, row_security_policy_hook_type typedef.
  • src/backend/utils/misc/guc_tables.crow_security 불리언 GUC(PGC_USERSET, 기본 on).
  • Rizvi, S., Mendelzon, A., Sudarshan, S. & Roy, P. (2004). “Extending Query Rewriting Techniques for Fine-Grained Access Control.” SIGMOD 2004. RLS가 일반화하는 접근 제어를 위한 쿼리 재작성 계보.
  • Bertino, E. & Sandhu, R. (2005). “Database Security — Concepts, Approaches, and Challenges.” IEEE TDSC 2(1):2-19. 세밀한/내용 기반 접근 제어와 읽기/쓰기 술어 분리의 분류법.
  • Database System Concepts(Silberschatz, Korth, Sudarshan, 7판), ch. 4 “Intermediate SQL”(인가, GRANT/REVOKE)과 ch. 9(애플리케이션 설계와 보안) — RLS가 행 단위로 확장하는 테이블/열 단위 DAC 기준선. (knowledge/research/dbms-general/.)

인접 문서 (교차 참조 — 해당 메커니즘은 거기서 소유, 여기서 중복하지 않음)

섹션 제목: “인접 문서 (교차 참조 — 해당 메커니즘은 거기서 소유, 여기서 중복하지 않음)”
  • postgres-rewriter.mdfireRIRrules(get_row_security_policies의 호출자)와 rte->securityQualsexpand_security_quals / preprocess_rowmarks 안에서 보안 배리어 서브쿼리로 변환되는 방식. leakproofness / 자격 조건 푸시다운 규칙이 거기 있다.
  • postgres-ddl-execution.mdALTER TABLE ... ENABLE / FORCE ROW LEVEL SECURITYpg_class.relrowsecurity / relforcerowsecurity를 설정하는 방식과 ProcessUtilityCreatePolicy / AlterPolicy 디스패치.
  • postgres-executor.mdExecWithCheckOptions: 이 모듈이 구성하는 WithCheckOption 노드를 쓰인 행마다 평가.
  • postgres-relcache.mdRelationBuildRowSecurityrd_rsdesc 디스크립터를 구동하는 릴캐시 수명 주기와 무효화.
  • postgres-system-catalogs.md — 접근 제어 카탈로그(pg_authid, pg_class 플래그, pg_default_acl) 중 pg_policy.