Initial commit — VPD Permission POC (clone-and-go)

ADB-centered row-level access control across heterogeneous DB sources
(AWS RDS Postgres + MySQL) using Oracle VPD + Data Redaction +
Secure Application Context, packaged as a one-click demo.

Mechanism:
- LOGON trigger calls ctx_pkg.init once per session to load the user's
  allowed regions from the permission mapping tables into a Secure App
  Context (VPD_CTX, USING ctx_pkg).
- VPD policy function vpd_region_filter reads SYS_CONTEXT and returns
  an IN-list predicate (or '1=0' for fail-closed, NULL for '*'),
  which Oracle injects into every SELECT on the protected views.
- Data Redaction reuses the same context to mask PII (email, full_name)
  when the allowed-regions value is not '*'.
- 5 documented bypass attempts (direct DB link SELECT, SET_CONTEXT
  spoof, DBMS_RLS drop, mapping table SELECT) all blocked by GRANT
  scoping + DEFINER rights on ctx_pkg.

One-click entrypoint:
- ./run.sh {prereq|source|adb|tests|audit|all|teardown}
- Source DDL (Postgres + MySQL customers + 12-row seed each) is
  applied via local psql/mysql; ADB-side setup via sqlplus with .env
  values injected as SQL*Plus DEFINE substitutions.

Verified E2E on ADB 26ai + AWS RDS PG + RDS MySQL (mysql_community
gateway) on 2026-05-26: VPDUSER_A sees only APAC rows (PG 2 / MySQL 6,
PII masked), VPDUSER_B sees all (PG 12 / MySQL 17, PII unmasked).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
devmrko
2026-05-26 14:03:32 +09:00
commit 68d53dc5a9
23 changed files with 2396 additions and 0 deletions

83
sql/adb/00_cleanup.sql Normal file
View File

@@ -0,0 +1,83 @@
-- ============================================================
-- 00_cleanup.sql
-- Idempotent teardown so we can re-run the POC from scratch.
-- Errors are ignored (objects may not exist on first run).
-- Run as ADMIN.
--
-- DEFINE: &DBLINK_PG_NAME, &DBLINK_MY_NAME
-- ============================================================
WHENEVER SQLERROR CONTINUE NONE;
SET ECHO OFF
SET FEEDBACK OFF
SET DEFINE ON
PROMPT === Dropping VPD policies (if present) ===
BEGIN DBMS_RLS.DROP_POLICY(USER, 'V_CUSTOMERS_PG', 'CUSTOMERS_PG_POLICY'); EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN DBMS_RLS.DROP_POLICY(USER, 'V_CUSTOMERS_MY', 'CUSTOMERS_MY_POLICY'); EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Dropping Data Redaction policies (if present) ===
BEGIN DBMS_REDACT.DROP_POLICY(USER, 'V_CUSTOMERS_PG', 'PII_REDACT_PG'); EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN DBMS_REDACT.DROP_POLICY(USER, 'V_CUSTOMERS_MY', 'PII_REDACT_MY'); EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Dropping logon trigger ===
BEGIN EXECUTE IMMEDIATE 'DROP TRIGGER vpd_logon_trg'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Dropping views ===
BEGIN EXECUTE IMMEDIATE 'DROP VIEW v_customers_pg'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP VIEW v_customers_my'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Dropping secure context and package ===
BEGIN EXECUTE IMMEDIATE 'DROP CONTEXT vpd_ctx'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP PACKAGE ctx_pkg'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP FUNCTION vpd_region_filter'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Dropping end-user accounts (cascade) ===
BEGIN EXECUTE IMMEDIATE 'DROP USER vpduser_a CASCADE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP USER vpduser_b CASCADE'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Dropping permission tables ===
BEGIN EXECUTE IMMEDIATE 'DROP TABLE permission CASCADE CONSTRAINTS'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE user_group CASCADE CONSTRAINTS'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE app_group CASCADE CONSTRAINTS'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE app_user CASCADE CONSTRAINTS'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE app_customer CASCADE CONSTRAINTS'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN EXECUTE IMMEDIATE 'DROP TABLE db_source CASCADE CONSTRAINTS'; EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Dropping DB Links + credentials (cascade heterogeneous gateway) ===
BEGIN
DBMS_CLOUD_ADMIN.DROP_DATABASE_LINK(db_link_name => UPPER('&DBLINK_PG_NAME'));
EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN
DBMS_CLOUD_ADMIN.DROP_DATABASE_LINK(db_link_name => UPPER('&DBLINK_MY_NAME'));
EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN
DBMS_CLOUD.DROP_CREDENTIAL(credential_name => UPPER('&DBLINK_PG_NAME._CRED'));
EXCEPTION WHEN OTHERS THEN NULL; END;
/
BEGIN
DBMS_CLOUD.DROP_CREDENTIAL(credential_name => UPPER('&DBLINK_MY_NAME._CRED'));
EXCEPTION WHEN OTHERS THEN NULL; END;
/
PROMPT === Cleanup complete ===
EXIT;

119
sql/adb/01_dblinks.sql Normal file
View File

