commit 68d53dc5a916d93a412fdf61cc3aa6f58fefbdeb Author: devmrko Date: Tue May 26 14:03:32 2026 +0900 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fabeedd --- /dev/null +++ b/.env.example @@ -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//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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ce7e24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# 환경/비밀 — 절대 commit 금지 +.env +.env.* +!.env.example + +# Oracle wallet +wallet/ +*.sso +*.p12 +*.pem +*.key + +# 로컬 실행 로그 +logs/ +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..876b390 --- /dev/null +++ b/README.md @@ -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//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. diff --git a/docs/01-quickstart.md b/docs/01-quickstart.md new file mode 100644 index 0000000..b2daf62 --- /dev/null +++ b/docs/01-quickstart.md @@ -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 | diff --git a/docs/02-architecture.md b/docs/02-architecture.md new file mode 100644 index 0000000..1f8f08d --- /dev/null +++ b/docs/02-architecture.md @@ -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', '') 에 저장 ├─ '*' → 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 에 추가 필요. diff --git a/docs/03-detailed-guide.md b/docs/03-detailed-guide.md new file mode 100644 index 0000000..69a7d69 --- /dev/null +++ b/docs/03-detailed-guide.md @@ -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번 절의 한계 표를 점검 리스트로 활용하시면 됩니다. diff --git a/docs/04-source-setup.md b/docs/04-source-setup.md new file mode 100644 index 0000000..99f5378 --- /dev/null +++ b/docs/04-source-setup.md @@ -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 => '', + port => 5432, + service_name => '', + 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` 도 통합니다. diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..2e03257 --- /dev/null +++ b/run.sh @@ -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}" <&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 +} diff --git a/sql/adb/00_cleanup.sql b/sql/adb/00_cleanup.sql new file mode 100644 index 0000000..1193ee0 --- /dev/null +++ b/sql/adb/00_cleanup.sql @@ -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; diff --git a/sql/adb/01_dblinks.sql b/sql/adb/01_dblinks.sql new file mode 100644 index 0000000..c922b79 --- /dev/null +++ b/sql/adb/01_dblinks.sql @@ -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 diff --git a/sql/adb/02_perm_tables.sql b/sql/adb/02_perm_tables.sql new file mode 100644 index 0000000..ee3beb5 --- /dev/null +++ b/sql/adb/02_perm_tables.sql @@ -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; diff --git a/sql/adb/03_seed.sql b/sql/adb/03_seed.sql new file mode 100644 index 0000000..804ac34 --- /dev/null +++ b/sql/adb/03_seed.sql @@ -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; diff --git a/sql/adb/04_secure_ctx.sql b/sql/adb/04_secure_ctx.sql new file mode 100644 index 0000000..879ca89 --- /dev/null +++ b/sql/adb/04_secure_ctx.sql @@ -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; diff --git a/sql/adb/05_views.sql b/sql/adb/05_views.sql new file mode 100644 index 0000000..07063ba --- /dev/null +++ b/sql/adb/05_views.sql @@ -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; diff --git a/sql/adb/06_policy.sql b/sql/adb/06_policy.sql new file mode 100644 index 0000000..bfe2b21 --- /dev/null +++ b/sql/adb/06_policy.sql @@ -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; diff --git a/sql/adb/06a_redaction.sql b/sql/adb/06a_redaction.sql new file mode 100644 index 0000000..b63335a --- /dev/null +++ b/sql/adb/06a_redaction.sql @@ -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; diff --git a/sql/adb/07_end_users.sql b/sql/adb/07_end_users.sql new file mode 100644 index 0000000..7070709 --- /dev/null +++ b/sql/adb/07_end_users.sql @@ -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; diff --git a/sql/adb/08_tests_user_a.sql b/sql/adb/08_tests_user_a.sql new file mode 100644 index 0000000..ad9f3b5 --- /dev/null +++ b/sql/adb/08_tests_user_a.sql @@ -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; diff --git a/sql/adb/09_tests_user_b.sql b/sql/adb/09_tests_user_b.sql new file mode 100644 index 0000000..2c95952 --- /dev/null +++ b/sql/adb/09_tests_user_b.sql @@ -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; diff --git a/sql/adb/10_tests_admin_audit.sql b/sql/adb/10_tests_admin_audit.sql new file mode 100644 index 0000000..19945f2 --- /dev/null +++ b/sql/adb/10_tests_admin_audit.sql @@ -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; diff --git a/sql/source/mysql_setup.sql b/sql/source/mysql_setup.sql new file mode 100644 index 0000000..82530e6 --- /dev/null +++ b/sql/source/mysql_setup.sql @@ -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; diff --git a/sql/source/postgres_setup.sql b/sql/source/postgres_setup.sql new file mode 100644 index 0000000..332b802 --- /dev/null +++ b/sql/source/postgres_setup.sql @@ -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 ===