행 수준 보안이 조용히 뚫리는 다섯 가지 경우

행 수준 보안은 깜빡한 WHERE 절에 대한 최고의 방어책입니다. 조용히 적용을 멈추기 전까지는 그렇습니다. PostgreSQL 17에서 행 수준 보안이 뚫리는 다섯 가지 경우를 재현 방법, 해결책, CI에서 잡아내는 카탈로그 쿼리와 함께 소개합니다.

Zhan Lu읽는 시간 12분
이 페이지의 내용

행 수준 보안(RLS)은 Postgres가 제공하는, 깜빡한 WHERE user_id = …에 대한 최고의 방어책입니다. 하지만 모르는 사이에 꺼지기도 쉽습니다. 속성 하나를 가진 역할, 잘못된 사용자가 만든 뷰, NULL이 아니라 빈 문자열로 돌아오는 설정이 그 예입니다. 이 글에서는 RLS가 조용히 데이터를 보호하지 못하게 되는 다섯 가지 경우를 살펴봅니다. 각각 psql에 붙여 넣어 실행할 수 있는 재현 방법과 해결책을 함께 싣고, 마지막에는 이 중 많은 경우를 CI에서 잡아내는 카탈로그 쿼리 하나를 소개합니다. 모든 코드는 Relyt One이 사용하는 것과 같은 메이저 버전인 PostgreSQL 17.11에서 실행했습니다.

Relyt One 관리형 인증 시작하기를 따라 했다면, 여기서 테스트하는 패턴을 이미 쓰고 있는 것입니다. 앱이 각 트랜잭션의 시작에서 검증된 토큰으로 app.user_id를 설정하고, 정책이 각 행의 소유자를 그 값과 비교하는 패턴입니다. 이 패턴은 건전합니다. 아래의 실패는 Postgres의 버그가 아닙니다. 이 패턴이 조용히 적용되지 않게 되는 지점이며, 모두 같은 방식으로 실패합니다. 오류도 로그도 없이, 보면 안 되는 사람에게 행이 전달될 뿐입니다.

하나의 정책이 적용되지 않는 다섯 가지 경우: 관문을 건너뛰거나(RLS를 우회하는 역할, FORCE가 없는 소유자, 뷰와 SECURITY DEFINER 함수) 엉뚱한 행을 통과시킵니다(비었거나 오래된 설정, 더 넓은 정책 또는 RLS가 없는 테이블)

준비

아래의 모든 내용은 슈퍼유저로 연결한 psql 세션 하나에서 실행하며, SET ROLE로 신원을 바꿉니다. 객체(테이블, 정책, 뷰)를 만들거나 변경하는 문장은 demo_owner 또는 슈퍼유저로 실행하고, 애플리케이션으로서 동작하는 문장은 SET ROLE demo_app으로 시작합니다. 실제 프로젝트에 있는 것을 대신하는 세 가지 역할이 있습니다.

  • demo_owner는 테이블을 소유하고 마이그레이션을 실행합니다.
  • demo_app은 애플리케이션이 연결할 때 쓰는 역할이며 NOBYPASSRLS입니다.
  • demo_admin에는 BYPASSRLS가 있습니다. Relyt One에서는 모든 프로젝트가 처음부터 가지고 있는 소유자 역할인 relytdb_owner가 이에 해당합니다.
CREATE ROLE demo_owner NOLOGIN;
CREATE ROLE demo_app   NOLOGIN;
CREATE ROLE demo_admin NOLOGIN BYPASSRLS;
CREATE SCHEMA demo AUTHORIZATION demo_owner;
GRANT USAGE ON SCHEMA demo TO demo_app;
GRANT USAGE, CREATE ON SCHEMA demo TO demo_admin;

테이블 하나, 정책 하나, 행 네 개입니다. 마지막 행은 소유자가 비어 있는데, 이유는 4번 항목에서 알 수 있습니다.

SET ROLE demo_owner;
CREATE TABLE demo.notes (id serial PRIMARY KEY, user_id text NOT NULL, body text NOT NULL);
INSERT INTO demo.notes (user_id, body)
  VALUES ('alice', 'a1'), ('alice', 'a2'), ('bob', 'b1'), ('', 'orphan');