@@ -0,0 +1,119 @@
-- ============================================================
-- 01_dblinks.sql
-- Run as ADMIN.
--
-- ADB 에서 외부 Postgres / MySQL 로 가는 DB Link 를 만든다.
-- DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK 를 쓰면 ADB 가 내장 게이트웨이로
-- heterogeneous (PG/MySQL) 연결을 처리해 준다.
--
-- 멱등성: 같은 이름의 link/credential 이 있으면 drop 후 재생성.
--
-- DEFINE: &DBLINK_PG_NAME, &DBLINK_MY_NAME,
-- &PG_HOST, &PG_PORT, &PG_DB, &PG_USER, &PG_PASSWORD,
-- &MY_HOST, &MY_PORT, &MY_DB, &MY_USER, &MY_PASSWORD
-- ============================================================
-- ECHO OFF: heredoc 의 DEFINE 으로 주입되는 패스워드가 stdout 으로 흐르지 않게.
-- 디버그 필요할 땐 sql 파일 단독으로만 실행하고, run.sh 경유 시는 절대 ON 하지 말 것.
SET ECHO OFF
SET VERIFY OFF
SET FEEDBACK ON
SET DEFINE ON
SET TERMOUT ON
WHENEVER SQLERROR EXIT SQL.SQLCODE
PROMPT === 1) DB link / credential ( drop) ===
DECLARE
PROCEDURE drop_link_if_exists(p_name IN VARCHAR2) IS
l_cnt NUMBER;
BEGIN
SELECT COUNT(*) INTO l_cnt FROM user_db_links
WHERE db_link = UPPER(p_name);
IF l_cnt > 0 THEN
DBMS_CLOUD_ADMIN.DROP_DATABASE_LINK(db_link_name => UPPER(p_name));
DBMS_OUTPUT.PUT_LINE('dropped db_link ' || UPPER(p_name));
END IF;
EXCEPTION WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('drop_link skip ' || p_name || ': ' || SQLERRM);
END;
PROCEDURE drop_cred_if_exists(p_name IN VARCHAR2) IS
l_cnt NUMBER;
BEGIN
SELECT COUNT(*) INTO l_cnt FROM user_credentials
WHERE credential_name = UPPER(p_name);
IF l_cnt > 0 THEN
DBMS_CLOUD.DROP_CREDENTIAL(credential_name => UPPER(p_name));
DBMS_OUTPUT.PUT_LINE('dropped credential ' || UPPER(p_name));
END IF;
EXCEPTION WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('drop_cred skip ' || p_name || ': ' || SQLERRM);
END;
BEGIN
drop_link_if_exists('&DBLINK_PG_NAME');
drop_link_if_exists('&DBLINK_MY_NAME');
drop_cred_if_exists('&DBLINK_PG_NAME._CRED');
drop_cred_if_exists('&DBLINK_MY_NAME._CRED');
END;
/
PROMPT === 2) credential ( DB ) ===
BEGIN
DBMS_CLOUD.CREATE_CREDENTIAL(
credential_name => '&DBLINK_PG_NAME._CRED',
username => '&PG_USER',
password => '&PG_PASSWORD'
);
DBMS_CLOUD.CREATE_CREDENTIAL(
credential_name => '&DBLINK_MY_NAME._CRED',
username => '&MY_USER',
password => '&MY_PASSWORD'
);
END;
/
PROMPT === 3) DB Link Postgres ===
BEGIN
DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK(
db_link_name => '&DBLINK_PG_NAME',
hostname => '&PG_HOST',
port => &PG_PORT,
service_name => '&PG_DB',
credential_name => '&DBLINK_PG_NAME._CRED',
gateway_params => JSON_OBJECT('db_type' VALUE 'postgres'),
ssl_server_cert_dn => NULL,
directory_name => NULL
);
END;
/
PROMPT === 4) DB Link MySQL ===
BEGIN
DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK(
db_link_name => '&DBLINK_MY_NAME',
hostname => '&MY_HOST',
port => &MY_PORT,
service_name => '&MY_DB',
credential_name => '&DBLINK_MY_NAME._CRED',
-- mysql_community: AWS RDS for MySQL, self-managed MySQL CE 등에 필요.
-- 디폴트 'mysql' 은 MySQL Enterprise/Commercial 전용.
gateway_params => JSON_OBJECT('db_type' VALUE 'mysql_community'),
ssl_server_cert_dn => NULL,
directory_name => NULL
);
END;
/
PROMPT === 5) DB link ===
COL db_link FORMAT a25
COL host FORMAT a60
SELECT db_link, host FROM user_db_links ORDER BY db_link;
PROMPT === 6) link SELECT ===
WHENEVER SQLERROR CONTINUE
PROMPT -- Postgres
SELECT COUNT(*) AS pg_customers FROM "public"."customers"@&DBLINK_PG_NAME;
PROMPT -- MySQL
SELECT COUNT(*) AS my_customers FROM "&MY_DB"."customers"@&DBLINK_MY_NAME;
PROMPT === 01_dblinks done ===
EXIT

View File

