보안 pg_columnmask 구현 모범 사례 - Amazon Aurora

보안 pg_columnmask 구현 모범 사례

다음 섹션에서는 Aurora PostgreSQL 환경에서 pg_columnmask를 구현하기 위한 보안 모범 사례를 제공합니다. 다음 권장 사항을 따릅니다.

  • 보안 역할 기반 액세스 제어 아키텍처 설정

  • 보안 취약성을 방지하는 마스킹 함수 개발

  • 마스킹 처리된 데이터를 사용하여 트리거 동작 이해 및 제어

역할 기반 보안 아키텍처

역할 계층 구조를 정의하여 데이터베이스에서 액세스 제어를 구현합니다. Aurora PostgreSQL pg_columnmask는 이러한 역할 내에서 세분화된 데이터 마스킹을 위한 추가 계층을 제공하여 이러한 제어를 강화합니다.

개별 사용자에게 권한을 부여하는 대신 조직 기능에 맞는 전용 역할을 생성합니다. 이 접근 방식은 조직 구조가 발전함에 따라 감사 가능성을 높이고 권한 관리를 간소화합니다.

예조직적 역할 계층 구조 생성

다음 예제에서는 다양한 함수에 대한 전용 역할이 있는 조직 역할 계층 구조를 생성한 다음 개별 사용자를 적절한 역할에 할당합니다. 이 예제에서는 조직 역할(analyst_role, support_role)이 먼저 생성된 다음 개별 사용자에게 이러한 역할에 대한 멤버십이 부여됩니다. 이 구조를 사용하면 각 개별 사용자 대신 역할 수준에서 권한을 관리할 수 있습니다.

-- Create organizational role hierarchy CREATE ROLE data_admin_role; CREATE ROLE security_admin_role; CREATE ROLE analyst_role; CREATE ROLE support_role; CREATE ROLE developer_role; -- Specify security_admin_role as masking policy manager in the DB cluster parameter -- group pgcolumnmask.policy_admin_rolname = 'security_admin_role' -- Create specific users and assign to appropriate roles CREATE USER security_manager; CREATE USER data_analyst1, data_analyst2; CREATE USER support_agent1, support_agent2; GRANT security_admin_role TO security_manager; GRANT analyst_role TO data_analyst1, data_analyst2; GRANT support_role TO support_agent1, support_agent2;

각 역할에 필요한 최소 권한만 부여하여 최소 권한 원칙을 구현합니다. 자격 증명이 손상된 경우 악용될 수 있는 광범위한 권한을 부여하지 마세요.

-- Grant specific table permissions rather than schema-wide access GRANT SELECT ON sensitive_data.customers TO analyst_role; GRANT SELECT ON sensitive_data.transactions TO analyst_role; -- Do not grant: GRANT ALL ON SCHEMA sensitive_data TO analyst_role;

정책 관리자는 마스킹 정책을 관리하는 스키마에 대한 USAGE 권한이 필요합니다. 최소 권한 원칙에 따라 이러한 권한을 선택적으로 부여합니다. 스키마 액세스 권한을 정기적으로 검토하여 권한이 있는 직원만 정책 관리 기능을 유지할 수 있도록 합니다.

정책 관리자 역할 파라미터 구성은 데이터베이스 관리자로만 제한됩니다. 이 파라미터는 데이터베이스 또는 세션 수준에서 수정할 수 없으므로 권한이 없는 사용자가 정책 관리자 할당을 재정의할 수 없습니다. 이 제한을 통해 마스킹 정책 제어가 중앙 집중화되고 안전하게 유지됩니다.

그룹이 아닌 특정 개인에게 정책 관리자 역할을 할당합니다. 정책 관리자는 데이터베이스 내의 모든 테이블을 마스킹할 수 있으므로이 대상 접근 방식은 마스킹 정책 관리에 대한 선택적 액세스를 보장합니다.

보안 마스킹 함수 개발

초기 바인딩 의미 체계를 사용하여 마스킹 함수를 개발함으로써 적절한 종속성 추적을 보장하고 런타임 중 검색 경로 수정과 같은 지연 바인딩 취약성을 방지합니다. SQL 함수에 구문을 사용하여 BEGIN ATOMIC 컴파일 시간 검증(예: 초기 바인딩) 및 종속성 관리를 활성화하는 것이 좋습니다.