ALTER TABLE demo.notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE demo.notes FORCE ROW LEVEL SECURITY;
CREATE POLICY own ON demo.notes FOR ALL
  USING      (user_id = current_setting('app.user_id', true))
  WITH CHECK (user_id = current_setting('app.user_id', true));
RESET ROLE;

GRANT SELECT, INSERT, UPDATE, DELETE ON demo.notes TO demo_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA demo TO demo_app;
GRANT SELECT ON demo.notes TO demo_admin;

애플리케이션이 보내는 평범한 요청은 이렇습니다.

SET ROLE demo_app;
BEGIN;
SELECT set_config('app.user_id', 'alice', true);  -- true: 이 트랜잭션에만 적용
SELECT body FROM demo.notes;                      -- a1, a2
COMMIT;

Alice는 자신의 행 두 개를 봅니다. 이제 이것을 깨뜨려 보겠습니다.

1. RLS를 아예 건너뛰는 역할

PostgreSQL 문서는 단호합니다. (PostgreSQL 문서는 영어로 되어 있어, 이 글에서 인용한 문장은 한국어로 옮긴 것입니다.) "슈퍼유저와 BYPASSRLS 속성을 가진 역할은 테이블에 접근할 때 항상 행 보안 시스템을 우회합니다." 정책은 아예 참조되지 않습니다.

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes;                       -- 2   (정책이 적용됨)
COMMIT;
SET ROLE demo_admin; SELECT count(*) FROM demo.notes;  -- 4   (BYPASSRLS: 모든 행)
RESET ROLE;          SELECT count(*) FROM demo.notes;  -- 4   (슈퍼유저: 모든 행)

이 속성은 역할에 속하며, 그 역할의 멤버에 속하지 않습니다. demo_app이 demo_admin의 멤버여도 어떤 코드가 SET ROLE demo_admin을 실행하기 전까지는 필터링됩니다.

GRANT demo_admin TO demo_app;
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes;                       -- 2   (멤버십만으로는 우회하지 않음)
COMMIT;
SET ROLE demo_admin; SELECT count(*) FROM demo.notes;  -- 4

Relyt One에서는 이 문제가 들리는 것보다 더 중요합니다. 콘솔에서 만든 데이터베이스 사용자에게는 BYPASSRLS가 있고, relytdb_owner도 마찬가지입니다. 실제 브랜치에서 확인했습니다. FORCE와 올바른 정책이 설정된 테이블을 relytdb_owner로 조회하자 모든 행이 반환되었습니다. 이런 역할 중 하나로 연결하는 앱에는 행 수준 보안이 전혀 없습니다. SQL로 전용 역할을 만들고 그 역할로 연결하세요. 관리형 인증 가이드에 방법이 나와 있으며, 이렇게 만든 역할이 직접 연결 문자열과 풀링 연결 문자열 모두로 로그인되는 것을 확인했습니다.

테스트: 애플리케이션이 연결하는 역할로 다음을 실행하세요. 반드시 false를 반환해야 합니다.

SET ROLE demo_app;
SELECT rolsuper OR rolbypassrls AS bypasses_rls FROM pg_roles WHERE rolname = current_user;  -- f

2. FORCE가 없는 소유자

기본적으로 "테이블 소유자는 보통 행 보안도 우회하지만, 소유자가 ALTER TABLE ... FORCE ROW LEVEL SECURITY로 행 보안의 적용을 받도록 선택할 수 있습니다." ENABLE만으로는 테이블을 소유한 역할을 제외한 모두가 보호됩니다.

SET ROLE demo_owner;
CREATE TABLE demo.notes2 (id serial PRIMARY KEY, user_id text NOT NULL, body text NOT NULL);
INSERT INTO demo.notes2 (user_id, body) VALUES ('alice', 'x'), ('bob', 'y');
ALTER TABLE demo.notes2 ENABLE ROW LEVEL SECURITY;          -- 활성화만 하고 강제하지 않음
CREATE POLICY own ON demo.notes2 FOR ALL
  USING      (user_id = current_setting('app.user_id', true))
  WITH CHECK (user_id = current_setting('app.user_id', true));