@@ -0,0 +1,56 @@
-- ============================================================
-- 01_perm_tables.sql
-- Permission model (the "policy plane"). All tables live in ADMIN.
-- Run as ADMIN.
-- ============================================================
SET ECHO OFF
SET FEEDBACK ON
PROMPT === Creating permission-model tables ===
CREATE TABLE app_customer (
customer_id NUMBER PRIMARY KEY,
customer_name VARCHAR2(80) NOT NULL UNIQUE
);
CREATE TABLE app_user (
user_id NUMBER PRIMARY KEY,
db_username VARCHAR2(30) NOT NULL UNIQUE, -- matches Oracle SESSION_USER (UPPER)
customer_id NUMBER NOT NULL REFERENCES app_customer(customer_id),
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N'))
);
CREATE TABLE app_group (
group_id NUMBER PRIMARY KEY,
customer_id NUMBER NOT NULL REFERENCES app_customer(customer_id),
group_name VARCHAR2(60) NOT NULL,
UNIQUE (customer_id, group_name)
);
CREATE TABLE user_group (
user_id NUMBER NOT NULL REFERENCES app_user(user_id),
group_id NUMBER NOT NULL REFERENCES app_group(group_id),
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE db_source (
source_id NUMBER PRIMARY KEY,
source_name VARCHAR2(60) NOT NULL UNIQUE, -- e.g. RDS_POSTGRES, RDS_MYSQL
source_type VARCHAR2(30) NOT NULL, -- 'DBLINK_PG','DBLINK_MY','EXTERNAL_TABLE',...
dblink_name VARCHAR2(128)
);
-- Each permission row says: this group, on this object of this source,
-- is allowed to see rows where region is in `allowed_regions`.
-- Convention: '*' means no restriction (full access).
CREATE TABLE permission (
perm_id NUMBER PRIMARY KEY,
group_id NUMBER NOT NULL REFERENCES app_group(group_id),
source_id NUMBER NOT NULL REFERENCES db_source(source_id),
object_name VARCHAR2(60) NOT NULL, -- the local view name, e.g. 'V_CUSTOMERS_PG'
allowed_regions VARCHAR2(200) NOT NULL, -- CSV: 'KR,APAC' or '*'
UNIQUE (group_id, source_id, object_name)
);
PROMPT === Permission tables created ===
EXIT;

46
sql/adb/03_seed.sql Normal file
View File

@@ -0,0 +1,46 @@
-- ============================================================
-- 02_seed.sql
-- Seed two end-users with different permissions to demonstrate VPD.
--
-- VPDUSER_A -> group KR_ANALYSTS -> allowed_regions = 'APAC'
-- VPDUSER_B -> group GLOBAL_ADMINS -> allowed_regions = '*' (all rows)
--
-- Run as ADMIN.
-- ============================================================
SET ECHO OFF
SET FEEDBACK ON
PROMPT === Seeding permission data ===
INSERT INTO app_customer (customer_id, customer_name) VALUES (1, 'Acme Corp');
INSERT INTO app_user (user_id, db_username, customer_id) VALUES (1, 'VPDUSER_A', 1);
INSERT INTO app_user (user_id, db_username, customer_id) VALUES (2, 'VPDUSER_B', 1);
INSERT INTO app_group (group_id, customer_id, group_name) VALUES (10, 1, 'KR_ANALYSTS');
INSERT INTO app_group (group_id, customer_id, group_name) VALUES (20, 1, 'GLOBAL_ADMINS');
-- A -> KR_ANALYSTS
INSERT INTO user_group (user_id, group_id) VALUES (1, 10);
-- B -> GLOBAL_ADMINS
INSERT INTO user_group (user_id, group_id) VALUES (2, 20);
INSERT INTO db_source (source_id, source_name, source_type, dblink_name)
VALUES (100, 'RDS_POSTGRES', 'DBLINK_PG', 'RDS_POSTGRES_LINK');
INSERT INTO db_source (source_id, source_name, source_type, dblink_name)
VALUES (200, 'RDS_MYSQL', 'DBLINK_MY', 'RDS_LINK');
-- Permissions: KR analysts see APAC only; Global admins see everything.
INSERT INTO permission (perm_id, group_id, source_id, object_name, allowed_regions)
VALUES (1, 10, 100, 'V_CUSTOMERS_PG', 'APAC');
INSERT INTO permission (perm_id, group_id, source_id, object_name, allowed_regions)
VALUES (2, 10, 200, 'V_CUSTOMERS_MY', 'APAC');
INSERT INTO permission (perm_id, group_id, source_id, object_name, allowed_regions)
VALUES (3, 20, 100, 'V_CUSTOMERS_PG', '*');
INSERT INTO permission (perm_id, group_id, source_id, object_name, allowed_regions)
VALUES (4, 20, 200, 'V_CUSTOMERS_MY', '*');
COMMIT;
PROMPT === Seed complete ===
EXIT;

70
sql/adb/04_secure_ctx.sql Normal file
View File

@@ -0,0 +1,70 @@
-- ============================================================
-- 03_secure_ctx.sql
-- Secure application context. ONLY ctx_pkg can SET vpd_ctx.*
-- End-users cannot spoof their identity / region list.
--
-- The package is AUTHID DEFINER so it can read permission tables
-- without granting end-users direct SELECT on those tables.
-- It uses SYS_CONTEXT('USERENV','SESSION_USER') to identify the
-- *invoker*, so even if an end-user calls it directly they only
-- ever load their OWN permissions.
-- ============================================================
SET ECHO OFF
SET FEEDBACK ON
PROMPT === Creating context package spec ===
CREATE OR REPLACE PACKAGE ctx_pkg AUTHID DEFINER AS
-- Called from the LOGON trigger (or manually).
-- Loads the connecting user's allowed regions per object into vpd_ctx.
PROCEDURE init;
END ctx_pkg;
/
PROMPT === Creating context package body ===
CREATE OR REPLACE PACKAGE BODY ctx_pkg AS
PROCEDURE init IS
v_user app_user.db_username%TYPE := SYS_CONTEXT('USERENV','SESSION_USER');
v_uid app_user.user_id%TYPE;
BEGIN
-- Look up app_user by Oracle session username (always uppercased).
BEGIN
SELECT user_id INTO v_uid
FROM app_user
WHERE db_username = v_user
AND active = 'Y';
EXCEPTION WHEN NO_DATA_FOUND THEN
-- Not an app user; leave context empty -> policy will return impossible predicate.
RETURN;
END;
-- For each object the user has permission on, aggregate allowed_regions
-- across all groups the user belongs to.
-- '*' wins over any specific list.
FOR r IN (
SELECT p.object_name,
CASE WHEN MAX(CASE WHEN p.allowed_regions='*' THEN 1 ELSE 0 END) = 1
THEN '*'
ELSE LISTAGG(p.allowed_regions, ',') WITHIN GROUP (ORDER BY p.allowed_regions)
END AS regions
FROM permission p
JOIN user_group ug ON ug.group_id = p.group_id
WHERE ug.user_id = v_uid
GROUP BY p.object_name
) LOOP
-- Attribute name = object name (e.g. V_CUSTOMERS_PG), value = CSV of regions.
DBMS_SESSION.SET_CONTEXT('VPD_CTX', r.object_name, r.regions);
END LOOP;
-- Also store the user_id for auditing / future use.
DBMS_SESSION.SET_CONTEXT('VPD_CTX', 'USER_ID', TO_CHAR(v_uid));
END init;
END ctx_pkg;
/
PROMPT === Binding secure context to ctx_pkg ===
-- This is the critical line: only code running INSIDE admin.ctx_pkg
-- can call DBMS_SESSION.SET_CONTEXT on the VPD_CTX namespace.
CREATE OR REPLACE CONTEXT vpd_ctx USING ctx_pkg;
PROMPT === Secure context ready ===
EXIT;

31
sql/adb/05_views.sql Normal file
View File

@@ -0,0 +1,31 @@
-- ============================================================
-- 04_views.sql
-- Local views over the remote (heterogeneous) DB-linked tables.
-- End-users will be granted SELECT on these views only.
-- The raw @dblink references stay inside ADMIN, invisible to users.
-- ============================================================
SET ECHO OFF
SET FEEDBACK ON
PROMPT === Creating v_customers_pg (Postgres) ===
-- Postgres is case-sensitive: schema, table, and column names must be quoted.
CREATE OR REPLACE VIEW v_customers_pg AS
SELECT "customer_id" AS customer_id,
"full_name" AS full_name,
"email" AS email,
"signup_date" AS signup_date,
"region" AS region
FROM "public"."customers"@RDS_POSTGRES_LINK;
PROMPT === Creating v_customers_my (MySQL) ===
-- MySQL via Oracle gateway: schema/table need quoting (lowercase preserved).
CREATE OR REPLACE VIEW v_customers_my AS
SELECT "customer_id" AS customer_id,
"full_name" AS full_name,
"email" AS email,
"signup_date" AS signup_date,
"region" AS region
FROM "ecommerce_poc"."customers"@RDS_LINK;
PROMPT === Views created ===
EXIT;

84
sql/adb/06_policy.sql Normal file
View File

@@ -0,0 +1,84 @@
-- ============================================================
-- 05_policy.sql
-- VPD policy function + DBMS_RLS.ADD_POLICY attachments.
--
-- The function returns a row-filter predicate based on the
-- secure context loaded at logon. If no permission is loaded for
-- this object, it returns an impossible predicate so the user
-- sees ZERO rows (fail closed).
-- ============================================================
SET ECHO OFF
SET FEEDBACK ON
PROMPT === Creating policy function vpd_region_filter ===
CREATE OR REPLACE FUNCTION vpd_region_filter(
p_schema IN VARCHAR2,
p_object IN VARCHAR2
) RETURN VARCHAR2 AS
v_regions VARCHAR2(4000);
v_pred VARCHAR2(4000);
v_list VARCHAR2(4000);
BEGIN
-- Read the CSV of allowed regions for this object from secure context.
v_regions := SYS_CONTEXT('VPD_CTX', UPPER(p_object));
IF v_regions IS NULL THEN
-- Fail closed: no entry => no rows visible.
RETURN '1=0';
END IF;
IF INSTR(v_regions, '*') > 0 THEN
-- Wildcard => no row filter (full visibility on this object).
RETURN NULL;
END IF;
-- Convert CSV 'KR,APAC' -> "'KR','APAC'" for an IN-list.
-- (Region values come only from our own permission table, so quoting
-- by escaping single quotes is sufficient; no untrusted user input.)
SELECT LISTAGG('''' || REPLACE(TRIM(column_value),'''','''''') || '''', ',')
WITHIN GROUP (ORDER BY column_value)
INTO v_list
FROM TABLE(APEX_STRING.SPLIT(v_regions, ','));
IF v_list IS NULL THEN
RETURN '1=0';
END IF;
v_pred := 'region IN (' || v_list || ')';
RETURN v_pred;
END;
/
SHOW ERRORS
PROMPT === Attaching policies to views ===
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => USER,
object_name => 'V_CUSTOMERS_PG',
policy_name => 'CUSTOMERS_PG_POLICY',
function_schema => USER,
policy_function => 'VPD_REGION_FILTER',
statement_types => 'SELECT',
update_check => FALSE,
enable => TRUE
);
END;
/
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => USER,
object_name => 'V_CUSTOMERS_MY',
policy_name => 'CUSTOMERS_MY_POLICY',
function_schema => USER,
policy_function => 'VPD_REGION_FILTER',
statement_types => 'SELECT',
update_check => FALSE,
enable => TRUE
);
END;
/
PROMPT === Policies attached ===
EXIT;

