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

46
.env.example Normal file
View File

@@ -0,0 +1,46 @@
# ============================================================
# .env.example → cp .env.example .env 후 값 채워서 사용
# 모든 run.sh / scripts 가 이 파일을 source 합니다.
# 절대 .env 자체를 git 에 올리지 마세요 (.gitignore 등록됨).
# ============================================================
# --- (1) 클라이언트 도구 경로 ---
# Oracle Instant Client (sqlplus) 가 PATH 에 있어야 함.
# macOS 예: /Users/<you>/devkit/instantclient
# Linux 예: /opt/oracle/instantclient_21_12
export PATH="${HOME}/devkit/instantclient:${PATH}"
# Oracle wallet 디렉토리 (cwallet.sso, tnsnames.ora 포함된 풀린 경로)
export TNS_ADMIN="" # 예: /Users/you/devkit/Wallet_D8AUKRO81636MON0
# --- (2) ADB 접속 ---
export ADB_TNS="" # tnsnames.ora alias (예: d8aukro81636mon0_tp)
export ADB_USER="admin"
export ADB_PASSWORD="" # 비워두면 run.sh 가 read -s 로 프롬프트
# 편의: sqlplus 한 줄 connect 문자열 (자동 합성됨; 직접 안 건드려도 됨)
# export ADB_CONN="${ADB_USER}/${ADB_PASSWORD}@${ADB_TNS}"
# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) ---
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
export VPDUSER_A_PASSWORD="RowFilter#A2026"
export VPDUSER_B_PASSWORD="RowFilter#B2026"
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
# ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴.
export PG_HOST="" # 예: vpd-poc.xxxxx.ap-northeast-2.rds.amazonaws.com
export PG_PORT="5432"
export PG_DB="vpdpoc" # CREATE DATABASE 미리 되어 있어야 함
export PG_USER="postgres"
export PG_PASSWORD=""
# --- (5) 원격 MySQL ---
export MY_HOST=""
export MY_PORT="3306"
export MY_DB="ecommerce_poc" # CREATE DATABASE 미리 되어 있어야 함
export MY_USER="admin"
export MY_PASSWORD=""
# --- (6) ADB → 원격 DB Link 이름 (관례적으로 고정 — 굳이 안 바꿔도 됨) ---
export DBLINK_PG_NAME="RDS_POSTGRES_LINK"
export DBLINK_MY_NAME="RDS_LINK"

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# 환경/비밀 — 절대 commit 금지
.env
.env.*
!.env.example
# Oracle wallet
wallet/
*.sso
*.p12
*.pem
*.key
# 로컬 실행 로그
logs/
*.log
# OS
.DS_Store
Thumbs.db

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
# VPD Permission POC — ADB 중심 행 단위 접근 제어
Oracle Autonomous Database (26ai) 의 **VPD (Virtual Private Database)**
**Data Redaction** 을 이용해, ADB 한 곳에서 여러 원본 DB (AWS RDS for PostgreSQL,
MySQL) 의 데이터를 **유저 → 그룹 → 권한 매핑 테이블** 만으로 행/열 단위 통제하는
End-to-End 데모입니다.
> 핵심: 원본 DB 는 단순한 데이터 소스로 두고, 접근 권한은 ADB 의 VPD 정책 함수와
> Secure Application Context 로 통합 관리합니다. 애플리케이션 코드 변경 없이
> 로그인하는 DB 유저에 따라 자동으로 행이 필터링됩니다.
---
## 구성요소
| 계층 | 객체 | 역할 |
|---|---|---|
| 원격 | `public.customers` (PG), `ecommerce_poc.customers` (MySQL) | 원본 데이터 |
| ADB - 연결 | `RDS_POSTGRES_LINK`, `RDS_LINK` | `DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 로 만든 heterogeneous DB Link |
| ADB - 매핑 | `app_customer`, `app_user`, `app_group`, `user_group`, `db_source`, `permission` | 누가 어느 소스의 어느 region 을 볼 수 있는지 |
| ADB - 컨텍스트 | `vpd_ctx` (Secure Application Context) + `ctx_pkg` | 로그인 시 권한을 세션 컨텍스트로 로딩 |
| ADB - 뷰 | `v_customers_pg`, `v_customers_my` | DB Link 위의 통합 뷰. **이 뷰에만 VPD/Redaction 정책이 붙음** |
| ADB - 정책 | `CUSTOMERS_PG_POLICY`, `CUSTOMERS_MY_POLICY`, `PII_REDACT_PG/MY` | VPD 행 필터 + 이메일 마스킹 |
| ADB - 엔드유저 | `vpduser_a`, `vpduser_b` | 최소 권한. 뷰만 SELECT 가능. LOGON 트리거로 컨텍스트 자동 로딩 |
---
## 빠른 시작 (One-Click)
```bash
git clone https://github.com/<you>/vpd-permission-poc.git
cd vpd-permission-poc
# 1) 환경값 채우기
cp .env.example .env
$EDITOR .env
# 2) 끝
./run.sh
```
`./run.sh` 가 다음을 순서대로 실행합니다:
1. **prereq**`sqlplus`, `psql`, `mysql` 존재 + `.env` 변수 검증
2. **source** — 원격 PG/MySQL 에 `customers` 테이블 + seed (멱등)
3. **adb** — ADB 측 cleanup → DB Link → 권한 테이블/seed → context/view/policy → 엔드유저
4. **tests**`vpduser_a``vpduser_b` 로 접속해 행 필터/마스킹 검증
5. **audit** — ADMIN 으로 정책/뷰/유저 상태 점검
세부 단계만 따로 돌리려면:
```bash
./run.sh prereq # 환경만 검증
./run.sh source # 원격 DB 만 준비
./run.sh adb # ADB 만 준비
./run.sh tests # 엔드유저 테스트만
./run.sh audit # ADMIN audit 만
./run.sh teardown # ADB 측 객체 + DB Link/credential 정리
```
---
## 사전 준비
| 항목 | 필요 사항 |
|---|---|
| ADB | Autonomous Database (Always Free 도 가능). Wallet 다운로드 + Instant Client 에 풀어둠 |
| PostgreSQL | 호스트/포트 도달 가능. `vpdpoc` (혹은 원하는 이름) DB 가 미리 생성되어 있어야 함 |
| MySQL | 호스트/포트 도달 가능. `ecommerce_poc` (혹은 원하는 이름) DB 가 미리 생성되어 있어야 함 |
| 클라이언트 도구 | `sqlplus` (Instant Client), `psql`, `mysql` |
`.env.example` 의 각 항목 의미는 같은 파일 안 주석 참고.
---
## 데모 시나리오
`sql/adb/03_seed.sql` 의 매핑:
| DB 유저 | 그룹 | 볼 수 있는 region |
|---|---|---|
| `vpduser_a` | `KR_ANALYSTS` | `APAC` 만 |
| `vpduser_b` | `GLOBAL_ADMINS` | `*` (전체) |
따라서:
* `vpduser_a``SELECT * FROM admin.v_customers_pg` → APAC rows 만, 이메일 마스킹됨
* `vpduser_b` 로 같은 쿼리 → 전체 rows, 이메일 원본
* 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음
`sql/adb/08_tests_user_a.sql` 가 우회 시도 5개 (DBMS_RLS 변경, 다른 유저 컨텍스트
설정 등) 를 시도하고 모두 ORA-xxxxx 로 실패하는 것을 보여줍니다.
---
## 디렉토리
```
.
├── run.sh # 원클릭 엔트리포인트
├── .env.example
├── scripts/lib/common.sh # log/ok/warn/die + env 검증 헬퍼
├── sql/
│ ├── source/
│ │ ├── postgres_setup.sql # 원격 PG: customers + 12 rows
│ │ └── mysql_setup.sql # 원격 MySQL: customers + 12 rows
│ └── adb/
│ ├── 00_cleanup.sql # 멱등 teardown
│ ├── 01_dblinks.sql # DB Link + credential 생성
│ ├── 02_perm_tables.sql # 6개 권한 매핑 테이블
│ ├── 03_seed.sql # 데모 매핑 데이터
│ ├── 04_secure_ctx.sql # ctx_pkg + vpd_ctx (Secure App Context)
│ ├── 05_views.sql # DB Link 통합 뷰
│ ├── 06_policy.sql # VPD 정책 + 정책 함수
│ ├── 06a_redaction.sql # Data Redaction (이메일 마스킹)
│ ├── 07_end_users.sql # vpduser_a/b + LOGON 트리거
│ ├── 08_tests_user_a.sql # APAC-only 검증 + 우회 시도
│ ├── 09_tests_user_b.sql # GLOBAL_ADMIN 검증
│ └── 10_tests_admin_audit.sql
└── docs/
└── 03-detailed-guide.md # 한국어 상세 설명 (아키텍처, 정책 로직, 운영 고려사항)
```
---
## 더 깊이 알고 싶으면
* [docs/03-detailed-guide.md](docs/03-detailed-guide.md) — 권한 매핑 모델, 정책
함수의 동적 SQL, Secure Context 사용 이유, 운영 시 주의점 등 전체 해설.
---
## 라이선스
MIT.

98
docs/01-quickstart.md Normal file
View File

@@ -0,0 +1,98 @@
# 01 · Quickstart
`./run.sh` 한 번으로 모든 게 끝납니다. 이 문서는 첫 실행 전에 무엇이 필요하고,
무엇이 잘못될 수 있는지 알려줍니다.
---
## 1. 사전에 준비되어 있어야 하는 것
### 1-1. ADB (Autonomous Database)
* 인스턴스 1개. Always Free 도 OK.
* OCI 콘솔에서 **Wallet 다운로드 → 로컬에 압축 풀기**.
* Instant Client (`sqlplus`) 가 설치되어 있고 PATH 에 등록되어 있을 것.
### 1-2. 원격 Postgres / MySQL
* ADB 가 네트워크로 도달 가능해야 합니다. (RDS public access ON, 보안그룹에
ADB egress IP 허용 등)
* **DB 가 미리 생성되어 있어야 합니다.** 테이블은 `run.sh source` 가 만듭니다.
```sql
-- Postgres
CREATE DATABASE vpdpoc;
-- MySQL
CREATE DATABASE ecommerce_poc;
```
* `psql`, `mysql` 클라이언트가 로컬에 설치되어 있어야 합니다.
```bash
brew install libpq mysql-client # macOS
apt-get install postgresql-client mysql-client # Debian/Ubuntu
```
### 1-3. ADB ADMIN 계정 패스워드
* `.env` 의 `ADB_PASSWORD` 에 넣거나, 비워두고 실행 시 프롬프트로 입력.
---
## 2. `.env` 채우기
```bash
cp .env.example .env
$EDITOR .env
```
최소한 다음 항목은 비어 있으면 안 됩니다:
| 변수 | 의미 |
|---|---|
| `TNS_ADMIN` | Wallet 풀린 디렉토리 (cwallet.sso, tnsnames.ora 가 있는 곳) |
| `ADB_TNS` | tnsnames.ora 안의 service alias (예: `d8aukro81636mon0_tp`) |
| `ADB_USER`, `ADB_PASSWORD` | ADB 관리자 계정 |
| `PG_HOST` 등 | 원격 Postgres 연결정보 |
| `MY_HOST` 등 | 원격 MySQL 연결정보 |
VPDUSER A/B 의 패스워드는 데모용 기본값이 들어있으니 그대로 써도 무방하지만,
공유 환경이면 바꿔주세요.
---
## 3. 실행
```bash
./run.sh
```
기대 출력 (요약):
```
[ OK ] prereq 통과
[ OK ] Postgres source 준비 완료
[ OK ] MySQL source 준비 완료
[ OK ] ADB setup 완료
[ OK ] user_a / user_b 테스트 실행 완료 (위 출력에서 행 수 / 거부 결과 확인)
[ OK ] audit 완료
[ OK ] === ALL DONE — VPD POC 전체 파이프라인 통과 ===
```
이후 직접 검증해보고 싶으면:
```bash
# vpduser_a 로 접속 → APAC rows 만 보여야 함
sqlplus vpduser_a/${VPDUSER_A_PASSWORD}@${ADB_TNS}
SQL> SELECT region, COUNT(*) FROM admin.v_customers_pg GROUP BY region;
```
---
## 4. 자주 하는 실수
| 증상 | 원인 / 해결 |
|---|---|
| `TNS:could not resolve` | `TNS_ADMIN` 경로 잘못 / `ADB_TNS` 가 tnsnames.ora 에 없음 |
| `ORA-12506` | Wallet 풀린 위치는 맞는데 권한 문제 (`chmod 600` 시도 X — 600 이면 sqlplus 가 못 읽음, 644 OK) |
| Postgres 연결 실패 | RDS Public Access 꺼져있거나 SG/firewall 차단. 로컬에서 `psql -h ... -U ...` 로 먼저 확인 |
| `ORA-28000` | ADB 계정이 잠겼음. OCI 콘솔에서 unlock |
| `ORA-65020` (DB Link 생성 실패) | 같은 이름 link 가 이미 다른 host 로 등록돼 있음. `00_cleanup.sql` 부터 다시 |
| 정책이 안 먹는다 (모든 row 보임) | `vpduser_a` 가 아니라 `admin` 으로 접속한 것 — ADMIN 은 정책 BYPASS |

156
docs/02-architecture.md Normal file
View File

@@ -0,0 +1,156 @@
# 02 · Architecture
## 한 줄 요약
> **원본 DB 는 그냥 데이터 창고**, **권한은 ADB 한 곳**, **VPD 가 SQL 에 WHERE 절을
> 자동 주입**.
---
## 왜 이렇게 만들었나 — 쿼리마다 EXISTS 돌리지 않는다
가장 단순한 접근은 "VPD 정책 함수 안에서 매번 매핑 테이블에 EXISTS 쿼리를 던지는"
방식입니다. 동작은 하지만 **모든 SELECT 마다 권한 lookup 이 한 번 더 일어나서**
원격 DB Link 위의 조회를 두 배로 만듭니다.
이 PoC 는 그 대신 **"로그인 시 1회만 매핑 조회 → 결과를 세션 컨텍스트에 캐시"**
패턴을 씁니다.
```
[LOGON 1회] [SELECT N회]
ctx_pkg.init vpd_region_filter
│ │
├─ app_user JOIN user_group ├─ SYS_CONTEXT 읽기 (메모리)
│ JOIN permission JOIN db_source │ = 디스크/네트워크 I/O 0
│ → 'APAC' 또는 'APAC,EMEA' 또는 '*' 또는 NULL
│ ├─ NULL → '1=0' (fail closed)
└─ SYS_CONTEXT('VPD_CTX', '<view>') 에 저장 ├─ '*' → NULL (필터 없음)
└─ 그 외 → 'region IN (...)'
← Oracle 이 WHERE 에 AND
```
**즉 EXISTS subquery 가 아니라 IN-list predicate 입니다.** 정책 함수가 반환한
predicate 문자열을 Oracle 이 SQL 에 자동으로 AND 붙여줍니다. 매핑 테이블은
LOGON 시 한 번만 읽으므로 쿼리 부하 0.
캐시가 stale 해질 위험 — 권한 변경 후 즉시 반영 — 은 `ctx_pkg.init` 을 수동
호출하거나 재로그인으로 해소합니다. 운영에선 권한 변경 빈도가 낮으니 합리적
트레이드오프입니다.
---
## 데이터 흐름
```
+---------------------+
| vpduser_a / b | ← 사용자 로그인
+----------+----------+
|
(1) LOGON 트리거
|
v
+---------------------+
| ctx_pkg.init | → vpd_ctx 컨텍스트에 권한 set
+----------+----------+
|
(2) SELECT * FROM v_customers_pg
|
v
+---------------------+
| VPD 정책 함수 | → SYS_CONTEXT 읽고 WHERE 절 동적 생성
| (region IN (...)) |
+----------+----------+
|
(3) 뷰가 DB Link 로 원격 조회
|
+----------+----------+
| RDS_POSTGRES_LINK | (DBMS_CLOUD_ADMIN heterogeneous)
| OR |
| RDS_LINK (MySQL) |
+----------+----------+
|
v
+---------------------+
| 원격 customers | → 필터링된 행만 ADB 로 반환
+---------------------+
|
(4) Data Redaction 으로 이메일 마스킹
|
v
사용자 화면
```
---
## 권한 모델 (6 테이블)
| 테이블 | 의미 |
|---|---|
| `app_customer` | 도메인의 "고객사" 개념 (멀티 테넌트 hook) |
| `app_user` | DB 유저 → 도메인 유저 매핑 (`db_username` 컬럼이 핵심) |
| `app_group` | 그룹 (KR_ANALYSTS, GLOBAL_ADMINS, ...) |
| `user_group` | user ↔ group N:N |
| `db_source` | 원본 소스 식별자 (PG, MY) |
| `permission` | (group, source, region) 행 — `region='*'` 이면 전체 허용 |
→ "이 유저는 어떤 region 을 볼 수 있는가?" 는 **`app_user → user_group → permission` JOIN**
한 번이면 답이 나옵니다. 그게 정책 함수가 하는 일.
---
## 정책 함수 (`vpd_region_filter`)
`DBMS_RLS.ADD_POLICY` 가 뷰 호출 시마다 부르는 함수. 반환 문자열이 그대로
**WHERE 절 뒤에 AND 로 붙습니다.**
핵심 로직:
1. `SYS_CONTEXT('VPD_CTX', 'ALLOWED_REGIONS_PG')` 를 읽음
(값 예: `'APAC'` 또는 `'APAC,EMEA,AMER'` 또는 `NULL`)
2. NULL/빈 값 → `RETURN '1=0'` (아무것도 안 보임 = fail closed)
3. `'*'` 포함 → `RETURN NULL` (필터 없음 = 전체)
4. 그 외 → `RETURN 'region IN (''APAC'',''EMEA'')'` 형식으로 in-list
→ 컨텍스트는 **Secure Application Context** (`USING ctx_pkg`) 라 다른 패키지에서
설정 불가. `vpduser_a``DBMS_SESSION.SET_CONTEXT('VPD_CTX', ...)` 직접 호출 →
ORA-01031.
---
## 왜 뷰를 한 단계 더 두나?
뷰가 없으면 정책을 **DB Link 위에 직접** 걸어야 하는데, VPD 는 원격 객체를
직접 보호할 수 없습니다. 그래서:
* `v_customers_pg``SELECT ... FROM "public"."customers"@RDS_POSTGRES_LINK`
하는 단순 통과 뷰
* VPD 정책은 이 **로컬 뷰** 에 붙음
* 엔드유저에게는 뷰만 GRANT, DB Link 는 **EXECUTE 권한 X / 직접 보지 못함**
---
## 신뢰 경계
| 누가 | 무엇을 할 수 있나 |
|---|---|
| `ADMIN` (ADB 관리자) | 전부 다. 정책 BYPASS. **운영에선 절대 일반 사용자에게 주지 말 것** |
| `vpduser_a/b` | (a) 자기에게 GRANT 된 뷰 SELECT, (b) `ctx_pkg.init` 호출 |
| `vpduser_a/b`**못** 하는 것 | DBMS_RLS 변경, EXEMPT ACCESS POLICY, CREATE TABLE (스냅샷 방지), 매핑 테이블 직접 SELECT, DB Link 직접 SELECT |
→ 즉 엔드유저 입장에서 정책을 우회할 표면이 없습니다. `07_end_users.sql`
주석에서 "what we are deliberately NOT granting" 섹션이 그 목록.
---
## 한계 / 운영 고려사항
* **DB Link 비밀번호** 는 `DBMS_CLOUD.CREATE_CREDENTIAL` 로 ADB 안에 저장되지만,
ADMIN 은 평문에 가깝게 읽을 방법이 있습니다 (deferred admin 모델). 운영에선
IAM/Vault 기반 credential 회전이 필요.
* **LOGON 트리거 실패 = fail closed** (컨텍스트 비어있음 → `1=0`). 운영에선
실패시 audit table 기록 추가 권장.
* **성능**: 정책 함수는 statement-level 캐싱이 기본. 같은 세션에서 같은 statement
를 반복 실행해도 한 번만 호출됨. 하지만 통합 뷰가 DB Link 위에 있어 **원격
쿼리는 매번** 발생.
* **Redaction 정책**은 row level filter 와 별개로 동작 — 마스킹 컬럼이 추가되면
06a 에 추가 필요.

694
docs/03-detailed-guide.md Normal file
View File

@@ -0,0 +1,694 @@
# VPD 기반 권한 관리 POC — 상세 설명서
> **이 문서의 대상 독자:** 데이터베이스 보안이나 Oracle 기술에 익숙하지 않은 분.
> 용어가 나올 때마다 풀어서 설명하고, 왜 그 장치가 필요한지를 함께 적습니다.
---
## 목차
1. [무엇을 만들었고, 왜 만들었나](#1-무엇을-만들었고-왜-만들었나)
2. [핵심 개념 풀어보기](#2-핵심-개념-풀어보기)
3. [전체 구조도](#3-전체-구조도)
4. [구성 요소별 설명](#4-구성-요소별-설명)
5. [실제 동작 결과 (테스트 출력)](#5-실제-동작-결과-테스트-출력)
6. [“정말로 못 우회하나?” — 보안 보장 7 계층](#6-정말로-못-우회하나--보안-보장-7-계층)
7. [추가 강화 옵션 (운영 단계용)](#7-추가-강화-옵션-운영-단계용)
8. [현재 POC의 한계와 운영 시 고려사항](#8-현재-poc의-한계와-운영-시-고려사항)
9. [디렉터리·파일 구조](#9-디렉터리파일-구조)
10. [실행 방법](#10-실행-방법)
11. [데이터 카탈로그 인프라로서의 가능성 — Databricks Unity Catalog 와 비교](#11-데이터-카탈로그-인프라로서의-가능성--databricks-unity-catalog-와-비교)
---
## 1. 무엇을 만들었고, 왜 만들었나
### 1-1. 풀고 싶은 문제
회사에는 **여러 개의 데이터베이스**가 있습니다. 어떤 데이터는 Oracle에, 어떤 데이터는 MySQL에, 어떤 데이터는 PostgreSQL에 들어 있어요. 분석가, 운영자, 외부 협력사 사람들이 각자 필요한 데이터만 볼 수 있어야 하는데, 각 데이터베이스마다 권한을 따로 설정하면 다음과 같은 문제가 생깁니다.
- **관리가 흩어진다**: “이 사람한테 어디까지 보여주고 있더라?” 를 추적하기 어렵습니다.
- **일관성이 깨진다**: MySQL에는 권한을 줬는데 Postgres에는 못 줬더라, 같은 실수가 흔합니다.
- **변경이 느리다**: 한 사람의 권한을 바꾸려면 여러 시스템을 동시에 손봐야 합니다.
### 1-2. 해결 아이디어
이 POC의 핵심 아이디어는 **“하나의 관문(Oracle ADB)** 을 둬서 모든 사용자 질의(SELECT 문)를 거기로만 받게 하고, 그 관문 안에서 ‘이 사용자에게 어디까지 보여줄지’를 자동으로 결정하자”입니다.
- **관문**: Oracle Autonomous Database (이하 **ADB**). 우리가 정책을 적용할 단 한 곳.
- **외부 데이터들**: ADB가 **DB Link** 라는 통로로 MySQL/Postgres에 연결되어 있어, 마치 “ADB 안의 테이블”처럼 조회할 수 있습니다.
- **자동 결정**: 사용자가 `SELECT * FROM 고객테이블` 이라고만 쓰면, Oracle이 알아서 “이 사용자가 KR 분석가니까 region='APAC'인 행만 보여줘야지” 하고 `WHERE region='APAC'`**자동으로 붙여서** 실행합니다. 이걸 **VPD (Virtual Private Database)** 라고 합니다.
### 1-3. 한 줄 요약
> 사용자가 단순한 SELECT 한 줄을 던져도, Oracle이 그 사람의 신분증을 보고 알아서 “보여줄 수 있는 행만” 골라줍니다. 사용자는 자기가 필터 받았다는 것조차 모를 수 있습니다.
---
## 2. 핵심 개념 풀어보기
### 2-1. VPD (Virtual Private Database)란?
“가상 사설 데이터베이스”라는 뜻인데, 이름이 좀 어렵게 들리지만 개념은 단순합니다.
**비유**: 회사 도서관에 책이 1만 권 있다고 합시다. 사람마다 열람 가능한 책이 다릅니다.
- **방식 A (전통)**: 사람마다 다른 도서관 건물을 만든다. (= 데이터를 복제) → 비싸고, 동기화 안 됨.
- **방식 B (VPD)**: 도서관은 하나. 그런데 들어오는 사람의 신분증을 보고 사서가 **자동으로** 그 사람이 못 보는 책장 앞에 가림막을 친다. 그 사람은 1만 권 중 자기에게 허용된 책만 보입니다.
VPD는 “방식 B”의 사서 역할을 Oracle이 자동으로 해 주는 기능입니다.
### 2-2. DB Link란?
ADB 안에 앉아서 `SELECT * FROM customers@RDS_LINK` 라고 쓰면, ADB가 알아서 AWS RDS의 MySQL까지 다녀와서 결과를 보여줍니다. 즉, **“다른 DB로 가는 지름길”** 같은 것입니다. 사용자 입장에선 외부 DB가 어디에 있든 ADB 안의 테이블처럼 보입니다.
이 POC의 ADB에는 두 개의 DB Link가 이미 만들어져 있습니다.
| DB Link 이름 | 가리키는 곳 |
|---|---|
| `RDS_LINK` | AWS RDS MySQL (`ecommerce_poc` 스키마) |
| `RDS_POSTGRES_LINK` | AWS RDS PostgreSQL (`public` 스키마) |
### 2-3. Application Context (애플리케이션 컨텍스트)란?
사용자가 ADB에 로그인하면 그 세션(연결)이 살아 있는 동안 “이 사람은 누구다”, “어디까지 볼 수 있다” 같은 정보를 **세션 메모리**에 저장해 둘 수 있습니다. 이걸 컨텍스트라고 부릅니다.
**비유**: 도서관에 들어올 때 받는 “오늘 출입증 + 열람권”. 출입증을 받은 사람은 도서관 안에서 굳이 매번 신분증을 꺼내지 않아도 됩니다. 사서(=Oracle)가 그 사람의 출입증을 자동으로 들여다봅니다.
POC에서는 이 컨텍스트를 **`VPD_CTX`** 라는 이름으로 만들어서, 로그인할 때 자동으로 “이 사용자가 볼 수 있는 region 목록”을 채워 둡니다.
### 2-4. Secure Application Context (보안 컨텍스트)
그냥 컨텍스트는 누구나 자기 마음대로 “나는 관리자야!”라고 적어 넣을 수 있어서 위험합니다. 그래서 Oracle은 **“이 컨텍스트는 OO이라는 패키지(코드 묶음) 안에서만 설정될 수 있다”** 고 못 박을 수 있습니다.
POC에서는:
```sql
CREATE OR REPLACE CONTEXT vpd_ctx USING ctx_pkg;
```
이 한 줄이 핵심입니다. `vpd_ctx` 컨텍스트는 오직 `ctx_pkg` 라는 패키지 안의 코드만이 값을 채울 수 있습니다. 사용자가 직접 `DBMS_SESSION.SET_CONTEXT('VPD_CTX','...')` 를 호출하면 **`ORA-01031: insufficient privileges`** 로 거절당합니다.
### 2-5. LOGON 트리거란?
“사용자가 데이터베이스에 로그인하는 순간, 자동으로 실행되는 작은 프로그램”입니다. 사용자가 뭘 하기 전에 시스템이 먼저 “환영합니다, 당신의 권한을 세팅해 둘게요” 라고 준비하는 역할이죠.
POC에서는 VPDUSER_A 또는 VPDUSER_B가 ADB에 로그인하는 순간 트리거가 발동되어 `ctx_pkg.init` 을 부르고, 그 결과로 `vpd_ctx` 컨텍스트에 그 사용자의 권한이 채워집니다. **사용자가 막을 수 없습니다.** 트리거를 끄거나 우회할 권한 자체를 주지 않았기 때문입니다.
---
## 3. 전체 구조도
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ADB (Oracle Autonomous DB) │
│ │
│ ┌──────────────┐ │
│ │ VPDUSER_A │ ① 로그인 │
│ │ VPDUSER_B │ ─────────► ┌─────────────────────────┐ │
│ └──────────────┘ │ LOGON 트리거 │ │
│ │ │ = vpd_logon_trg │ │
│ │ └──────────┬──────────────┘ │
│ │ │ ② ctx_pkg.init 호출 │
│ │ ▼ │
│ │ ┌─────────────────────────┐ │
│ │ │ ctx_pkg.init │ │
│ │ │ - permission 테이블 조회 │ │
│ │ │ - SET_CONTEXT │ │
│ │ └──────────┬──────────────┘ │
│ │ │ ③ 세션 메모리에 권한 저장 │
│ │ ▼ │
│ │ ┌─────────────────────────┐ │
│ │ │ VPD_CTX (보안 컨텍스트) │ │
│ │ │ USER_ID=1 │ │
│ │ │ V_CUSTOMERS_PG='APAC' │ │
│ │ │ V_CUSTOMERS_MY='APAC' │ │
│ │ └─────────────────────────┘ │
│ │ │
│ │ ④ SELECT * FROM v_customers_pg │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ v_customers_pg (로컬 뷰) │ ← VPD 정책 부착 │
│ └──────┬──────────────────────┘ ⑤ vpd_region_filter 함수 호출 │
│ │ │ │
│ │ ▼ "region IN ('APAC')" 반환 │
│ │ │
│ │ ⑥ 사용자의 질의에 WHERE 절 자동 결합: │
│ │ SELECT * FROM "public"."customers"@RDS_POSTGRES_LINK │
│ │ WHERE region IN ('APAC') │
│ ▼ │
│ ┌────────────────────────────────────────────────┐ │
│ │ DB Link: RDS_POSTGRES_LINK / RDS_LINK │ │
│ └────────────────┬───────────────────────────────┘ │
└────────────────────┼───────────────────────────────────────────────────────┘
┌───────────────────────┐ ┌───────────────────────┐
│ AWS RDS PostgreSQL │ │ AWS RDS MySQL │
│ public.customers │ │ ecommerce_poc.customers│
└───────────────────────┘ └───────────────────────┘
```
---
## 4. 구성 요소별 설명
POC는 9개의 SQL 파일로 이루어져 있고, 각각 한 가지 역할을 합니다.
### 4-1. `sql/00_cleanup.sql` — 초기화
POC를 몇 번이고 새로 깔 수 있도록, 이전에 만든 것을 모두 지웁니다. 처음 실행할 땐 “없는데 지우라고?” 하는 에러가 나도 무시하도록 `EXCEPTION WHEN OTHERS THEN NULL` 처리를 합니다.
### 4-2. `sql/01_perm_tables.sql` — 권한 모델 (정책 평면)
“누가, 어느 그룹에 속하고, 그 그룹은 어느 데이터 소스의 어느 객체에서 어떤 행을 볼 수 있는가”를 저장하는 테이블 6개를 만듭니다.
```
app_customer ──┐
├── 회사 단위 격리 (멀티테넌트 대비)
app_user ──┤
│ ↑ 각 사용자는 1개 회사에 속함
app_group ──┤
│ ↑ 그룹도 회사 단위로 관리
user_group ──┘ ↑ 사용자 ↔ 그룹 N:M
db_source ── 외부 데이터 소스 정의 (어느 DB Link인지)
permission ── 핵심: 그룹 × 소스 × 객체 → 허용 region 목록
```
`permission.allowed_regions` 컬럼은 `'APAC'` 처럼 콤마로 구분된 값이거나 `'*'` 입니다. `'*'` 는 “모든 region 허용 = 필터 없음”을 뜻합니다.
### 4-3. `sql/02_seed.sql` — 시연용 데이터 주입
```
VPDUSER_A → KR_ANALYSTS 그룹 → APAC 만 조회 가능
VPDUSER_B → GLOBAL_ADMINS 그룹 → 전 region 조회 가능 (*)
```
이 두 사용자를 가지고 “같은 SQL 한 줄을 던졌을 때 결과가 다르다”는 것을 보일 겁니다.
### 4-4. `sql/03_secure_ctx.sql` — 보안 컨텍스트와 패키지
세 가지가 한 번에 만들어집니다.
1. **`ctx_pkg` 패키지의 명세**: `init` 이라는 프로시저가 있다고 선언.
2. **`ctx_pkg` 패키지의 본문**: 실제 로직.
- 지금 로그인한 Oracle 사용자 이름을 알아낸다 (`SYS_CONTEXT('USERENV','SESSION_USER')`).
- 그 사람을 `app_user` 테이블에서 찾는다.
- `user_group``permission` 을 따라가서 “이 사람이 어느 객체에 어떤 region 까지 허용됐는지” 조회.
- 결과를 `VPD_CTX` 컨텍스트 안에 객체별 속성으로 저장. (예: `VPD_CTX.V_CUSTOMERS_PG = 'APAC'`)
3. **`vpd_ctx` 보안 컨텍스트 자체**: `USING ctx_pkg` 라고 못 박아서, 다른 곳에서는 절대 이 값을 쓸 수 없게 막는다.
### 4-5. `sql/04_views.sql` — 외부 테이블에 대한 로컬 뷰
ADB 안에서 사용자가 보게 될 “테이블처럼 생긴 것”을 두 개 만듭니다. 둘 다 그저 외부 DB의 `customers` 테이블을 들여다보는 창문(view)일 뿐입니다.
- `v_customers_pg` → PostgreSQL의 `public.customers`
- `v_customers_my` → MySQL의 `ecommerce_poc.customers`
⚠️ **중요**: 사용자에게는 이 뷰만 보여줍니다. 뒤에 있는 `customers@RDS_POSTGRES_LINK` 같은 원본 참조는 **절대 직접 접근하게 두면 안 됩니다.** 그래야 정책을 우회할 수 없습니다.
### 4-6. `sql/05_policy.sql` — VPD 정책 함수와 부착
이게 마법의 핵심입니다.
1. **정책 함수 `vpd_region_filter`**: 사용자의 컨텍스트에서 허용 region 을 읽고, `region IN ('APAC')` 같은 문자열을 돌려준다.
- 만약 컨텍스트에 아무것도 없으면 → **`1=0` 을 돌려준다.** (= “행 0개” = **fail-closed 안전장치**)
- 만약 `'*'` 가 있으면 → `NULL` 을 돌려준다. (= 필터 없음 = 전부 보임)
2. **`DBMS_RLS.ADD_POLICY`** 로 위 함수를 `v_customers_pg`, `v_customers_my` 두 뷰에 “SELECT 문에 자동 결합되게” 부착.
이후 사용자가 `SELECT * FROM v_customers_pg` 라고 쓰면 Oracle이 내부적으로 `SELECT * FROM v_customers_pg WHERE region IN ('APAC')` 로 바꿔서 실행합니다. **사용자는 자기 쿼리에 WHERE이 추가됐다는 사실조차 모릅니다.**
### 4-7. `sql/06_end_users.sql` — 일반 사용자 계정과 LOGON 트리거
`VPDUSER_A`, `VPDUSER_B` 라는 일반 사용자 두 명을 만듭니다. 이 두 계정에 주는 권한은 **딱 4 가지**입니다.
```
✅ CREATE SESSION (로그인할 권리)
✅ SELECT on v_customers_pg
✅ SELECT on v_customers_my
✅ EXECUTE on ctx_pkg (트리거가 부를 수 있도록)
```
그 외에는 **아무것도 안 줍니다.** 특히:
```
❌ permission, app_user 등 권한 테이블 — 못 봄
❌ 원본 customers@DB_LINK — 못 봄
❌ DBMS_RLS — 정책을 끄거나 바꿀 권한 없음
❌ EXEMPT ACCESS POLICY — VPD를 무시할 시스템 권한 없음
❌ CREATE TABLE / CREATE MATERIALIZED VIEW — 필터된 결과를 스냅샷으로 빼낼 수 없음
❌ DBA 계열 ROLE 일체 없음
```
마지막으로 **LOGON 트리거** 를 만듭니다.
```sql
CREATE OR REPLACE TRIGGER vpd_logon_trg
AFTER LOGON ON DATABASE
BEGIN
IF SYS_CONTEXT('USERENV','SESSION_USER') IN ('VPDUSER_A','VPDUSER_B') THEN
admin.ctx_pkg.init;
END IF;
END;
```
이 트리거는 데이터베이스 전체 단위로 “누가 로그인하든” 발동되고, 그 사람이 우리가 관리하는 사용자면 컨텍스트를 자동으로 채웁니다.
### 4-8. `sql/07_tests_user_a.sql`, `08_tests_user_b.sql`
각 사용자가 직접 쿼리해서, 정말 자기에게 허용된 행만 보이는지 확인. 그리고 5가지 **우회 시도** 가 전부 실패하는지 확인.
### 4-9. `sql/09_tests_admin_audit.sql`
관리자(ADMIN) 입장에서 “지금 어떤 정책들이 어느 뷰에 부착돼 있나?”, “누구한테 어떤 권한이 주어졌나?” 를 한눈에 보기 위한 감사용 스크립트. 다음 네 가지를 출력합니다.
1. **Attached VPD policies**`DBA_POLICIES` 에서 `V_CUSTOMERS_PG`, `V_CUSTOMERS_MY` 에 붙은 정책(`CUSTOMERS_PG_POLICY`, `CUSTOMERS_MY_POLICY`) 과 활성화 상태.
2. **Attached Data Redaction policies**`REDACTION_POLICIES` 에서 `PII_REDACT_PG`, `PII_REDACT_MY` 의 게이팅 식(`SYS_CONTEXT('VPD_CTX','<뷰명>') IS NULL OR != '*'`) 을 그대로 확인.
3. **Redacted columns**`REDACTION_COLUMNS` 에서 어느 컬럼(`EMAIL`, `FULL_NAME`)이 어떤 정규식(`^(.)(.*)(@.*)$``\1****\3` 등)으로 마스킹되는지 표시.
4. **Permission summary**`app_user / user_group / app_group / permission / db_source` 조인으로 “누가 어느 그룹에 속해 어느 소스의 어느 뷰에서 어느 region 까지 보는지” 가 한눈에.
즉 이 한 스크립트가 행 단위(VPD) + 컬럼 단위(Redaction) + 권한 모델(permission 테이블) 세 계층을 모두 들여다봅니다.
---
## 5. 실제 동작 결과 (테스트 출력)
### 5-1. VPDUSER_A — KR 분석가, APAC만 허용
```
SQL> SELECT USER, SYS_CONTEXT('VPD_CTX','V_CUSTOMERS_PG') AS regions FROM dual;
USER REGIONS
---------- -------
VPDUSER_A APAC
SQL> SELECT customer_id, full_name, email, region FROM admin.v_customers_pg ORDER BY customer_id;
CUSTOMER_ID FULL_NAME EMAIL REGION
----------- ------------ --------------------- ------
3 A**** a****@example.com APAC
5 C**** c****@example.com APAC
```
이 한 결과에 **두 가지 보호 계층** 이 동시에 보입니다.
1. **행 단위 (VPD)** — 원본은 5건이지만 2건만 보입니다. `region IN ('NA','EMEA')` 행은 VPDUSER_A 의 세션에서는 “존재 자체를 모릅니다.” `WHERE region='NA'` 같은 조건을 걸어도 0건이 나옵니다.
2. **컬럼 단위 (Data Redaction)** — 보이는 2건의 `full_name``email` 도 원본이 아닙니다. `Alice Kim``A****`, `alice.kim@example.com``a****@example.com` 으로 마스킹돼서 내려옵니다. 정규식 마스킹은 ADB 엔진이 컬럼 표시 직전에 적용하므로, 클라이언트 어디서도 원본 값을 받아볼 수 없습니다.
### 5-2. VPDUSER_B — 글로벌 관리자, 전부 허용
```
SQL> SELECT customer_id, full_name, email, region FROM admin.v_customers_pg ORDER BY customer_id;
CUSTOMER_ID FULL_NAME EMAIL REGION
----------- -------------- --------------------------- ------
1 John Doe john.doe@example.com NA
2 Jane Smith jane.smith@example.com EMEA
3 Alice Kim alice.kim@example.com APAC
4 Bob Johnson bob.j@example.com NA
5 Charlie Lee charlie.lee@example.com APAC
```
같은 SQL, 다른 결과.
- **행 5건 다 보임** — VPDUSER_B 는 `allowed_regions='*'` 이므로 VPD 가 행 필터를 붙이지 않습니다.
- **컬럼도 원본 그대로** — 같은 `*` 권한이 Data Redaction 게이팅 식 (`SYS_CONTEXT('VPD_CTX','V_CUSTOMERS_PG') != '*'`) 을 통과하지 못하므로 마스킹이 작동하지 않고 원본을 그대로 반환합니다.
**같은 한 줄의 SQL 이 사용자 신원만 다를 뿐, 보이는 행 수도 다르고 PII 값도 다릅니다.** 이것이 VPD + Data Redaction 이 ADB 엔진 안에서 동작한 증거입니다.
### 5-3. 우회 시도 — 전부 실패
VPDUSER_A 가 시도해 본 5 가지 우회법은 모두 막혔습니다.
| 시도 | 결과 |
|---|---|
| ① 원본 외부 테이블 직접 조회 (`SELECT FROM ...@RDS_POSTGRES_LINK`) | ❌ `ORA-02019` (DB Link 자체를 모름) |
| ② 컨텍스트 위조 (`DBMS_SESSION.SET_CONTEXT(...)` 직접 호출) | ❌ `ORA-01031: insufficient privileges` |
| ③ VPD 정책 삭제 (`DBMS_RLS.DROP_POLICY`) | ❌ `PLS-00201: DBMS_RLS must be declared` (실행 권한 없음) |
| ④ 권한 테이블 조회 (`SELECT FROM admin.permission`) | ❌ `ORA-00942: table or view does not exist` |
| ⑤ 사용자 테이블 조회 (`SELECT FROM admin.app_user`) | ❌ `ORA-00942` |
---
## 6. “정말로 못 우회하나?” — 보안 보장 7 계층
VPD는 “Oracle 안에서” 강력하지만, “Oracle 밖” 까지 보장해 주진 않습니다. 다음 7 가지 장치가 함께 있어야 빈틈없이 막힙니다. 각 항목은 “만약 이걸 안 해두면 어떻게 뚫리나” 의 시나리오와 함께 설명합니다.
### 6-1. LOGON 트리거 — 항상 컨텍스트가 세팅된다
**뚫리는 시나리오 (장치가 없다면)**: 사용자가 컨텍스트를 안 채우고 그냥 쿼리. 정책 함수가 `NULL` 을 반환해서 “필터 없음” 으로 해석되면 전부 보임.
**막는 방법**: 로그인하는 그 순간 DB 엔진이 트리거를 강제로 실행. 사용자가 SQL을 한 줄도 치기 전에 컨텍스트가 채워진다. 클라이언트가 SQL\*Plus든 JDBC든 ORDS든 상관없이 적용.
### 6-2. Fail-Closed 정책 — 컨텍스트 없으면 “0행”
**뚫리는 시나리오 (장치가 없다면)**: 어떤 이유로 트리거가 실패하면 컨텍스트가 비어 있고, 정책이 “필터 없음 = 전부” 로 해석.
**막는 방법**: 정책 함수가 컨텍스트에 권한이 없으면 무조건 `1=0` 을 반환해서 행 0개. 사고 시 데이터가 새 나가지 않고 그냥 “안 보임” 으로 안전하게 실패한다.
### 6-3. Secure Application Context — 위조 불가
**뚫리는 시나리오 (장치가 없다면)**: 사용자가 `DBMS_SESSION.SET_CONTEXT('VPD_CTX','V_CUSTOMERS_PG','*')` 한 줄로 자기 권한을 무한대로 만든다.
**막는 방법**: `CREATE CONTEXT vpd_ctx USING ctx_pkg`. 이 컨텍스트는 오직 `ctx_pkg` 패키지 안에서 호출되는 SET_CONTEXT만 받는다. 사용자가 직접 호출하면 `ORA-01031` 로 거절.
### 6-4. 최소 권한 원칙 — 뷰 외에는 못 만진다
**뚫리는 시나리오 (장치가 없다면)**: 사용자가 권한 테이블을 직접 UPDATE 해서 자기 그룹의 `allowed_regions``*` 로 바꿔버린다.
**막는 방법**: 사용자에게 **테이블 조회·수정·정책 변경 권한을 일체 주지 않는다.** 오직 뷰의 SELECT 권한과 `ctx_pkg` 실행 권한만.
### 6-5. 외부 데이터 직접 접근 차단
**뚫리는 시나리오 (장치가 없다면)**: 사용자가 `SELECT * FROM customers@RDS_LINK` 를 직접 쳐서 VPD가 안 걸린 원본을 들여다본다.
**막는 방법**: DB Link 는 ADMIN 의 **PRIVATE DB Link**. 다른 사용자에게 사용 권한을 주지 않으면 그 이름 자체를 알 수 없고 (`ORA-02019: connection description not found`) 호출도 불가.
### 6-6. EXEMPT ACCESS POLICY 권한 차단
**뚫리는 시나리오 (장치가 없다면)**: 어떤 사용자에게 DBA 권한을 잘못 줘서 `EXEMPT ACCESS POLICY` 시스템 권한이 따라간다. 이 권한이 있으면 VPD가 통째로 무시된다.
**막는 방법**: 일반 사용자에겐 `DBA`, `PDB_DBA` 같은 묶음 권한을 **절대 부여하지 않는다.** POC에서는 `CREATE SESSION` 외에 ROLE 자체를 안 줬다.
### 6-7. 정책 조작 권한 차단
**뚫리는 시나리오 (장치가 없다면)**: 사용자가 `DBMS_RLS.DROP_POLICY('ADMIN','V_CUSTOMERS_PG','...')` 을 호출해서 정책을 끈다.
**막는 방법**: `DBMS_RLS` 패키지에 대한 EXECUTE 권한을 주지 않는다. 시도하면 `PLS-00201: identifier 'DBMS_RLS' must be declared` 로 거절.
---
## 7. 추가 강화 옵션 (운영 단계용)
POC 에서는 아래 옵션을 **문서화만** 해 둡니다. 실제 운영 환경에서는 다음 중 필요한 것을 추가하시면 됩니다.
### 7-1. LOGON 트리거를 “Hard-Fail” 모드로 바꾸기
지금은 트리거 안에 `EXCEPTION WHEN OTHERS THEN NULL` 이 있어서, 컨텍스트 로딩이 실패해도 로그인 자체는 성공합니다 (대신 정책이 fail-closed 라 데이터는 안 보임).
운영 환경에선 “문제가 있으면 아예 로그인 자체를 거부” 하는 편이 **모니터링상 명확** 합니다. 다음과 같이 바꿉니다.
```sql
CREATE OR REPLACE TRIGGER vpd_logon_trg
AFTER LOGON ON DATABASE
BEGIN
IF SYS_CONTEXT('USERENV','SESSION_USER') IN ('VPDUSER_A','VPDUSER_B') THEN
admin.ctx_pkg.init;
-- 컨텍스트가 비어 있으면 로그인 거부
IF SYS_CONTEXT('VPD_CTX','USER_ID') IS NULL THEN
RAISE_APPLICATION_ERROR(-20001,
'VPD context could not be loaded. Login blocked for safety.');
END IF;
END IF;
END;
/
```
이러면 트리거가 실패하거나 컨텍스트가 비어 있을 때 사용자는 **연결 자체가 안 됩니다.** “행이 안 보인다”가 아니라 “들어올 수 없다” 가 되므로 보안 사고 감지가 빨라집니다.
### 7-2. Proxy Authentication (대리 인증)
웹 애플리케이션, ORDS, APEX 등에서 자주 만나는 함정은 다음과 같습니다.
> 앱 서버가 **항상 ADMIN 계정으로** ADB에 접속한 뒤, “지금 로그인한 사용자는 김철수입니다” 같은 정보를 앱 코드 안에서만 들고 있는 경우. → ADB 입장에서 세션은 ADMIN 이므로 LOGON 트리거가 김철수 용으로 발동하지 않고, VPD가 김철수의 권한을 알 수가 없음. **모든 데이터가 다 보임.**
이를 막으려면 **Proxy Authentication** 을 씁니다.
```sql
-- 미리 한 번:
ALTER USER vpduser_a GRANT CONNECT THROUGH app_user;
ALTER USER vpduser_b GRANT CONNECT THROUGH app_user;
```
이제 앱 서버는 `app_user[vpduser_a]/password@db` 같은 식으로 접속합니다.
- 앱 서버는 vpduser_a 의 비밀번호를 모르고도,
- ADB 안에서 세션의 정체는 vpduser_a 가 되며,
- LOGON 트리거가 vpduser_a 용으로 정상 발동하고,
- VPD가 정상 동작합니다.
ORDS, APEX, JDBC 풀, Python 앱 모두 이 패턴을 지원합니다.
### 7-3. Unified Audit — 누가 무엇을 시도했는지 기록
VPD 가 막아도, “누가 막혔는지” 를 기록해두면 침입 시도 감지나 사고 조사에 좋습니다. ADB는 기본적으로 Unified Audit이 켜져 있고, 다음 같은 조건으로 감사 정책을 추가할 수 있습니다.
```sql
CREATE AUDIT POLICY vpd_view_access
ACTIONS SELECT ON admin.v_customers_pg,
SELECT ON admin.v_customers_my;
AUDIT POLICY vpd_view_access;
```
이후 `UNIFIED_AUDIT_TRAIL` 뷰에서 누가 언제 어떤 뷰를 조회했는지 다 보입니다.
### 7-4. 권한 변경의 즉시 반영
지금 구조에서 `permission` 테이블을 바꾸면, 이미 로그인되어 있는 사용자의 세션 컨텍스트는 **그대로** 입니다. 다음 로그인부터 적용됩니다. 즉시 반영이 필요하면 두 가지 방법이 있습니다.
1. **세션 강제 종료**: ADMIN 이 변경 후 그 사용자 세션을 `ALTER SYSTEM KILL SESSION` 으로 끊는다. 다음 로그인 때 새 권한 적용.
2. **정책 함수를 컨텍스트가 아닌 테이블 직접 조회로 작성**: 매 쿼리마다 `permission` 테이블을 읽으니 즉시 반영. **성능 비용** 이 큼 (POC 에서 피한 이유).
### 7-5. Materialized View / 결과 캐시 (성능 최적화)
지금 POC 는 “일단 동작” 에 초점이 있어, DB Link 너머에서 데이터를 다 가져온 뒤 ADB 가 필터링합니다. 큰 테이블에서는 느립니다. 운영 단계에서 가능한 최적화 옵션:
- **Materialized View**: 외부 데이터를 주기적으로 ADB 안으로 복제하고 그 위에 VPD 적용. 사용자는 ADB 안의 사본만 조회 → DB Link 가 매번 안 일어남.
- **Result Cache**: 같은 쿼리 결과를 메모리에 캐싱.
- **Predicate Pushdown**: 정책이 만든 WHERE 절을 외부 DB 쪽으로 밀어 보내기. 이종 DB(MySQL/Postgres) 에선 제한적.
---
## 8. 현재 POC의 한계와 운영 시 고려사항
| 항목 | 현재 POC 상태 | 운영 시 권장 |
|---|---|---|
| 정책 평면 스키마 | `ADMIN` 한 곳에 다 있음 | `VPD_OWNER` 같은 별도 스키마로 분리, ADMIN과 격리 |
| 패키지 권한 | `AUTHID DEFINER` | 그대로 (정책 함수도 DEFINER 가 맞음) |
| 권한 테이블 변경 즉시 반영 | 다음 로그인부터 적용 | 7-4 참조 |
| 성능 (DB Link) | Predicate pushdown 거의 안 됨 | 7-5 참조 |
| 사용자 비밀번호 관리 | 평문 비밀번호 | IAM 또는 Proxy Auth |
| 컨텍스트 위조 | 차단됨 (Secure Context) | 그대로 |
| 외부 DB 직접 접근 | DB Link 사용 권한 없어서 차단 | + 네트워크 단에서 RDS에 직접 접근 못 하도록 보안그룹 잠금 |
| 정책 조작 | 막혀 있음 | 그대로 |
| 감사 로깅 | 없음 | Unified Audit 추가 (7-3 참조) |
| LOGON 실패 정책 | Soft-fail (들어오긴 하나 데이터 0건) | Hard-fail (7-1 참조) |
| 정책 우회 위험성 검토 | 5 가지 시도 모두 차단 확인 | 신규 객체 추가할 때마다 동일 검증 절차 필요 |
---
## 9. 디렉터리·파일 구조
```
ords/
├── .env ← ADB 연결 정보 (비밀번호 포함, .gitignore 됨)
├── .gitignore ← .env, *.log 제외
├── README.md ← (선택) 실행 가이드
├── run_poc.sh ← 셋업/테스트/감사 일괄 실행 스크립트
├── sql/
│ ├── 00_cleanup.sql ← 초기화 (멱등성)
│ ├── 01_perm_tables.sql ← 권한 모델 6개 테이블 생성
│ ├── 02_seed.sql ← 데모 데이터 주입
│ ├── 03_secure_ctx.sql ← ctx_pkg + Secure Context 생성
│ ├── 04_views.sql ← 외부 테이블에 대한 로컬 뷰 2개
│ ├── 05_policy.sql ← VPD 정책 함수 + ADD_POLICY
│ ├── 06_end_users.sql ← 일반 사용자 2명 + LOGON 트리거
│ ├── 07_tests_user_a.sql ← VPDUSER_A 입장에서 검증
│ ├── 08_tests_user_b.sql ← VPDUSER_B 입장에서 검증
│ └── 09_tests_admin_audit.sql ← ADMIN 입장에서 정책·권한 현황 감사
└── docs/
└── 설명.md ← (이 문서)
```
---
## 10. 실행 방법
```bash
cd /Users/joungminko/devkit/ords
# 1) 전체 셋업 + 테스트 + 감사 한 번에
./run_poc.sh all
# 또는 단계별로:
./run_poc.sh setup # 정리 + 모든 객체 생성
./run_poc.sh test # VPDUSER_A, VPDUSER_B 시점 테스트
./run_poc.sh audit # ADMIN 시점 정책·권한 감사
./run_poc.sh teardown # 전부 삭제
# .env 파일이 있어야 합니다. (이 POC 에는 포함됨)
```
### 사람의 눈으로 직접 확인하고 싶을 때
```bash
source .env
# VPDUSER_A로 직접 들어가서 쿼리해보기:
sqlplus "vpduser_a/\"RowFilter#A2026\"@$ADB_TNS"
SQL> SELECT region, COUNT(*) FROM admin.v_customers_pg GROUP BY region;
-- 결과: APAC 만 보임
# VPDUSER_B로:
sqlplus "vpduser_b/\"RowFilter#B2026\"@$ADB_TNS"
SQL> SELECT region, COUNT(*) FROM admin.v_customers_pg GROUP BY region;
-- 결과: APAC, EMEA, NA 모두 보임
```
---
## 11. 데이터 카탈로그 인프라로서의 가능성 — Databricks Unity Catalog 와 비교
이 POC 를 단순히 “권한 관리” 가 아니라 **데이터 카탈로그 플랫폼의 기반(인프라)** 으로 확장할 수 있을까? 결론부터 말씀드리면:
> **“네, 가능합니다. 그리고 사실 ADB 자체에 데이터 카탈로그 기능이 이미 들어 있기 때문에, 우리가 만든 VPD 권한 계층과 결합하면 별도 제품 없이도 의미 있는 카탈로그 플랫폼이 됩니다.”**
### 11-1. ADB 가 “기본 탑재” 로 제공하는 카탈로그 기능들
ADB 의 **Database Actions / Data Studio** 콘솔 안에는 별도 라이선스 없이 쓸 수 있는 데이터 카탈로그 도구들이 포함되어 있습니다.
| 기능 | 어디에서 제공되나 | 무엇을 해 주나 |
|---|---|---|
| **Data Catalog** (Data Studio 안의 도구) | Database Actions → Data Studio → Catalog | 객체(Entity) 등록, 비즈니스 모델, 용어집(Term Glossary), 기본 데이터 계보(lineage) |
| **Sensitive Data Discovery (SDD)** | Data Studio → Data Analysis | 스키마를 스캔해서 PII(개인정보) 컬럼을 자동 탐지·태깅 |
| **Data Profiling** | Data Studio → Data Insights | 컬럼 통계, 분포, NULL 비율, 이상치 등 |
| **Business Glossary / Terms** | Data Catalog 안 | 비즈니스 용어를 데이터 객체와 매핑 |
| **Data Dictionary** | `ALL_TABLES`, `ALL_TAB_COLUMNS` 등 | 모든 카탈로그 도구의 기반이 되는 메타데이터 |
| **ORDS (REST 자동 노출)** | Database Actions → REST | 객체에 REST 엔드포인트를 자동 부여 |
| **APEX** | Database Actions → APEX | 권한·메타데이터 위에 노코드 UI 구축 |
| **Unified Audit** | DB 엔진 내장 | 모든 접근·실패 시도 감사 로그 |
즉, “카탈로그” 라는 말이 의미하는 대부분의 기능(탐색·분류·민감정보 태깅·프로파일링·용어집·기본 계보·감사) 이 **이미 ADB 안에 있습니다.** 우리가 새로 만든 것은 그 위에서 “행 단위 권한을 자동으로 적용하는 집행 계층” 입니다.
### 11-2. Unity Catalog 가 제공하는 것 vs. 우리 ADB + VPD 가 제공하는 것
| 기능 | ADB 기본 + 우리 POC | Unity Catalog | 메모 |
|---|---|---|---|
| 단일 정책 평면 (중앙 집중 권한 관리) | ✅ (POC 가 추가) | ✅ | 동등 |
| Row-level Security (행 단위 권한) | ✅ (POC: VPD) | ✅ | 동등 |
| Column-level Masking (컬럼 단위 마스킹) | ✅ (POC: Oracle Data Redaction, `email`/`full_name` 마스킹) | ✅ | 동등 |
| 이종(Multi-source) DB 통합 | ✅ DB Link / External Tables | ✅ External Location | 동등 |
| 사용자 신원 기반 집행 | ✅ LOGON + Context | ✅ 컴퓨트 계층 | 동등 |
| 메타데이터 카탈로그 (테이블·소유자·태그) | ✅ **Data Studio Catalog 내장** | ✅ | 동등 |
| 탐색 UI / 검색 | ✅ **Database Actions 내장** | ✅ | 동등 |
| 비즈니스 용어집 (Glossary) | ✅ **Data Catalog Terms 내장** | ✅ | 동등 |
| 민감정보 자동 분류 / 태깅 | ✅ **Sensitive Data Discovery 내장** | ✅ | 동등 |
| 데이터 프로파일링 | ✅ **Data Insights 내장** | ✅ | 동등 |
| 감사 로그 | ✅ Unified Audit 내장 | ✅ | 동등 |
| 기본 데이터 계보 (Lineage) | ⚠️ ADB 내부 한정 | ✅ 폭넓음 | 부분 동등 |
| 조직 간 데이터 공유 | ⚠️ Oracle Data Share 있으나 범위 좁음 | ✅ Delta Sharing | 약점 |
| **SQL 이외 경로(예: Spark on S3) 의 통제** | ❌ ADB 를 거치지 않으면 무력 | ✅ Databricks 컴퓨트 거치면 통제됨 | **이게 가장 큰 차이점** |
### 11-3. 가장 중요한 차이점 — “어디서” 정책이 집행되는가
**Unity Catalog 의 강점**: 정책을 **컴퓨트 계층(Spark, SQL Warehouse)** 에서 강제합니다. 즉, Databricks 에서 돌아가는 Spark 작업이 S3 의 Parquet 파일을 직접 읽어도 그 작업이 Databricks 컴퓨트 위에서 실행되는 한 정책이 적용됩니다.
**ADB+VPD 의 강점**: 정책을 **데이터베이스 내부(SQL parser)** 에서 강제합니다. 어떤 클라이언트(BI 도구, JDBC, ORDS, sqlplus...)에서 들어오든, **ADB 를 통해 들어오는 모든 쿼리는 무조건 막힙니다.** “정책을 호출하지 않으면 그만” 같은 회피가 불가능합니다.
**그렇다면 한계는?** ADB 는 “ADB 를 거치지 않는 접근” 은 막을 수 없습니다. 누군가 Spark 작업을 짜서 RDS 에 직접 붙으면 ADB 에는 아무 흔적이 안 남습니다. 이 빈틈은 두 가지로 메웁니다.
1. **아키텍처 규칙**: “이 데이터 소스들에 대한 모든 접근은 ADB 를 통해야 한다” 를 조직 규칙으로 못 박는다.
2. **네트워크 통제**: RDS 인스턴스를 외부에서 직접 못 부르도록 보안그룹/VPC 단에서 잠근다. 오직 ADB 만이 RDS 까지 도달 가능하게.
기술적 보장이 아니라 **설계 결정**입니다. 충분히 달성 가능하지만 기술이 자동으로 막아주는 게 아니라는 점을 인지하셔야 합니다.
### 11-4. ADB+VPD 를 카탈로그 인프라로 쓸 때의 진짜 강점
1. **집행이 수학적으로 보장된다.** VPD 는 SQL 파서 단계에서 적용됩니다. 어떤 클라이언트도, 어떤 BI 도구도, 어떤 드라이버도 이 정책을 건너뛸 수 없습니다. 앱 코드가 “정책 함수 호출하는 걸 깜빡했어요” 같은 사고가 원천 차단됩니다.
2. **데이터 이동이 필요 없다.** Unity Catalog 의 진가는 데이터가 Databricks 의 Delta Lake 안에 있을 때 발휘됩니다. ADB+VPD 는 **MySQL 은 MySQL 인 채로, Postgres 는 Postgres 인 채로** 통합 거버넌스를 줍니다.
3. **다종(polyglot) RDBMS 환경에 강하다.** 이종 DB Link 와 Database Gateway 로 Oracle + MySQL + Postgres + SQL Server + Db2 등을 한 시야에서 묶을 수 있습니다.
4. **SQL 네이티브.** Oracle Net 이나 JDBC 만 말할 줄 알면 어떤 BI 도구든 그대로 붙습니다. 별도 커넥터를 만들 필요가 없습니다.
5. **규제 산업 (금융·보험·의료) 에 매우 적합합니다.** “페타바이트 스케일” 보다 “감사 추적과 증명 가능한 집행” 이 더 중요한 영역에서 이 구조는 자연스럽습니다.
### 11-5. ADB+VPD 가 적합하지 않은 경우
- **레이크하우스 / 대용량 객체 스토리지 분석 (TB ~ PB 단위 스캔, ML 파이프라인 등).** 이종 DB Link Gateway 는 Predicate Pushdown 이 제한적이라, 큰 결과를 다 끌어와 로컬에서 필터링하는 일이 생깁니다.
- **이미 Spark 중심으로 굴러가는 조직.** 사용자가 굳이 ADB 를 거치지 않으려 들 가능성이 높습니다.
- **멀티 클라우드·멀티 계정 거버넌스가 이미 Unity / Lake Formation / Atlan 등으로 정착된 환경.**
### 11-6. 카탈로그 플랫폼으로 진화시킬 때의 권장 아키텍처 (수정판)
ADB 안에 이미 카탈로그 도구들이 들어 있다는 점을 반영하면 그림이 한층 단순해집니다. **별도 제품을 사 붙일 필요 없이 ADB 한 통 안에서 끝납니다.**
```
┌──────────────────────────────────────────────────────────────────┐
│ ADB (단일 박스) │
│ │
│ ① 탐색 / 메타데이터 / UI (전부 ADB 내장) │
│ ├─ Database Actions (웹 콘솔) │
│ ├─ Data Studio → Data Catalog (Entity, Glossary, Lineage) │
│ ├─ Data Studio → Sensitive Data Discovery (PII 자동 태깅) │
│ ├─ Data Studio → Data Insights (프로파일링) │
│ ├─ APEX (권한 관리 UI 노코드 구축) │
│ └─ ORDS (모든 객체 REST 자동 노출) │
│ │
│ ② 정책 평면 (오늘의 POC 가 추가한 부분) │
│ ├─ permission / group / user / source 테이블 │
│ ├─ Secure Application Context │
│ └─ Unified Audit (집행 결과·실패 시도 기록) │
│ │
│ ③ 집행 (ADB 엔진 안에서 자동 적용) │
│ ├─ VPD 정책 (행 단위) │
│ └─ Data Redaction (컬럼 단위, PII 마스킹) — 구현됨 │
│ │
│ ④ 데이터 소스 │
│ ├─ ADB 로컬 테이블 / 외부 테이블 (DBMS_CLOUD) │
│ └─ DB Link 로 묶인 RDS MySQL / Postgres / 다른 Oracle 등 │
└──────────────────────────────────────────────────────────────────┘
│ 모든 SQL 클라이언트 (BI, JDBC, ORDS, sqlplus...)
```
이 그림이 보여 주는 핵심은: **카탈로그 + 집행 + 감사 + UI 가 한 박스 안에 있다.** Unity Catalog 처럼 별도 컨트롤 플레인을 구입·설치할 필요가 없습니다.
### 11-7. POC 를 “진짜 카탈로그 시나리오” 로 확장하기
지금 POC 에 ADB 내장 카탈로그 기능을 결합하면 다음과 같은 **엔드-투-엔드 시나리오** 가 자연스럽게 완성됩니다. 이게 “카탈로그 + 거버넌스” 플랫폼이 실제 운영에서 어떻게 돌아가는지를 보여 주는 모습입니다.
#### 시나리오: PII 자동 인식 → 자동 마스킹 + 행 권한
1. **데이터 등록 (Data Studio Catalog)**
데이터 엔지니어가 `v_customers_pg`, `v_customers_my` 를 Data Catalog 에 “Customer” 라는 비즈니스 Entity 로 등록합니다. 용어집에 “Customer”, “Region” 같은 비즈니스 용어를 정의하고 매핑합니다.
2. **민감정보 자동 탐지 (Sensitive Data Discovery)**
ADB 가 두 뷰를 스캔해서 `email`, `full_name` 컬럼을 **PII** 로 자동 태깅합니다. 사람이 컬럼 하나하나 확인할 필요가 없습니다.
3. **정책 정의 (우리의 권한 모델 + Oracle Data Redaction)**
관리자가 카탈로그 UI 에서 결정합니다:
- **행 단위 (VPD)**: KR_ANALYSTS → APAC 만 — **구현됨**
- **컬럼 단위 (Data Redaction)**: PII 로 태깅된 컬럼은 KR_ANALYSTS 에게는 마스킹(`a****@example.com`, `A****`), GLOBAL_ADMINS 에게는 원본 그대로 — **구현됨** (`sql/05a_redaction.sql`)
4. **사용자 질의 — 모든 통제가 자동 적용**
VPDUSER_A 가 `SELECT * FROM v_customers_pg` 한 줄을 던지면:
- VPD 가 `region IN ('APAC')` 을 자동으로 결합 → 행 필터링
- Data Redaction 이 `email`, `full_name` 을 마스킹 → 컬럼 보호
- 두 정책 모두 사용자가 알지 못한 채 적용됨
5. **감사 (Unified Audit)**
누가 언제 어떤 뷰를 조회했고, 어떤 정책이 적용됐고, 거부된 시도는 무엇이었는지 모두 기록됩니다.
6. **거버넌스 운영 (APEX UI)**
비-DBA 인 데이터 거버넌스 담당자가 APEX 로 만들어진 화면에서 권한을 부여/회수합니다. SQL 을 한 줄도 안 씁니다.
이 흐름이 완성되면 **“우리 회사 데이터 카탈로그 플랫폼” 이라는 표현이 정당화** 됩니다.
#### 카탈로그 시나리오 확장 시 작업 우선순위
1. ~~**Data Redaction 정책 추가**~~**완료.** `sql/05a_redaction.sql` 에서 `email`, `full_name` 을 정규식 기반으로 마스킹. 정책 식은 `SYS_CONTEXT('VPD_CTX','<뷰명>') != '*'` 일 때만 마스킹 적용.
2. **Data Studio Catalog 에 두 뷰 등록 + Term Glossary 정의** — UI 작업, 코딩 거의 없음.
3. **Sensitive Data Discovery 실행** — 한 번의 클릭으로 PII 자동 태깅.
4. **APEX 권한 관리 앱**`permission` 테이블 위에 노코드 양식 폼 구축. (1~2일)
5. **Unified Audit 정책 활성화 + 대시보드** — 감사 로그를 운영자가 볼 수 있게.
6. **OCI Data Catalog 와 연계 (선택)** — 조직 전체의 다른 데이터 자산(데이터레이크, S3, OCI Object Storage 등)까지 시야를 넓힐 때.
### 11-8. 결론
| 질문 | 답 |
|---|---|
| ADB + VPD 를 “데이터 카탈로그 플랫폼” 이라고 말할 수 있는가? | **네, 정당화 가능합니다.** Database Actions / Data Studio 안에 이미 카탈로그 도구(Catalog, SDD, Insights, Glossary) 가 내장되어 있고, 우리 POC 가 추가한 권한 집행 계층이 거버넌스의 마지막 퍼즐을 채웁니다. |
| Unity Catalog 와 동등한 제품이 되는가? | **포지셔닝이 다릅니다.** Unity 는 “레이크하우스 + Spark 컴퓨트” 중심, 우리 구조는 “RDBMS·SQL 중심 거버넌스 + 집행 플랫폼”. 각각 잘 맞는 영역이 다릅니다. |
| 가장 큰 약점은? | ADB 를 거치지 않는 경로(예: Spark → RDS 직결, 객체 스토리지 직접 분석) 에 무력. 네트워크·아키텍처 정책으로 그 빈틈을 메워야 합니다. 계보(lineage) 도 ADB 가 보는 범위로 제한됩니다. |
| 가장 큰 강점은? | 집행이 SQL 파서 안에서 일어나서 **앱 코드가 절대 우회할 수 없습니다.** 그리고 **모든 카탈로그 기능이 한 박스(ADB) 안에 있어** 별도 제품 도입·통합 비용이 없습니다. |
| 다음 단계로 추천하는 것? | 11-7 의 6단계 — Data Redaction 은 이미 구현됨. 남은 항목 중 (1) Data Catalog 등록 + SDD (2) APEX 권한 관리 UI 까지만 추가해도 “카탈로그 플랫폼” 이라고 부르기 충분한 형태가 됩니다. |
---
## 마무리
이 POC가 보여주는 핵심은 단 하나입니다.
> **권한 정책을 데이터베이스 한 곳에 모아두고, 사용자의 SQL은 그대로 두면서 Oracle이 자동으로 알맞게 필터링한다.**
사용자는 `WHERE` 절을 매번 쓸 필요가 없고, 개발자는 권한 로직을 앱 코드에 흩뿌릴 필요가 없고, 보안 담당자는 한 곳만 보면 누가 어디까지 보는지 알 수 있습니다. 그리고 위 6번에서 본 7 가지 계층이 함께 있어, “VPD 만 믿었는데 우회됐다” 같은 시나리오는 발생하기 어렵습니다.
운영 단계로 넘어갈 때는 7번 절의 강화 옵션과 8번 절의 한계 표를 점검 리스트로 활용하시면 됩니다.