-- Example - Secure masking function with early binding CREATE OR REPLACE FUNCTION secure_mask_ssn(input_ssn TEXT) RETURNS TEXT LANGUAGE SQL IMMUTABLE PARALLEL SAFE STRICT BEGIN ATOMIC SELECT CASE WHEN input_ssn IS NULL THEN NULL WHEN length(input_ssn) < 4 THEN repeat('X', length(input_ssn)) ELSE repeat('X', length(input_ssn) - 4) || right(input_ssn, 4) END; END;

또는 모든 객체 참조를 명시적으로 스키마로 검증하여 검색 경로 변경에 영향을 받지 않는 함수를 생성하여 다양한 사용자 세션에서 일관된 동작을 보장합니다.

-- Function immune to search path changes CREATE OR REPLACE FUNCTION data_masking.secure_phone_mask(phone_number TEXT) RETURNS TEXT LANGUAGE SQL IMMUTABLE PARALLEL SAFE STRICT AS $$ SELECT CASE WHEN phone_number IS NULL THEN NULL WHEN public.length(public.regexp_replace(phone_number, '[^0-9]', '', 'g')) < 10 THEN 'XXX-XXX-XXXX' ELSE public.regexp_replace( phone_number, '([0-9]{3})[0-9]{3}([0-9]{4})', public.concat('\1-XXX-\2') ) END; $$;

마스킹 함수 내에서 입력 검증을 구현하여 엣지 케이스를 처리하고 예상치 못한 동작을 방지합니다. 항상 Null 처리를 포함하고 입력 형식을 검증하여 일관된 마스킹 동작을 보장합니다.

-- Robust masking function with comprehensive input validation CREATE OR REPLACE FUNCTION secure_mask_phone(phone_number TEXT) RETURNS TEXT LANGUAGE SQL IMMUTABLE PARALLEL SAFE STRICT BEGIN ATOMIC SELECT CASE WHEN phone_number IS NULL THEN NULL WHEN length(trim(phone_number)) = 0 THEN phone_number WHEN length(regexp_replace(phone_number, '[^0-9]', '', 'g')) < 10 THEN 'XXX-XXX-XXXX' ELSE regexp_replace(phone_number, '([0-9]{3})[0-9]{3}([0-9]{4})', '\1-XXX-\2') END; END;

pg_columnmask를 사용하여 DML 트리거 동작

테이블 트리거의 경우 전환 테이블의 마스크가 완전히 해제됩니다. 뷰 트리거(IOT)의 경우 현재 사용자의 뷰 권한에 따라 전환 테이블이 마스킹됩니다.

pg_columnmask를 사용한 테이블 트리거

트리거는 실행 중인 DML 쿼리에 의해 수정된 행의 이전 버전과 새 버전이 포함된 전환 테이블을 전달합니다. 트리거가 실행되는 시점에 따라 Aurora PostgreSQL은 이전 행과 새 행을 채웁니다. 예를 들어 참조할 이전 버전이 없기 때문에 BEFORE INSERT 트리거에는 새 버전의 행과 빈 이전 버전만 있습니다.

pg_columnmask는 테이블의 트리거 내에서 전환 테이블을 마스킹하지 않습니다. 트리거는 본문 내에서 마스킹된 열을 사용할 수 있으며 마스킹되지 않은 데이터를 볼 수 있습니다. 트리거 생성자는 사용자에 대해 트리거가 어떻게 실행되는지 확인해야 합니다. 이 경우 다음 예제가 올바르게 작동합니다.

