고급 PostgreSQL 역할 기반 접근 제어: 행 수준 보안 및 정책 관리

PostgreSQL 행 수준 보안을 RBAC 정책과 함께 사용하여 테넌트 격리, 소유권 확인 및 더 안전한 애플리케이션 접근을 구현합니다.

고급 PostgreSQL 역할 기반 접근 제어: 행 수준 보안 및 정책 관리

PostgreSQL 행 수준 보안(RLS)을 사용하면 데이터베이스가 현재 역할 및 세션 컨텍스트에 따라 행을 필터링할 수 있습니다. 앱이 여러 테넌트를 제공하거나 관리자, 매니저, 일반 사용자 접근을 혼합하는 경우, RLS는 누락된 WHERE tenant_id = ... 하나가 데이터 유출로 이어지는 것을 방지할 수 있습니다.

RLS는 애플리케이션 권한 부여를 대체하지 않습니다. 가장 민감한 경계인 연결이 읽거나 쓸 수 있는 행에 대한 데이터베이스 수준의 안전망을 제공합니다.

행 수준 보안이 필요한 이유

기존 접근 방식의 문제점

애플리케이션 계층 필터링:

# 모든 쿼리는 필터링을 기억해야 함
query = "SELECT * FROM documents WHERE tenant_id = %s"
results = db.execute(query, [current_user_tenant_id])

문제점:

  • 필터를 잊기 쉬움 (보안 취약점)
  • 애플리케이션 전반에 걸친 코드 중복
  • 직접 데이터베이스 접근에 대한 보호 없음
  • 복잡한 감사 요구 사항

행 수준 보안의 장점

  • 자동 적용: 정책이 투명하게 적용됨
  • 중앙 집중식 규칙: 보안 로직이 한 곳에 위치
  • 멀티 테넌트 격리: SaaS 애플리케이션에 완벽함
  • 감사 준수: 내장된 보안 보장
  • 성능: 효율적인 쿼리 계획

기본 RLS 설정

1단계: 테이블에서 RLS 활성화

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200),
    content TEXT,
    owner_id INTEGER NOT NULL,
    department VARCHAR(50),
    is_public BOOLEAN DEFAULT false,
    created_at TIMESTAMP DEFAULT NOW()
);

-- 행 수준 보안 활성화
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

2단계: 역할 생성

-- 다양한 사용자 역할 생성
CREATE ROLE regular_user;
CREATE ROLE department_manager;
CREATE ROLE admin;

-- 테이블 접근 권한 부여
GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO regular_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO department_manager;
GRANT ALL ON documents TO admin;

3단계: 정책 생성

-- 정책: 사용자는 자신의 문서만 볼 수 있음
CREATE POLICY user_own_documents ON documents
    FOR SELECT
    TO regular_user
    USING (owner_id = current_setting('app.user_id')::INTEGER);

-- 정책: 사용자는 자신이 소유한 문서만 삽입할 수 있음
CREATE POLICY user_insert_own ON documents
    FOR INSERT
    TO regular_user
    WITH CHECK (owner_id = current_setting('app.user_id')::INTEGER);

-- 정책: 사용자는 자신의 문서만 업데이트할 수 있음
CREATE POLICY user_update_own ON documents
    FOR UPDATE
    TO regular_user
    USING (owner_id = current_setting('app.user_id')::INTEGER)
    WITH CHECK (owner_id = current_setting('app.user_id')::INTEGER);

4단계: 세션 변수 설정

애플리케이션 코드에서:

import psycopg2

# 역할로 연결
conn = psycopg2.connect(
    host="localhost",
    database="mydb",
    user="regular_user",
    password="password"
)

# 사용자 컨텍스트 설정
cursor = conn.cursor()
cursor.execute("SELECT set_config('app.user_id', %s, false)", [str(current_user_id)])

# 이제 쿼리가 자동으로 필터링됨
cursor.execute("SELECT * FROM documents")
# owner_id = current_user_id인 문서만 반환

멀티 테넌트 SaaS 애플리케이션

완전한 멀티 테넌트 설정