SELECT count(*) FROM demo.notes2;   -- 2  (alice와 bob의 행: 정책을 건너뜀)

ALTER TABLE demo.notes2 FORCE ROW LEVEL SECURITY;
SELECT count(*) FROM demo.notes2;   -- 0  (이제 소유자도 정책의 적용을 받음)
RESET ROLE;

마이그레이션 도구와 애플리케이션이 역할 하나를 같이 쓴다면, 그 역할이 소유자이므로 애플리케이션이 RLS를 우회합니다. 사용자 데이터가 들어 있는 모든 테이블에 FORCE를 쓰고, 소유자는 요청 처리 경로에서 빼세요. Relyt One에서는 소유자에게 BYPASSRLS가 있어 FORCE가 도움이 되지 않으므로, 해결책은 1번 항목입니다.

3. 다른 사람의 권한으로 실행되는 코드

뷰는 소유자의 권한으로 실행됩니다. CREATE VIEW 문서는 이렇게 말합니다. "기본적으로 뷰가 참조하는 기반 릴레이션에 대한 접근은 뷰 소유자의 권한으로 결정됩니다." 행 수준 보안 정책도 마찬가지입니다. SECURITY DEFINER 함수는 함수 소유자의 신원으로 같은 일을 합니다. 둘 다 유용하지만, 소유자가 RLS를 우회한다면 둘 다 RLS가 막으려던 바로 그것이 됩니다.

notes2를 다시 사용해 봅시다. "활성화만 하고 강제하지 않음" 상태로 되돌리고, 그 위에 뷰와 definer 함수를 만듭니다. 둘 다 demo_owner가 소유하며, alice의 행을 요청합니다.

ALTER TABLE demo.notes2 NO FORCE ROW LEVEL SECURITY;
GRANT SELECT ON demo.notes2 TO demo_app, demo_admin;

SET ROLE demo_owner;
CREATE VIEW demo.v_notes2 AS SELECT * FROM demo.notes2;
CREATE FUNCTION demo.count_notes2() RETURNS bigint
  LANGUAGE sql SECURITY DEFINER SET search_path = demo, pg_temp
  AS $$ SELECT count(*) FROM demo.notes2 $$;
RESET ROLE;
GRANT SELECT ON demo.v_notes2 TO demo_app;
GRANT EXECUTE ON FUNCTION demo.count_notes2() TO demo_app;

SET ROLE demo_app;
BEGIN;
SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes2;     -- 1
SELECT count(*) FROM demo.v_notes2;   -- 2   유출
SELECT demo.count_notes2();           -- 2   유출
COMMIT;

테이블에 FORCE를 추가하면 소유자도 정책의 적용을 받으므로 두 구멍이 모두 막힙니다. 하지만 소유자 자체가 RLS를 우회한다면 FORCE는 아무 효과가 없습니다. demo_admin(relytdb_owner를 대신하는 역할)으로 뷰를 만들면 FORCE된 테이블에서도 유출됩니다.

alice의 쿼리가 실행되는 위치보이는 행 수
테이블에서 직접1
테이블 소유자가 소유한 뷰를 통해(테이블에 FORCE 없음)2
SECURITY DEFINER 함수를 통해(테이블에 FORCE 없음)2
FORCE 후 같은 뷰와 함수1
BYPASSRLS 역할이 소유한 뷰를 통해, 테이블에 FORCE 있음2
같은 뷰에 security_invoker = true1

마지막 두 행을 확인하려면 demo_admin으로 뷰를 만들고 테이블에 FORCE를 설정하세요.

RESET ROLE;
ALTER TABLE demo.notes2 FORCE ROW LEVEL SECURITY;
SET ROLE demo_admin;
CREATE VIEW demo.v_admin AS SELECT * FROM demo.notes2;     -- BYPASSRLS 역할이 소유함
RESET ROLE;
GRANT SELECT ON demo.v_admin TO demo_app;

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.v_admin;    -- 2   테이블에 FORCE가 있어도 유출
COMMIT;