-- Example for table trigger uses masked column in its definition -- Create a table and insert some rows CREATE TABLE public.credit_card_table ( name TEXT, credit_card_no VARCHAR(16), is_fraud BOOL ); INSERT INTO public.credit_card_table (name, credit_card_no, is_fraud) VALUES ('John Doe', '4532015112830366', false), ('Jane Smith', '5410000000000000', true), ('Brad Smith', '1234567891234567', true); -- Create a role which will see masked data and grant it privileges CREATE ROLE intern_user; GRANT SELECT, DELETE ON public.credit_card_table TO intern_user; -- Trigger which will silenty skip delete of non fraudelent credit cards CREATE OR REPLACE FUNCTION prevent_non_fraud_delete() RETURNS TRIGGER AS $$ BEGIN IF OLD.is_fraud = false THEN RETURN NULL; END IF; RETURN OLD; END; $$ LANGUAGE plpgsql; CREATE TRIGGER prevent_non_fraud_delete BEFORE DELETE ON credit_card_table FOR EACH ROW EXECUTE FUNCTION prevent_non_fraud_delete(); CREATE OR REPLACE FUNCTION public.return_false() RETURNS BOOLEAN LANGUAGE SQL IMMUTABLE PARALLEL SAFE STRICT BEGIN ATOMIC SELECT false; END; -- A masking policy that masks both credit card number and is_fraud column. -- If we apply masking inside trigger then prevent_non_fraud_delete trigger will -- allow deleting more rows to masked user (even non fraud ones). CALL pgcolumnmask.create_masking_policy( 'mask_credit_card_no_&_is_fraud'::NAME, 'public.credit_card_table'::REGCLASS, JSON_BUILD_OBJECT('credit_card_no', 'pgcolumnmask.mask_text(credit_card_no)', 'is_fraud', 'public.return_false()')::JSONB, ARRAY['intern_user']::NAME[], 10::INT ); -- Test trigger behaviour using intern_user BEGIN; SET ROLE intern_user; -- credit card number & is_fraud is completely masked from intern_user SELECT * FROM public.credit_card_table; name | credit_card_no | is_fraud ------------+------------------+---------- John Doe | XXXXXXXXXXXXXXXX | f Jane Smith | XXXXXXXXXXXXXXXX | f Brad Smith | XXXXXXXXXXXXXXXX | f (3 rows) -- The delete trigger lets the intern user delete rows for Jane and Brad even though -- intern_user sees their is_fraud = false, but the table trigger works with original -- unmasked value DELETE FROM public.credit_card_table RETURNING *; name | credit_card_no | is_fraud ------------+------------------+---------- Jane Smith | XXXXXXXXXXXXXXXX | f Brad Smith | XXXXXXXXXXXXXXXX | f (2 rows) COMMIT;

트리거 생성자는 트리거 본문에서 사용하는 문에 대해 주의하지 않으면 사용자에게 마스킹되지 않은 데이터를 유출합니다. 예를 들어 RAISE NOTICE ‘%’, masked_column;를 사용하면 현재 사용자에게 열이 출력됩니다.

-- Example showing table trigger leaking column value to current user CREATE OR REPLACE FUNCTION leaky_trigger_func() RETURNS TRIGGER AS $$ BEGIN RAISE NOTICE 'Old credit card number was: %', OLD.credit_card_no; RAISE NOTICE 'New credit card number is %', NEW.credit_card_no; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER leaky_trigger AFTER UPDATE ON public.credit_card_table FOR EACH ROW EXECUTE FUNCTION leaky_trigger_func(); -- Grant update on column is_fraud to auditor role -- auditor will NOT HAVE PERMISSION TO READ DATA CREATE ROLE auditor; GRANT UPDATE (is_fraud) ON public.credit_card_table TO auditor; -- Also add auditor role to existing masking policy on credit card table CALL pgcolumnmask.alter_masking_policy( 'mask_credit_card_no_&_is_fraud'::NAME, 'public.credit_card_table'::REGCLASS, NULL::JSONB, ARRAY['intern_user', 'auditor']::NAME[], NULL::INT ); -- Log in as auditor -- [auditor] -- Update will fail if trying to read data from the table UPDATE public.credit_card_table SET is_fraud = true WHERE credit_card_no = '4532015112830366'; ERROR: permission denied for table cc_table -- [auditor] -- But leaky update trigger will still print the entire row even though -- current user does not have permission to select from public.credit_card_table UPDATE public.credit_card_table SET is_fraud = true; NOTICE: Old credit_card_no was: 4532015112830366 NOTICE: New credit_card_no is 4532015112830366
pg_columnmask가 있는 뷰의 트리거(Instead of 트리거)

트리거는 PostgreSQL의 뷰에서만 생성할 수 있습니다. 업데이트가 불가능한 뷰에서 DML 문을 실행하는 데 사용됩니다. 뷰 쿼리 내에서 사용되는 뷰 및 기본 테이블의 소유자가 다를 수 있으므로 전송 테이블은 항상 트리거(IOT) 대신 내부에서 마스킹됩니다. 이 경우 기본 테이블에는 뷰 소유자에게 적용되는 일부 마스킹 정책이 있을 수 있으며, 뷰 소유자는 항상 트리거 내의 기본 테이블에서 마스킹된 데이터를 확인해야 합니다. 이는 테이블의 트리거와 다릅니다. 이 경우 트리거 생성자와 테이블 내부의 데이터는 동일한 사용자가 소유하며 여기서는 그렇지 않기 때문입니다.