-- 테넌트 테이블 생성
CREATE TABLE tenants (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- 사용자 테이블 생성
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    tenant_id INTEGER REFERENCES tenants(id),
    role VARCHAR(20) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- 애플리케이션 데이터 테이블 생성
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    tenant_id INTEGER NOT NULL REFERENCES tenants(id),
    customer_name VARCHAR(100),
    amount DECIMAL(10,2),
    status VARCHAR(20),
    created_by INTEGER REFERENCES users(id),
    created_at TIMESTAMP DEFAULT NOW()
);

-- RLS 활성화
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- 정책: 사용자는 자신의 테넌트 주문만 볼 수 있음
CREATE POLICY tenant_isolation ON orders
    FOR ALL
    TO public
    USING (tenant_id = current_setting('app.tenant_id')::INTEGER)
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::INTEGER);

애플리케이션 통합

Django 예제:

from django.db import connection

class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.user.is_authenticated:
            with connection.cursor() as cursor:
                cursor.execute(
                    "SELECT set_config('app.tenant_id', %s, false)",
                    [str(request.user.tenant_id)]
                )
        
        response = self.get_response(request)
        return response

Node.js 예제:

const { Pool } = require('pg');
const pool = new Pool();

async function setTenantContext(tenantId) {
    const client = await pool.connect();
    try {
        await client.query('SELECT set_config($1, $2, false)', ['app.tenant_id', String(tenantId)]);
        return client;
    } catch (err) {
        client.release();
        throw err;
    }
}

// 사용법
app.use(async (req, res, next) => {
    if (req.user) {
        req.dbClient = await setTenantContext(req.user.tenantId);
    }
    next();
});

고급 정책 패턴

1. 계층적 접근 제어

-- 사용자는 자신의 부서 문서와 공개 문서를 볼 수 있음
CREATE POLICY department_access ON documents
    FOR SELECT
    TO regular_user
    USING (
        department = current_setting('app.user_department', true)
        OR is_public = true
        OR owner_id = current_setting('app.user_id')::INTEGER
    );

2. 시간 기반 접근

CREATE TABLE subscriptions (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL,
    content TEXT,
    valid_from TIMESTAMP NOT NULL,
    valid_until TIMESTAMP NOT NULL
);

ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;

-- 정책: 사용자는 활성 구독만 볼 수 있음
CREATE POLICY active_subscriptions ON subscriptions
    FOR SELECT
    TO public
    USING (
        user_id = current_setting('app.user_id')::INTEGER
        AND NOW() BETWEEN valid_from AND valid_until
    );

3. 복잡한 로직을 사용한 역할 기반 접근

CREATE TABLE projects (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    owner_id INTEGER,
    team_id INTEGER,
    visibility VARCHAR(20) -- 'private', 'team', 'public'
);

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- 복잡한 가시성 정책
CREATE POLICY project_visibility ON projects
    FOR SELECT
    TO public
    USING (
        CASE current_setting('app.user_role', true)
            WHEN 'admin' THEN true
            WHEN 'manager' THEN (
                team_id = current_setting('app.user_team_id')::INTEGER
                OR visibility = 'public'
            )
            ELSE (
                owner_id = current_setting('app.user_id')::INTEGER
                OR visibility = 'public'
            )
        END
    );

4. 공유 접근 목록

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200),
    owner_id INTEGER NOT NULL
);

CREATE TABLE document_shares (
    document_id INTEGER REFERENCES documents(id),
    shared_with_user_id INTEGER,
    permission VARCHAR(20), -- 'read', 'write'
    PRIMARY KEY (document_id, shared_with_user_id)
);

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- 정책: 사용자는 소유하거나 공유된 문서를 볼 수 있음
CREATE POLICY document_access ON documents
    FOR SELECT
    TO public
    USING (
        owner_id = current_setting('app.user_id')::INTEGER
        OR id IN (
            SELECT document_id 
            FROM document_shares 
            WHERE shared_with_user_id = current_setting('app.user_id')::INTEGER
        )
    );