85
sql/adb/06a_redaction.sql Normal file
View File

@@ -0,0 +1,85 @@
-- ============================================================
-- 05a_redaction.sql
-- Column-level masking with Oracle Data Redaction (DBMS_REDACT).
--
-- Policy:
-- PII columns (email, full_name) are MASKED unless the session
-- has full-region access ('*') on the corresponding view.
--
-- - VPDUSER_A (KR_ANALYSTS, regions = 'APAC') -> sees masked PII
-- - VPDUSER_B (GLOBAL_ADMINS, regions = '*') -> sees real PII
--
-- Reuses the secure VPD_CTX populated at logon — no new context.
-- Data Redaction and VPD compose: VPD filters rows first, Redaction
-- then transforms columns on the surviving rows.
--
-- NOTE: ADMIN has the EXEMPT REDACTION POLICY system privilege
-- implicitly via DBA, so ADMIN sessions still see real values.
-- End-users do not have it, so they see the masked output.
-- ============================================================
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === Creating PII redaction policy on v_customers_pg ===
BEGIN
DBMS_REDACT.ADD_POLICY(
object_schema => USER,
object_name => 'V_CUSTOMERS_PG',
column_name => 'EMAIL',
policy_name => 'PII_REDACT_PG',
function_type => DBMS_REDACT.REGEXP,
regexp_pattern => '^(.)(.*)(@.*)$',
regexp_replace_string => '\1****\3',
regexp_position => 1,
regexp_occurrence => 1,
expression => 'SYS_CONTEXT(''VPD_CTX'',''V_CUSTOMERS_PG'') IS NULL OR SYS_CONTEXT(''VPD_CTX'',''V_CUSTOMERS_PG'') != ''*'''
);
DBMS_REDACT.ALTER_POLICY(
object_schema => USER,
object_name => 'V_CUSTOMERS_PG',
policy_name => 'PII_REDACT_PG',
action => DBMS_REDACT.ADD_COLUMN,
column_name => 'FULL_NAME',
function_type => DBMS_REDACT.REGEXP,
regexp_pattern => '^(.)(.*)$',
regexp_replace_string => '\1****',
regexp_position => 1,
regexp_occurrence => 1
);
END;
/
PROMPT === Creating PII redaction policy on v_customers_my ===
BEGIN
DBMS_REDACT.ADD_POLICY(
object_schema => USER,
object_name => 'V_CUSTOMERS_MY',
column_name => 'EMAIL',
policy_name => 'PII_REDACT_MY',
function_type => DBMS_REDACT.REGEXP,
regexp_pattern => '^(.)(.*)(@.*)$',
regexp_replace_string => '\1****\3',
regexp_position => 1,
regexp_occurrence => 1,
expression => 'SYS_CONTEXT(''VPD_CTX'',''V_CUSTOMERS_MY'') IS NULL OR SYS_CONTEXT(''VPD_CTX'',''V_CUSTOMERS_MY'') != ''*'''
);
DBMS_REDACT.ALTER_POLICY(
object_schema => USER,
object_name => 'V_CUSTOMERS_MY',
policy_name => 'PII_REDACT_MY',
action => DBMS_REDACT.ADD_COLUMN,
column_name => 'FULL_NAME',
function_type => DBMS_REDACT.REGEXP,
regexp_pattern => '^(.)(.*)$',
regexp_replace_string => '\1****',
regexp_position => 1,
regexp_occurrence => 1
);
END;
/
PROMPT === Redaction policies attached ===
EXIT;