99
docs/04-source-setup.md Normal file
View File

@@ -0,0 +1,99 @@
# 04 · Source DB Setup
원격 Postgres 와 MySQL 에 무엇이 만들어지는지, 또 ADB 가 어떻게 접속하는지.
---
## Postgres (`sql/source/postgres_setup.sql`)
```
DB : 사용자가 .env 에서 지정 (PG_DB, 기본값 vpdpoc)
schema : public
table : public.customers
PK : customer_id (INTEGER)
컬럼 : full_name TEXT, email TEXT, signup_date DATE,
region VARCHAR(8) -- CHECK IN ('APAC','EMEA','AMER')
seed rows : 12 (APAC 4 / EMEA 4 / AMER 4)
멱등성 : ON CONFLICT (customer_id) DO NOTHING
```
수동 실행:
```bash
PGPASSWORD=$PG_PASSWORD psql \
-h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \
-f sql/source/postgres_setup.sql
```
---
## MySQL (`sql/source/mysql_setup.sql`)
```
DB : MY_DB (기본 ecommerce_poc)
table : customers
PK : customer_id (INT)
컬럼 : 동일 스키마. region CHECK 제약은 chk_region 로 표현
seed rows : 12 (PK 101~112, 역시 APAC/EMEA/AMER 4/4/4)
멱등성 : INSERT IGNORE
```
PK 범위를 PG (1~12) 와 다르게 가져간 이유:
* 두 소스는 서로 독립적인 별개의 데이터 라는 것을 데모에서 시각적으로 보여주기 위함
* `vpduser_b` 가 양쪽 뷰를 합칠 때 PK 가 겹치지 않아 UNION 데모하기 쉬움
수동 실행:
```bash
mysql -h $MY_HOST -P $MY_PORT -u $MY_USER -p"$MY_PASSWORD" $MY_DB \
< sql/source/mysql_setup.sql
```
---
## ADB → 원격 DB Link (`sql/adb/01_dblinks.sql`)
`DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 가 ADB 안에서 직접 heterogeneous 연결을
처리합니다. 별도 Database Gateway 설치 불필요.
```sql
DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK(
db_link_name => 'RDS_POSTGRES_LINK',
hostname => '<PG_HOST>',
port => 5432,
service_name => '<PG_DB>',
credential_name => 'RDS_POSTGRES_LINK_CRED', -- DBMS_CLOUD.CREATE_CREDENTIAL 로 등록
gateway_params => JSON_OBJECT('db_type' VALUE 'postgres')
);
```
MySQL 도 동일 패턴, `db_type``'mysql'`.
링크 검증:
```sql
SELECT COUNT(*) FROM "public"."customers"@RDS_POSTGRES_LINK;
SELECT COUNT(*) FROM "ecommerce_poc"."customers"@RDS_LINK;
```
---
## 원격 식별자 인용 (quoting) 주의
| 원본 DB | 식별자 quoting | ADB 에서 호출 시 |
|---|---|---|
| Postgres | 소문자 보존하려면 `"public"."customers"` (대소문자 구분) | `"public"."customers"@RDS_POSTGRES_LINK` |
| MySQL | 일반적으로 무관 (`\`customers\``) | `"ecommerce_poc"."customers"@RDS_LINK` (ADB 게이트웨이가 자동 매핑) |
→ 잘못 쓰면 `ORA-00942: table or view does not exist` 가 뜨는데, 원인이 권한이
아니라 **이름 케이스** 인 경우가 많습니다.
---
## 네트워크 체크리스트
* RDS Public Access: ON (또는 ADB ↔ RDS 같은 VCN/peer)
* Security Group: ADB egress IP (또는 0.0.0.0/0 임시) 가 5432/3306 으로 도달 가능
* Local 테스트: `nc -zv $PG_HOST 5432` / `nc -zv $MY_HOST 3306` 가 먼저 통해야
`run.sh source` 도 통합니다.