진짜 해결책은 PostgreSQL 15에서 추가된 security_invoker입니다("뷰를 통한 테이블 접근을 뷰 호출자의 권한으로 제어할 수 있도록 허용"). 이 옵션을 쓰면 쿼리를 실행하는 사용자의 정책이 적용됩니다.

CREATE VIEW demo.v_safe WITH (security_invoker = true) AS SELECT * FROM demo.notes2;
ALTER VIEW demo.v_admin SET (security_invoker = true);   -- 기존 뷰를 수정함. 이제 alice에게는 1개 행이 보임

사용자 데이터가 있는 테이블에 대한 SECURITY DEFINER 함수는 가능하면 쓰지 마세요. 꼭 써야 한다면 사용자를 인수로 넘기고 함수 안에서 그 값으로 명시적으로 필터링하며, 위 예제처럼 항상 search_path를 고정하세요.

4. 생각과 다른 설정

이 패턴은 current_setting('app.user_id', true)에 의존합니다. 두 번째 인수는 "설정이 없을 때 오류를 내는 대신 NULL을 반환하라"는 뜻이며, 새 세션에서는 실제로 NULL이 반환됩니다. 그러다 트랜잭션이 set_config(…, true)로 값을 설정하고 끝납니다. 다음 쿼리에는 무엇이 보일까요?

위의 요청들이 이미 이 설정을 정의해 두었으므로, 먼저 새 세션을 시작하세요.

-- psql: 다시 연결해 새 세션을 얻음
\c
SET ROLE demo_app;
SELECT coalesce(current_setting('app.user_id', true), '<NULL>');       -- <NULL>

BEGIN; SELECT set_config('app.user_id', 'alice', true); COMMIT;

SELECT '[' || coalesce(current_setting('app.user_id', true), '<NULL>') || ']';  -- []
SELECT user_id || '/' || body FROM demo.notes;                         -- /orphan

NULL이 아니라 빈 문자열입니다. PostgreSQL 15, 16, 17에서 모두 이렇게 동작했습니다. 트랜잭션이 끝난 뒤에도 설정은 빈 값으로 정의된 채 남습니다. 문서에 나와 있는 동작이 아니므로 사용하는 버전에서 직접 확인하세요. user_id = ''를 비교하는 정책은 소유자가 빈 문자열인 모든 행과 일치하며, 우리 테이블에는 그런 행이 하나 있습니다. 이런 행은 버그와 마이그레이션에서 생깁니다. 검증을 빠져나간 빈 sub, 아무도 의도하지 않은 기본값 같은 것들입니다. 가장 노출하고 싶지 않은 행들입니다.

이 항목의 나머지 절반은 세 번째 인수의 실수입니다. false를 쓰면 설정이 세션 전체에 유지되고, 연결 풀을 쓰면 그 연결의 다음 요청이 설정을 그대로 물려받습니다.

SET ROLE demo_app;
SELECT set_config('app.user_id', 'alice', false);   -- 세션 전체에 적용
SELECT count(*) FROM demo.notes;                    -- 2, 이 연결을 다음에 쓰는 누구에게든

해결책은 정책이 빈 설정을 거부하게 하고, 소유자가 빈 행이 생기지 않게 하는 것입니다.

RESET ROLE;
ALTER POLICY own ON demo.notes
  USING      (nullif(current_setting('app.user_id', true), '') IS NOT NULL
              AND user_id = current_setting('app.user_id', true))
  WITH CHECK (nullif(current_setting('app.user_id', true), '') IS NOT NULL
              AND user_id = current_setting('app.user_id', true));

ALTER TABLE demo.notes ADD CONSTRAINT user_id_not_empty CHECK (user_id <> '') NOT VALID;

새 세션에서 확인하세요.