61
sql/adb/07_end_users.sql Normal file
View File

@@ -0,0 +1,61 @@
-- ============================================================
-- 07_end_users.sql
-- Create the two end-user accounts with MINIMAL privileges.
-- Add a LOGON trigger that loads each user's context automatically.
-- Run as ADMIN.
--
-- DEFINE: &VPDUSER_A_PASSWORD, &VPDUSER_B_PASSWORD
-- ============================================================
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE ON
PROMPT === Creating end-user accounts ===
-- Passwords come from .env (DEFINE) so they aren't hardcoded in source.
-- Production should use IAM / proxy auth / mTLS instead of static passwords.
CREATE USER vpduser_a IDENTIFIED BY "&VPDUSER_A_PASSWORD";
CREATE USER vpduser_b IDENTIFIED BY "&VPDUSER_B_PASSWORD";
-- ADB requires a tablespace quota even for read-only users in some setups; we
-- skip QUOTA since these users won't create objects.
GRANT CREATE SESSION TO vpduser_a;
GRANT CREATE SESSION TO vpduser_b;
PROMPT === Granting SELECT on the policy-protected views ONLY ===
GRANT SELECT ON v_customers_pg TO vpduser_a;
GRANT SELECT ON v_customers_my TO vpduser_a;
GRANT SELECT ON v_customers_pg TO vpduser_b;
GRANT SELECT ON v_customers_my TO vpduser_b;
-- Allow them to call ctx_pkg.init (the logon trigger needs this, and a manual
-- re-init is sometimes useful). The package is bound to vpd_ctx so calling it
-- is harmless: it only ever loads the caller's OWN permissions.
GRANT EXECUTE ON ctx_pkg TO vpduser_a;
GRANT EXECUTE ON ctx_pkg TO vpduser_b;
-- NOTE on what we are deliberately NOT granting:
-- * NO grant on app_user / permission / etc. -> users can't read who-can-see-what
-- * NO grant on the underlying @dblink tables -> can't bypass the view
-- * NO grant on DBMS_RLS -> can't alter/drop the policy
-- * NO EXEMPT ACCESS POLICY -> can't escape VPD
-- * NO CREATE TABLE / CREATE MATERIALIZED VIEW -> can't snapshot filtered data
-- * NO DBA / PDB_DBA / RESOURCE roles -> least privilege
PROMPT === Creating LOGON trigger that auto-loads context ===
CREATE OR REPLACE TRIGGER vpd_logon_trg
AFTER LOGON ON DATABASE
BEGIN
-- Only fire for our application end-users. ADMIN logons keep normal behavior.
IF SYS_CONTEXT('USERENV','SESSION_USER') IN ('VPDUSER_A','VPDUSER_B') THEN
admin.ctx_pkg.init;
END IF;
EXCEPTION
WHEN OTHERS THEN
-- Never block login on a context-loading error; fail closed (no perms loaded
-- means policy returns 1=0, i.e. no rows). Log if you have an audit table.
NULL;
END;
/
PROMPT === End-users ready ===
EXIT;