183
run.sh Executable file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# ============================================================
# run.sh — VPD Permission POC 원클릭 오케스트레이터
#
# 사용법:
# ./run.sh # = ./run.sh all
# ./run.sh prereq # 도구 존재 / .env 변수만 검증
# ./run.sh source # 원격 PG + MySQL 에 customers 테이블/seed 생성
# ./run.sh adb # ADB 측 cleanup → dblinks → perm/ctx/view/policy → end_users
# ./run.sh tests # vpduser_a / vpduser_b 로 접속해서 행 필터 검증
# ./run.sh audit # admin 으로 정책/뷰/유저 상태 점검
# ./run.sh all # source → adb → tests → audit
# ./run.sh teardown # ADB 측 객체 + 원격 link/cred 만 정리 (원격 PG/MySQL 데이터는 보존)
#
# 환경설정:
# cp .env.example .env → 값 채우고 → ./run.sh
# ============================================================
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
# --- 1) .env 로드 ---
if [[ ! -f "$ROOT/.env" ]]; then
echo "[FAIL] .env 가 없습니다. cp .env.example .env 후 값을 채워주세요." >&2
exit 1
fi
# shellcheck disable=SC1091
set -a; . "$ROOT/.env"; set +a
# --- 2) 공용 함수 로드 ---
# shellcheck disable=SC1091
. "$ROOT/scripts/lib/common.sh"
CMD="${1:-all}"
# ============================================================
# 헬퍼: ADB sqlplus 한 번 호출
# 인자: $1 = SQL 파일 경로 (DEFINE 변수는 환경변수에서 합성)
# ADB_PASSWORD 가 비어있으면 prompt.
# ============================================================
run_sqlplus_file() {
local sql_file="$1"
[[ -f "$sql_file" ]] || die "SQL 파일 없음: $sql_file"
log "sqlplus ← $sql_file"
# heredoc 으로 DEFINE 주입 후 @sql_file 실행.
# 비번은 stdin 으로 흘려보내고 셸 트레이스/echo 에 안 남게 한다.
sqlplus -S -L "${ADB_USER}/${ADB_PASSWORD}@${ADB_TNS}" <<SQLEOF
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE ON
DEFINE DBLINK_PG_NAME = "${DBLINK_PG_NAME}"
DEFINE DBLINK_MY_NAME = "${DBLINK_MY_NAME}"
DEFINE PG_HOST = "${PG_HOST}"
DEFINE PG_PORT = ${PG_PORT}
DEFINE PG_DB = "${PG_DB}"
DEFINE PG_USER = "${PG_USER}"
DEFINE PG_PASSWORD = "${PG_PASSWORD}"
DEFINE MY_HOST = "${MY_HOST}"
DEFINE MY_PORT = ${MY_PORT}
DEFINE MY_DB = "${MY_DB}"
DEFINE MY_USER = "${MY_USER}"
DEFINE MY_PASSWORD = "${MY_PASSWORD}"
DEFINE VPDUSER_A_PASSWORD = "${VPDUSER_A_PASSWORD}"
DEFINE VPDUSER_B_PASSWORD = "${VPDUSER_B_PASSWORD}"
@${sql_file}
SQLEOF
}
# ============================================================
# 헬퍼: 엔드유저 (vpduser_a / vpduser_b) 로 sqlplus 호출
# 인자: $1 = USER, $2 = PASSWORD, $3 = SQL 파일
# ============================================================
run_sqlplus_as() {
local user="$1" pw="$2" sql_file="$3"
[[ -f "$sql_file" ]] || die "SQL 파일 없음: $sql_file"
log "sqlplus(${user}) ← $sql_file"
sqlplus -S -L "${user}/${pw}@${ADB_TNS}" <<SQLEOF
WHENEVER SQLERROR CONTINUE
@${sql_file}
SQLEOF
}
# ============================================================
# 단계별 함수
# ============================================================
do_prereq() {
log "=== prereq: 도구 및 환경변수 검증 ==="
need_cmd sqlplus "Oracle Instant Client (sqlplus) PATH 등록 필요"
need_cmd psql "PostgreSQL client (psql) 설치 필요 — brew install libpq 등"
need_cmd mysql "MySQL client 설치 필요 — brew install mysql-client 등"
require_env \
TNS_ADMIN ADB_TNS ADB_USER \
VPDUSER_A_PASSWORD VPDUSER_B_PASSWORD \
PG_HOST PG_PORT PG_DB PG_USER \
MY_HOST MY_PORT MY_DB MY_USER \
DBLINK_PG_NAME DBLINK_MY_NAME
prompt_password_if_empty ADB_PASSWORD "ADB ADMIN"
prompt_password_if_empty PG_PASSWORD "PostgreSQL (${PG_USER}@${PG_HOST})"
prompt_password_if_empty MY_PASSWORD "MySQL (${MY_USER}@${MY_HOST})"
[[ -d "$TNS_ADMIN" ]] || die "TNS_ADMIN 디렉토리가 존재하지 않음: $TNS_ADMIN"
[[ -f "$TNS_ADMIN/tnsnames.ora" ]] || warn "tnsnames.ora 가 $TNS_ADMIN 에 없음 — 확인 필요"
ok "prereq 통과"
}
do_source() {
log "=== source: 원격 Postgres / MySQL 에 customers 테이블 + seed ==="
log "-- Postgres ($PG_HOST:$PG_PORT/$PG_DB) --"
PGPASSWORD="$PG_PASSWORD" psql \
-h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \
-v ON_ERROR_STOP=1 \
-f "$ROOT/sql/source/postgres_setup.sql"
ok "Postgres source 준비 완료"
log "-- MySQL ($MY_HOST:$MY_PORT/$MY_DB) --"
mysql \
-h "$MY_HOST" -P "$MY_PORT" -u "$MY_USER" "-p${MY_PASSWORD}" \
"$MY_DB" < "$ROOT/sql/source/mysql_setup.sql"
ok "MySQL source 준비 완료"
}
do_adb() {
log "=== adb: ADB 측 cleanup → dblinks → perm/ctx/view/policy → end_users ==="
run_sqlplus_file "$ROOT/sql/adb/00_cleanup.sql"
run_sqlplus_file "$ROOT/sql/adb/01_dblinks.sql"
run_sqlplus_file "$ROOT/sql/adb/02_perm_tables.sql"
run_sqlplus_file "$ROOT/sql/adb/03_seed.sql"
run_sqlplus_file "$ROOT/sql/adb/04_secure_ctx.sql"
run_sqlplus_file "$ROOT/sql/adb/05_views.sql"
run_sqlplus_file "$ROOT/sql/adb/06_policy.sql"
run_sqlplus_file "$ROOT/sql/adb/06a_redaction.sql"
run_sqlplus_file "$ROOT/sql/adb/07_end_users.sql"
ok "ADB setup 완료"
}
do_tests() {
log "=== tests: 엔드유저 권한/필터 검증 ==="
run_sqlplus_as vpduser_a "$VPDUSER_A_PASSWORD" "$ROOT/sql/adb/08_tests_user_a.sql"
run_sqlplus_as vpduser_b "$VPDUSER_B_PASSWORD" "$ROOT/sql/adb/09_tests_user_b.sql"
ok "user_a / user_b 테스트 실행 완료 (위 출력에서 행 수 / 거부 결과 확인)"
}
do_audit() {
log "=== audit: admin 으로 정책 / 뷰 / 유저 상태 점검 ==="
run_sqlplus_file "$ROOT/sql/adb/10_tests_admin_audit.sql"
ok "audit 완료"
}
do_teardown() {
log "=== teardown: ADB 측 객체 + dblink/credential 정리 ==="
warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)"
run_sqlplus_file "$ROOT/sql/adb/00_cleanup.sql"
ok "teardown 완료"
}
# ============================================================
# 디스패치
# ============================================================
case "$CMD" in
prereq) do_prereq ;;
source) do_prereq; do_source ;;
adb) do_prereq; do_adb ;;
tests) do_prereq; do_tests ;;
audit) do_prereq; do_audit ;;
teardown) do_prereq; do_teardown ;;
all)
do_prereq
do_source
do_adb
do_tests
do_audit
ok "=== ALL DONE — VPD POC 전체 파이프라인 통과 ==="
;;
*)
die "알 수 없는 명령: $CMD (사용: prereq|source|adb|tests|audit|all|teardown)"
;;
esac