-- psql: 다시 연결해 새 세션을 얻음
\c
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true); COMMIT;
SELECT count(*) FROM demo.notes;                         -- 0   (이제 빈 설정은 어떤 행과도 일치하지 않음)
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes;                         -- 2   (alice의 정상 요청은 여전히 작동함)
COMMIT;

(NOT VALID를 쓰면 빈 행이 남아 있어도 제약 조건을 추가할 수 있습니다. 그 행을 정리한 뒤 VALIDATE CONSTRAINT를 실행하세요.) 데이터 접근 함수가 호출하는 형태는 set_config(…, true) 하나뿐이도록 하세요.

5. 더 넓은 정책, 또는 정책이 아예 없음

정책은 더해집니다. "주어진 쿼리에 여러 정책이 적용되면, 허용 정책(기본값)은 OR로, 제한 정책은 AND로 결합됩니다." 보고서나 지원 도구를 위해 허용 정책을 하나 추가하면 모든 사람의 접근 범위가 넓어집니다.

RESET ROLE;
CREATE POLICY pub ON demo.notes FOR SELECT USING (true);

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes;    -- 4, alice에게 모든 것이 보임
COMMIT;

RESTRICTIVE 정책은 AND로 결합되므로, 나중에 어떤 허용 정책이 추가되더라도 최소한의 바닥선을 지켜 줍니다.

RESET ROLE;
CREATE POLICY tenant_guard ON demo.notes AS RESTRICTIVE FOR SELECT
  USING (nullif(current_setting('app.user_id', true), '') IS NOT NULL
         AND user_id = current_setting('app.user_id', true));

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes;    -- `pub`이 있어도 다시 2
COMMIT;

새 테이블에는 RLS가 없는 상태로 시작합니다. 애플리케이션 역할에 기본 권한까지 부여했다면, 처음 만들고 잊어버린 테이블은 모든 사람이 읽을 수 있습니다.

RESET ROLE;
ALTER DEFAULT PRIVILEGES FOR ROLE demo_owner IN SCHEMA demo GRANT SELECT ON TABLES TO demo_app;

SET ROLE demo_owner;
CREATE TABLE demo.notes_new (user_id text NOT NULL, body text NOT NULL);   -- 아무도 ENABLE ROW LEVEL SECURITY를 실행하지 않음
INSERT INTO demo.notes_new VALUES ('alice', 'n1'), ('bob', 'n2');
RESET ROLE;

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes_new;    -- 2, alice에게 bob의 행도 보임
COMMIT;

반대는 안전합니다. RLS가 활성화되어 있고 정책이 없는 테이블은 "기본 거부"이므로 보이거나 갱신할 수 있는 행이 없습니다.

구멍이 아닌 두 가지

  • USING만 있는 정책. ALL과 UPDATE 정책에서는 "WITH CHECK 표현식이 정의되어 있지 않으면 USING 표현식이" 새 행에도 사용됩니다. INSERT 정책에는 USING 절을 둘 수 없습니다. 실제 Relyt One 브랜치에서 사용자가 다른 사람이 소유한 행을 쓰도록 한 테스트는 정책에 의해 거부되었습니다.
  • 정책이 없는 명령. RLS가 활성화되어 있고 적용되는 정책이 없으면, 문서에 따르면 "기본 거부" 정책이 있는 것으로 간주됩니다. FOR SELECT 정책만 있는 테이블은 삽입을 거부합니다.

RLS가 절대 다루지 않는 두 가지

고유 제약 조건. "고유 제약 조건이나 기본 키 제약 조건, 외래 키 참조 같은 참조 무결성 검사는 데이터 무결성을 유지하기 위해 항상 행 보안을 우회합니다." 문서는 그 결과를 "은닉 채널(covert channel)"이라고 부릅니다. Bob은 alice의 슬러그를 읽을 수 없지만, 그것이 존재한다는 사실은 알아낼 수 있습니다.