View File

@@ -0,0 +1,74 @@
-- ============================================================
-- 07_tests_user_a.sql
-- Run as VPDUSER_A (group KR_ANALYSTS, allowed_regions=APAC).
-- Expected: only APAC rows visible; bypass attempts fail.
-- ============================================================
SET FEEDBACK ON
SET LINESIZE 200
SET PAGESIZE 100
PROMPT
PROMPT === Who am I, and what context did the LOGON trigger load? ===
SELECT USER AS session_user,
SYS_CONTEXT('VPD_CTX','USER_ID') AS app_user_id,
SYS_CONTEXT('VPD_CTX','V_CUSTOMERS_PG') AS regions_pg,
SYS_CONTEXT('VPD_CTX','V_CUSTOMERS_MY') AS regions_my
FROM dual;
PROMPT
PROMPT === Distinct regions visible from Postgres view (expect: APAC only) ===
SELECT DISTINCT region FROM admin.v_customers_pg ORDER BY 1;
PROMPT
PROMPT === Distinct regions visible from MySQL view (expect: APAC only) ===
SELECT DISTINCT region FROM admin.v_customers_my ORDER BY 1;
PROMPT
PROMPT === Row counts ===
SELECT 'V_CUSTOMERS_PG' AS view_name, COUNT(*) AS rows_visible FROM admin.v_customers_pg
UNION ALL
SELECT 'V_CUSTOMERS_MY', COUNT(*) FROM admin.v_customers_my;
PROMPT
PROMPT === PII REDACTION (expect masked email/full_name: 'j****@...' / 'A****') ===
COLUMN customer_id FORMAT 9999
COLUMN full_name FORMAT A20
COLUMN email FORMAT A30
COLUMN region FORMAT A8
SELECT customer_id, full_name, email, region
FROM admin.v_customers_pg
ORDER BY customer_id;
SELECT customer_id, full_name, email, region
FROM admin.v_customers_my
ORDER BY customer_id;
PROMPT
PROMPT === BYPASS 1: query remote table directly (expect ORA-00942 / privilege error) ===
WHENEVER SQLERROR CONTINUE;
SELECT COUNT(*) FROM "public"."customers"@RDS_POSTGRES_LINK;
SELECT COUNT(*) FROM "ecommerce_poc"."customers"@RDS_LINK;
PROMPT
PROMPT === BYPASS 2: try to spoof the context (expect ORA-01031) ===
BEGIN
DBMS_SESSION.SET_CONTEXT('VPD_CTX','V_CUSTOMERS_PG','*');
END;
/
PROMPT
PROMPT === BYPASS 3: try to drop the policy (expect ORA-00942 / privilege error) ===
BEGIN
DBMS_RLS.DROP_POLICY('ADMIN','V_CUSTOMERS_PG','CUSTOMERS_PG_POLICY');
END;
/
PROMPT
PROMPT === BYPASS 4: try to read the permission table (expect ORA-00942) ===
SELECT COUNT(*) FROM admin.permission;
PROMPT
PROMPT === BYPASS 5: try to read app_user (expect ORA-00942) ===
SELECT COUNT(*) FROM admin.app_user;
EXIT;