-- 정책: 사용자는 소유한 문서 또는 쓰기 권한으로 공유된 문서를 수정할 수 있음
CREATE POLICY document_modify ON documents
    FOR UPDATE
    TO public
    USING (
        owner_id = current_setting('app.user_id')::INTEGER
        OR id IN (
            SELECT document_id 
            FROM document_shares 
            WHERE shared_with_user_id = current_setting('app.user_id')::INTEGER
              AND permission = 'write'
        )
    );

관리자 접근 및 RLS

테이블 소유자와 슈퍼유저는 강제하지 않는 한 RLS를 우회할 수 있습니다. 관리자 경로는 별도의 설계 결정으로 취급해야 하며, 사후에 고려해서는 안 됩니다.

옵션 1: BYPASSRLS 역할 속성

CREATE ROLE admin_user WITH LOGIN PASSWORD 'secure_password';
ALTER ROLE admin_user BYPASSRLS;

이것은 드물게 사용하세요. BYPASSRLS는 강력하며 일반 애플리케이션 역할에 부여해서는 안 됩니다.

옵션 2: 관리자를 위한 허용 정책

-- 관리자 역할에 대한 허용 정책 생성
CREATE POLICY admin_all_access ON documents
    FOR ALL
    TO admin
    USING (true)
    WITH CHECK (true);

옵션 3: 테이블 소유자에 대해 RLS 강제

ALTER TABLE documents FORCE ROW LEVEL SECURITY;

FORCE ROW LEVEL SECURITY는 테이블 소유자도 정책을 따르도록 합니다. 이는 일반적으로 애플리케이션 소유 테이블에 더 안전합니다. 유지 관리 작업의 경우, 임시 스크립트 내에서 RLS를 비활성화하는 대신 별도의 제어된 역할을 사용하세요.

정책 유형: USING vs WITH CHECK

USING 절

기존 행이 표시/수정 가능한지 제어합니다:

CREATE POLICY select_own ON documents
    FOR SELECT
    USING (owner_id = current_setting('app.user_id')::INTEGER);

-- 사용자는 owner_id가 일치하는 행만 SELECT할 수 있음

WITH CHECK 절

새로운/업데이트된 행이 허용되는지 제어합니다:

CREATE POLICY insert_own ON documents
    FOR INSERT
    WITH CHECK (owner_id = current_setting('app.user_id')::INTEGER);

-- 사용자는 owner_id가 일치하는 행만 INSERT할 수 있음

결합된 정책

CREATE POLICY update_own ON documents
    FOR UPDATE
    USING (owner_id = current_setting('app.user_id')::INTEGER)
    WITH CHECK (owner_id = current_setting('app.user_id')::INTEGER);

성능 고려 사항

1. 정책 컬럼 인덱싱

-- 정책에 사용된 컬럼에 인덱스 생성
CREATE INDEX idx_documents_owner ON documents(owner_id);
CREATE INDEX idx_documents_tenant ON documents(tenant_id);
CREATE INDEX idx_documents_department ON documents(department);

2. 쿼리 계획 분석

EXPLAIN ANALYZE
SELECT * FROM documents
WHERE title LIKE '%report%';

-- 정책 필터가 효율적으로 적용되는지 확인

3. 정책 내 서브쿼리 주의

서브쿼리는 공유 접근에 적합한 모델일 수 있지만, 인덱스와 테스트가 필요합니다. 모든 후보 행에 대해 다른 테이블을 조사하는 정책은 비용이 많이 들 수 있습니다.

인덱스 없이 위험:

CREATE POLICY slow_policy ON documents
    USING (
        id IN (
            SELECT document_id
            FROM shares
            WHERE user_id = current_setting('app.user_id')::INTEGER
        )
    );

지원 인덱스와 함께 더 명확함:

CREATE POLICY faster_policy ON documents
    USING (
        EXISTS (
            SELECT 1 FROM shares 
            WHERE document_id = documents.id 
              AND user_id = current_setting('app.user_id')::INTEGER
        )
    );