SET ROLE demo_owner;
CREATE TABLE demo.slugs (id serial PRIMARY KEY, user_id text NOT NULL, slug text NOT NULL UNIQUE);
INSERT INTO demo.slugs (user_id, slug) VALUES ('alice', 'secret-project');
ALTER TABLE demo.slugs ENABLE ROW LEVEL SECURITY;
ALTER TABLE demo.slugs FORCE ROW LEVEL SECURITY;
CREATE POLICY own ON demo.slugs FOR ALL
  USING      (user_id = current_setting('app.user_id', true))
  WITH CHECK (user_id = current_setting('app.user_id', true));
RESET ROLE;
GRANT SELECT, INSERT ON demo.slugs TO demo_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA demo TO demo_app;

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'bob', true);
SELECT count(*) FROM demo.slugs WHERE slug = 'secret-project';              -- 0
INSERT INTO demo.slugs (user_id, slug) VALUES ('bob', 'secret-project');
-- ERROR:  duplicate key value violates unique constraint "slugs_slug_key"
ROLLBACK;

고유성을 테넌트별로 두면 유출이 사라집니다. bob의 삽입이 성공합니다. 값이 모든 사용자에 걸쳐 고유해야 한다면, 그 값이 존재한다는 사실은 공개된다고 받아들이세요.

RESET ROLE;
ALTER TABLE demo.slugs DROP CONSTRAINT slugs_slug_key, ADD UNIQUE (user_id, slug);

구체화된 뷰. 문서에는 이에 대한 언급이 없으므로 직접 테스트하세요. 구체화된 뷰는 누군가 새로 고친 시점의 행을 저장해 두며, 저장된 행을 읽을 때는 테이블의 정책이 실행되지 않습니다. 우리 테스트에서는 alice의 설정이 활성화된 상태에서 만든 뷰가 alice의 행 두 개를 bob에게 보여 주었습니다.

SET ROLE demo_owner;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
CREATE MATERIALIZED VIEW demo.m_notes AS SELECT * FROM demo.notes;   -- alice의 행을 저장함
COMMIT;
RESET ROLE;
GRANT SELECT ON demo.m_notes TO demo_app;

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'bob', true);
SELECT count(*) FROM demo.m_notes;    -- 2   bob이 alice의 행을 읽음
COMMIT;

사용자 데이터에 대한 구체화된 뷰에는 애플리케이션 역할의 접근 권한을 부여하지 마세요. 뷰에 테넌트 열을 넣고 쿼리에서 그 열로 필터링하거나, 기반 테이블을 읽으세요.

문서에서 한 가지를 더 짚어 둡니다. 정책 표현식은 각 행에 대해 "사용자 쿼리에서 오는 어떤 조건이나 함수보다 먼저" 평가되지만, 예외가 하나 있습니다. "정보를 유출하지 않음이 보장되는 leakproof 함수는 옵티마이저가 행 보안 검사보다 먼저 적용하기로 선택할 수 있습니다." LEAKPROOF 표시는 반드시 지킬 수 있어야 하는 약속으로 다루세요.

CI에서 실행할 수 있는 자체 감사

위 목록의 대부분은 카탈로그에 흔적을 남깁니다. 이 쿼리는 발견 사항마다 한 행을 반환합니다. public 스키마를 살펴보며(다른 스키마는 이름을 바꾸세요), 애플리케이션이 연결하는 역할로 실행하고 무언가 반환되면 빌드를 실패시키세요.

-- rls_audit.sql: 한 사용자의 행이 다른 사용자에게 닿을 수 있게 하는 것 (스키마 "public")
WITH tbl AS (
  SELECT c.oid, c.relname, c.relrowsecurity, c.relforcerowsecurity
  FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
  WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
)
SELECT 'connected role bypasses RLS' AS problem, current_user::text AS object
  FROM pg_roles WHERE rolname = current_user AND (rolsuper OR rolbypassrls)
UNION ALL
SELECT 'table without RLS', relname FROM tbl WHERE NOT relrowsecurity
UNION ALL
SELECT 'RLS enabled but not forced', relname FROM tbl WHERE relrowsecurity AND NOT relforcerowsecurity
UNION ALL
SELECT 'view without security_invoker', c.relname
  FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
  WHERE n.nspname = 'public' AND c.relkind = 'v'
    AND NOT EXISTS (SELECT 1 FROM unnest(coalesce(c.reloptions, '{}')) o
                    WHERE o ~* '^security_invoker=(true|t|yes|y|on|1)$')