View File

@@ -0,0 +1,46 @@
-- ============================================================
-- 08_tests_user_b.sql
-- Run as VPDUSER_B (group GLOBAL_ADMINS, allowed_regions=*).
-- Expected: ALL regions visible (no row filter).
-- ============================================================
SET FEEDBACK ON
SET LINESIZE 200
SET PAGESIZE 100
PROMPT
PROMPT === Who am I, and what context did the LOGON trigger load? ===
SELECT USER AS session_user,
SYS_CONTEXT('VPD_CTX','USER_ID') AS app_user_id,
SYS_CONTEXT('VPD_CTX','V_CUSTOMERS_PG') AS regions_pg,
SYS_CONTEXT('VPD_CTX','V_CUSTOMERS_MY') AS regions_my
FROM dual;
PROMPT
PROMPT === Distinct regions visible from Postgres view (expect: all regions) ===
SELECT DISTINCT region FROM admin.v_customers_pg ORDER BY 1;
PROMPT
PROMPT === Distinct regions visible from MySQL view (expect: all regions) ===
SELECT DISTINCT region FROM admin.v_customers_my ORDER BY 1;
PROMPT
PROMPT === Row counts (expect higher than VPDUSER_A) ===
SELECT 'V_CUSTOMERS_PG' AS view_name, COUNT(*) AS rows_visible FROM admin.v_customers_pg
UNION ALL
SELECT 'V_CUSTOMERS_MY', COUNT(*) FROM admin.v_customers_my;
PROMPT
PROMPT === PII REDACTION (expect REAL email/full_name GLOBAL_ADMINS has '*' so no masking) ===
COLUMN customer_id FORMAT 9999
COLUMN full_name FORMAT A20
COLUMN email FORMAT A30
COLUMN region FORMAT A8
SELECT customer_id, full_name, email, region
FROM admin.v_customers_pg
ORDER BY customer_id;
SELECT customer_id, full_name, email, region
FROM admin.v_customers_my
ORDER BY customer_id;
EXIT;

View File

@@ -0,0 +1,71 @@
-- ============================================================
-- 09_tests_admin_audit.sql
-- Run as ADMIN to audit / verify policy attachment.
-- ============================================================
SET FEEDBACK ON
SET LINESIZE 220
SET PAGESIZE 100
COL object_name FORMAT a20
COL policy FORMAT a25
COL pf_owner FORMAT a15
COL package FORMAT a15
COL function FORMAT a25
COL sel FORMAT a3
COL enable FORMAT a6
PROMPT
PROMPT === Attached VPD policies ===
SELECT object_name,
policy_name AS policy,
pf_owner,
package,
function,
sel,
enable
FROM dba_policies
WHERE object_owner = USER
ORDER BY object_name, policy_name;
PROMPT
PROMPT === Attached Data Redaction policies ===
COL object_name FORMAT a20
COL policy_name FORMAT a20
COL expression FORMAT a80 WORD_WRAPPED
SELECT object_name,
policy_name,
expression
FROM redaction_policies
WHERE object_owner = USER
ORDER BY object_name, policy_name;
PROMPT
PROMPT === Redacted columns (which columns get masked, and how) ===
COL object_name FORMAT a20
COL column_name FORMAT a15
COL function_type FORMAT a15
COL regexp_pattern FORMAT a25
COL regexp_replace_string FORMAT a15
SELECT object_name,
column_name,
function_type,
regexp_pattern,
regexp_replace_string
FROM redaction_columns
WHERE object_owner = USER
ORDER BY object_name, column_name;
PROMPT
PROMPT === Permission summary (who can see what) ===
SELECT u.db_username,
g.group_name,
s.source_name,
p.object_name,
p.allowed_regions
FROM app_user u
JOIN user_group ug ON ug.user_id = u.user_id
JOIN app_group g ON g.group_id = ug.group_id
JOIN permission p ON p.group_id = g.group_id
JOIN db_source s ON s.source_id = p.source_id
ORDER BY u.db_username, p.object_name;
EXIT;

View File