shares(user_id, document_id)와 같은 인덱스를 생성하고 EXPLAIN (ANALYZE, BUFFERS)로 계획을 확인하세요.

모니터링 및 감사

활성 정책 보기

-- 모든 RLS 정책 나열
SELECT 
    schemaname,
    tablename,
    policyname,
    permissive,
    roles,
    cmd,
    qual,
    with_check
FROM pg_policies
ORDER BY tablename, policyname;

정책 변경 감사

마이그레이션 및 데이터베이스 감사 로그에서 정책 DDL을 추적하세요. 환경에서 이벤트 트리거를 사용하는 경우, PostgreSQL 버전에 대해 신중하게 테스트하고 CREATE POLICY, ALTER POLICY, DROP POLICY 이벤트를 포함하세요. 테스트되지 않은 트리거 스니펫을 규정 준수 증거로 신뢰하지 마세요.

정책 효과 테스트

-- 테스트 함수 생성
CREATE OR REPLACE FUNCTION test_user_access(
    test_user_id INTEGER,
    test_tenant_id INTEGER
)
RETURNS TABLE(document_id INTEGER, title VARCHAR) AS $$
BEGIN
    -- 세션 변수 설정
    PERFORM set_config('app.user_id', test_user_id::text, false);
    PERFORM set_config('app.tenant_id', test_tenant_id::text, false);
    
    -- 접근 가능한 문서 반환
    RETURN QUERY SELECT id, documents.title FROM documents;
END;
$$ LANGUAGE plpgsql SECURITY INVOKER;

-- 접근 테스트
SELECT * FROM test_user_access(123, 5);

문제 해결

문제 1: 정책이 적용되지 않음

RLS가 활성화되었는지 확인:

SELECT tablename, rowsecurity 
FROM pg_tables 
WHERE schemaname = 'public' AND tablename = 'documents';

비활성화된 경우 활성화:

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

문제 2: 세션 변수가 설정되지 않음

현재 설정 디버깅:

SHOW app.user_id;
SHOW app.tenant_id;

-- 또는 current_setting 사용
SELECT current_setting('app.user_id', true);

문제 3: 성능 저하

느린 정책 식별:

EXPLAIN (ANALYZE, BUFFERS) 
SELECT * FROM documents WHERE title = 'test';

-- 정책 조건이 있는 "Filter" 작업 찾기

해결책: 정책 단순화 또는 인덱스 추가

문제 4: 예상치 못한 접근 거부

특정 사용자로 테스트:

SET ROLE regular_user;
SELECT set_config('app.user_id', '123', false);

SELECT * FROM documents;
-- 해당 사용자가 실제로 볼 수 있는 것을 표시

RESET ROLE;

모범 사례

  1. 항상 세션 변수 설정: current_setting() 값이 채워져 있는지 확인
  2. 정책 컬럼 인덱싱: USING/WITH CHECK에 사용된 모든 컬럼에 인덱스 생성
  3. 철저히 테스트: 다양한 역할과 시나리오로 정책 확인
  4. 정책 단순 유지: 복잡한 로직은 성능에 영향을 미침
  5. EXPLAIN 사용: RLS가 활성화된 상태에서 쿼리 계획 분석
  6. 정책 문서화: 유지보수를 위해 정책 로직에 주석 추가
  7. 관심사 분리: SELECT, INSERT, UPDATE, DELETE에 대해 다른 정책 사용
  8. 성능 모니터링: 느린 쿼리에 대한 알림 설정

핵심 요약

가장 위험이 높은 테이블에 대한 테넌트 격리라는 하나의 간단한 정책으로 시작하세요. set_config로 세션 컨텍스트를 설정하고, 정책에서 사용하는 모든 컬럼을 인덱싱하고, 실제 애플리케이션 역할로 읽기 및 쓰기를 테스트하세요.

기본 경계가 작동하면, 제품에서 필요로 하는 경우에만 소유권, 관리자 또는 공유 접근 정책을 추가하세요. 작고 테스트된 정책은 전체 권한 모델을 인코딩하려는 하나의 영리한 규칙보다 이해하기 쉽습니다.