40
scripts/lib/common.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# 공용 함수 — log/ok/warn/die, 의존성 검사, 비밀번호 프롬프트
# scripts/lib/common.sh
RED='\033[0;31m'
GRN='\033[0;32m'
YLW='\033[0;33m'
BLU='\033[0;34m'
NC='\033[0m'
log() { printf "${BLU}[%s]${NC} %s\n" "$(date +%H:%M:%S)" "$*" >&2; }
ok() { printf "${GRN}[ OK ]${NC} %s\n" "$*" >&2; }
warn() { printf "${YLW}[WARN]${NC} %s\n" "$*" >&2; }
die() { printf "${RED}[FAIL]${NC} %s\n" "$*" >&2; exit 1; }
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "필수 명령 누락: $1 ($2)"
}
require_env() {
local missing=()
for v in "$@"; do
[[ -z "${!v:-}" ]] && missing+=("$v")
done
if (( ${#missing[@]} > 0 )); then
die ".env 누락 변수: ${missing[*]}"
fi
}
prompt_password_if_empty() {
# prompt_password_if_empty VAR_NAME "사람이 읽을 라벨"
local var="$1" label="$2"
if [[ -z "${!var:-}" ]]; then
printf "%s 비밀번호: " "$label" >&2
read -rs pw
echo >&2
[[ -n "$pw" ]] || die "$label 비밀번호가 비어있습니다"
export "$var=$pw"
fi
}

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 ===