@@ -0,0 +1,51 @@
-- ============================================================
-- sql/source/mysql_setup.sql
-- Run on the REMOTE MySQL instance (mysql CLI).
--
-- Creates the customers table in the `ecommerce_poc` schema with
-- sample data spanning multiple regions (APAC / EMEA / AMER).
--
-- 동일 customer_id 가 Postgres seed 와 겹치지만, 다른 DB 이므로 무관.
-- 의도적으로 일부 row 의 region 을 다르게 줘서 두 소스가 독립적임을 보임.
--
-- 멱등성: INSERT IGNORE 로 중복 PK skip.
--
-- 호출 예시 (run.sh source 가 자동 실행):
-- mysql -h $MY_HOST -P $MY_PORT -u $MY_USER -p"$MY_PASSWORD" \
-- $MY_DB < sql/source/mysql_setup.sql
-- ============================================================
SELECT '=== Creating ecommerce_poc.customers (idempotent) ===' AS status;
CREATE TABLE IF NOT EXISTS customers (
customer_id INT NOT NULL PRIMARY KEY,
full_name VARCHAR(80) NOT NULL,
email VARCHAR(120) NOT NULL,
signup_date DATE NOT NULL,
region VARCHAR(8) NOT NULL,
CONSTRAINT chk_region CHECK (region IN ('APAC','EMEA','AMER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SELECT '=== Seeding sample rows (12 rows across 3 regions) ===' AS status;
INSERT IGNORE INTO customers (customer_id, full_name, email, signup_date, region) VALUES
(101, 'Aki Tanaka', 'aki.tanaka@shop.example', '2024-01-20', 'APAC'),
(102, 'Bohyun Lee', 'bohyun.lee@shop.example', '2024-02-08', 'APAC'),
(103, 'Cheng Ho', 'cheng.ho@shop.example', '2024-02-25', 'APAC'),
(104, 'Devi Patel', 'devi.patel@shop.example', '2024-03-12', 'APAC'),
(105, 'Erik Johansson', 'erik.j@shop.example', '2024-03-22', 'EMEA'),
(106, 'Fatima Al-Hassan','fatima.h@shop.example', '2024-04-05', 'EMEA'),
(107, 'Greta Lindqvist', 'greta.l@shop.example', '2024-04-18', 'EMEA'),
(108, 'Hans Becker', 'hans.becker@shop.example', '2024-05-01', 'EMEA'),
(109, 'Ines Martinez', 'ines.martinez@shop.example', '2024-05-13', 'AMER'),
(110, 'Jorge Silva', 'jorge.silva@shop.example', '2024-05-25', 'AMER'),
(111, 'Kayla Anderson', 'kayla.a@shop.example', '2024-06-07', 'AMER'),
(112, 'Lucas Costa', 'lucas.costa@shop.example', '2024-06-19', 'AMER');
SELECT '=== Verifying ===' AS status;
SELECT region, COUNT(*) AS row_count
FROM customers
GROUP BY region
ORDER BY region;
SELECT '=== mysql_setup done ===' AS status;

View File

@@ -0,0 +1,49 @@
-- ============================================================
-- sql/source/postgres_setup.sql
-- Run on the REMOTE PostgreSQL instance (psql).
--
-- Creates the customers table in the `public` schema with sample
-- data spanning multiple regions (APAC / EMEA / AMER). The VPD
-- policy on ADB will filter rows from this table per logged-in user.
--
-- 멱등성: 기존 데이터가 있으면 그대로 두고 INSERT 시 ON CONFLICT 로 skip.
--
-- 호출 예시 (run.sh source 가 자동 실행):
-- PGPASSWORD=$PG_PASSWORD psql -h $PG_HOST -U $PG_USER -d $PG_DB \
-- -f sql/source/postgres_setup.sql
-- ============================================================
\echo === Creating public.customers (idempotent) ===
CREATE TABLE IF NOT EXISTS public.customers (
customer_id INTEGER PRIMARY KEY,
full_name TEXT NOT NULL,
email TEXT NOT NULL,
signup_date DATE NOT NULL,
region VARCHAR(8) NOT NULL CHECK (region IN ('APAC','EMEA','AMER'))
);
\echo === Seeding sample rows (12 rows across 3 regions) ===
INSERT INTO public.customers (customer_id, full_name, email, signup_date, region) VALUES
( 1, 'Alex Kim', 'alex.kim@example.com', DATE '2024-01-15', 'APAC'),
( 2, 'Bora Park', 'bora.park@example.com', DATE '2024-02-03', 'APAC'),
( 3, 'Chen Wei', 'chen.wei@example.com', DATE '2024-02-21', 'APAC'),
( 4, 'Daisuke Sato', 'daisuke.sato@example.com', DATE '2024-03-09', 'APAC'),
( 5, 'Emma Mueller', 'emma.mueller@example.com', DATE '2024-03-18', 'EMEA'),
( 6, 'Francois Dubois', 'francois.d@example.com', DATE '2024-04-02', 'EMEA'),
( 7, 'Giulia Rossi', 'giulia.rossi@example.com', DATE '2024-04-14', 'EMEA'),
( 8, 'Henry Smith', 'henry.smith@example.com', DATE '2024-04-27', 'EMEA'),
( 9, 'Isabella Garcia', 'isabella.g@example.com', DATE '2024-05-08', 'AMER'),
(10, 'James Brown', 'james.brown@example.com', DATE '2024-05-19', 'AMER'),
(11, 'Karen Lopez', 'karen.lopez@example.com', DATE '2024-06-01', 'AMER'),
(12, 'Liam Wilson', 'liam.wilson@example.com', DATE '2024-06-13', 'AMER')
ON CONFLICT (customer_id) DO NOTHING;
\echo === Verifying ===
SELECT region, COUNT(*) AS row_count
FROM public.customers
GROUP BY region
ORDER BY region;
\echo === postgres_setup done ===