-- Create a view over credit card table CREATE OR REPLACE VIEW public.credit_card_view AS SELECT * FROM public.credit_card_table; -- Truncate credit card table and insert fresh data TRUNCATE TABLE public.credit_card_table; INSERT INTO public.credit_card_table (name, credit_card_no, is_fraud) VALUES ('John Doe', '4532015112830366', false), ('Jane Smith', '5410000000000000', true), ('Brad Smith', '1234567891234567', true); CREATE OR REPLACE FUNCTION public.print_changes() RETURNS TRIGGER AS $$ BEGIN RAISE NOTICE 'Old row: name=%, credit card number=%, is fraud=%', OLD.name, OLD.credit_card_no, OLD.is_fraud; RAISE NOTICE 'New row: name=%, credit card number=%, is fraud=%', NEW.name, NEW.credit_card_no, NEW.is_fraud; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER print_changes_trigger INSTEAD OF UPDATE ON public.credit_card_view FOR EACH ROW EXECUTE FUNCTION public.print_changes(); GRANT SELECT, UPDATE ON public.credit_card_view TO auditor; -- [auditor] -- Login as auditor role BEGIN; -- Any data coming out from the table will be masked in instead of triggers -- according to masking policies applicable to current user UPDATE public.credit_card_view SET name = CONCAT(name, '_new_name') RETURNING *; NOTICE: Old row: name=John Doe, credit card number=XXXXXXXXXXXXXXXX, is fraud=f NOTICE: New row: name=John Doe_new_name, credit card number=XXXXXXXXXXXXXXXX, is fraud=f NOTICE: Old row: name=Jane Smith, credit card number=XXXXXXXXXXXXXXXX, is fraud=f NOTICE: New row: name=Jane Smith_new_name, credit card number=XXXXXXXXXXXXXXXX, is fraud=f NOTICE: Old row: name=Brad Smith, credit card number=XXXXXXXXXXXXXXXX, is fraud=f NOTICE: New row: name=Brad Smith_new_name, credit card number=XXXXXXXXXXXXXXXX, is fraud=f name | credit_card_no | is_fraud ---------------------+------------------+---------- John Doe_new_name | XXXXXXXXXXXXXXXX | f Jane Smith_new_name | XXXXXXXXXXXXXXXX | f Brad Smith_new_name | XXXXXXXXXXXXXXXX | f -- Any new data going into the table using INSERT or UPDATE command will be unmasked UPDATE public.credit_card_view SET credit_card_no = '9876987698769876' RETURNING *; NOTICE: Old row: name=John Doe, credit card number=XXXXXXXXXXXXXXXX, is fraud=f NOTICE: New row: name=John Doe, credit card number=9876987698769876, is fraud=f NOTICE: Old row: name=Jane Smith, credit card number=XXXXXXXXXXXXXXXX, is fraud=f NOTICE: New row: name=Jane Smith, credit card number=9876987698769876, is fraud=f NOTICE: Old row: name=Brad Smith, credit card number=XXXXXXXXXXXXXXXX, is fraud=f NOTICE: New row: name=Brad Smith, credit card number=9876987698769876, is fraud=f name | credit_card_no | is_fraud ------------+------------------+---------- John Doe | 9876987698769876 | f Jane Smith | 9876987698769876 | f Brad Smith | 9876987698769876 | f COMMIT;
트리거 동작을 제어하는 데이터베이스/사용자 수준 GuC

두 구성 파라미터는 적용 가능한 마스킹 정책이 있는 사용자의 실행 동작을 제어합니다. 이러한 파라미터를 사용하여 추가 보안 제한이 필요한 경우 마스킹 처리된 테이블 또는 뷰에서 트리거가 실행되지 않도록 할 수 있습니다. 두 파라미터 모두 기본적으로 비활성화되어 있어 트리거가 정상적으로 실행될 수 있습니다.

첫 번째 GUC: 마스킹 처리된 테이블에 대한 트리거 실행 제한

사양:

  • 이름: pgcolumnmask.restrict_dml_triggers_for_masked_users

  • 유형: boolean

  • 기본값: false(트리거 실행 허용)

TRUE로 설정하면 마스킹된 사용자의 마스킹된 테이블에서 트리거 실행을 방지합니다. pg_columnmask는 오류가 발생함에도 불구하고 실행됩니다.

두 번째 GUC: 마스킹 처리된 테이블이 있는 뷰에 대한 실행 제한 트리거

사양:

  • 이름: pgcolumnmask.restrict_iot_triggers_for_masked_users

  • 유형: boolean

  • 기본값: false(트리거 실행 허용)

TRUE로 설정된 경우 마스킹된 사용자에 대한 정의에 마스킹된 테이블이 포함된 뷰에서 트리거 실행을 방지합니다.

이러한 파라미터는 독립적으로 작동하며 표준 데이터베이스 구성 파라미터처럼 구성할 수 있습니다.