UNION ALL
SELECT 'materialized view or foreign table (RLS does not apply)', c.relname
  FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
  WHERE n.nspname = 'public' AND c.relkind IN ('m', 'f')
UNION ALL
SELECT 'SECURITY DEFINER function', p.proname
  FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
  WHERE n.nspname = 'public' AND p.prosecdef
UNION ALL
SELECT 'permissive policy that allows every row', p.polname
  FROM pg_policy p
  WHERE p.polrelid IN (SELECT oid FROM tbl) AND p.polpermissive AND pg_get_expr(p.polqual, p.polrelid) = 'true'
ORDER BY 1, 2;
set -euo pipefail
out=$(psql "$DATABASE_URL" -X -A -t -v ON_ERROR_STOP=1 -f rls_audit.sql)   # 연결 또는 SQL 오류가 나면 빌드가 중단됩니다
if [ -n "$out" ]; then echo "$out"; exit 1; fi

위의 모든 문제를 담도록 만든 스키마에 이 쿼리를 실행했습니다. 쿼리는 그 모두를 보고했고(역할, RLS가 없는 새 테이블, FORCE되지 않은 테이블, 일반 뷰, definer 함수, 구체화된 뷰, 외부 테이블, 모든 행을 허용하는 정책), 어떤 표기로든 security_invoker를 켠 뷰는 무시했으며, 스키마를 고친 뒤에는 아무것도 반환하지 않았습니다. 설정이 비어 있는지, 또는 정책들이 결합해 의도보다 넓어지는지는 볼 수 없습니다(문자 그대로 true인 정책만 잡아냅니다). 이런 경우에는 사용자로서 요청을 실행하고 행 수를 세는 테스트가 필요하며, TODO 예제에 있는 테스트가 그런 것입니다. 조회용 테이블처럼 정당하게 사용자 데이터가 없는 테이블은 "table without RLS"에 나타나므로, 그런 테이블을 위한 허용 목록을 추가하세요.

요약

  • 애플리케이션이 NOBYPASSRLS이고 슈퍼유저가 아닌 역할로 연결하며, 감사의 첫 번째 검사 결과가 비어 있습니다. Relyt One에서 이는 relytdb_owner나 콘솔에서 만든 사용자가 절대 아닙니다.
  • 사용자 데이터가 있는 모든 테이블에 ENABLE과 FORCE ROW LEVEL SECURITY가 있고, 소유자는 요청 처리 경로에서 빠져 있습니다.
  • 그런 테이블 위의 뷰는 security_invoker = true를 쓰며, 사용자와 마주하는 SECURITY DEFINER 함수는 쓰지 않거나 명시적으로 필터링합니다.
  • 정책이 빈 설정을 거부하고, CHECK (user_id <> '')가 이를 뒷받침합니다. set_config는 항상 true로만 호출합니다.
  • RESTRICTIVE 정책이 허용 정책들 밑에 있고, 감사가 CI에서 실행되어 RLS 없는 새 테이블이 배포되지 못합니다.
  • 사용자 데이터의 고유 제약 조건에 테넌트 열이 포함되어 있고, 애플리케이션 역할에는 사용자 데이터에 대한 구체화된 뷰 권한을 부여하지 않습니다.

행 수준 보안은 다른 접근 제어 코드와 똑같이 테스트할 때 보답합니다. 사용자 종류마다 로그인하고, 쿼리 종류마다 요청하고, 행 수를 세세요. 관리형 인증 위에서 만든다면 미리 보기 글에서 사용자 ID가 데이터베이스에 도달하는 방식을 설명하고, TODO 예제에 테스트가 있습니다.

무료 프로젝트 만들기

태그Postgres보안rls

작성자

Zhan Lu

다음 데이터베이스는 브랜치 하나면 됩니다.

1분 안에 무료 프로젝트를 만드세요. 신용카드도, 설정도 필요 없습니다.

블로그로 돌아가기