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:
83
sql/adb/00_cleanup.sql
Normal file
83
sql/adb/00_cleanup.sql
Normal 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
119
sql/adb/01_dblinks.sql
Normal 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
|
||||
56
sql/adb/02_perm_tables.sql
Normal file
56
sql/adb/02_perm_tables.sql
Normal 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
46
sql/adb/03_seed.sql
Normal 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
70
sql/adb/04_secure_ctx.sql
Normal 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
31
sql/adb/05_views.sql
Normal 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
84
sql/adb/06_policy.sql
Normal 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
85
sql/adb/06a_redaction.sql
Normal 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
61
sql/adb/07_end_users.sql
Normal 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;
|
||||
74
sql/adb/08_tests_user_a.sql
Normal file
74
sql/adb/08_tests_user_a.sql
Normal 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;
|
||||
46
sql/adb/09_tests_user_b.sql
Normal file
46
sql/adb/09_tests_user_b.sql
Normal 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;
|
||||
71
sql/adb/10_tests_admin_audit.sql
Normal file
71
sql/adb/10_tests_admin_audit.sql
Normal 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;
|
||||
Reference in New Issue
Block a user