diff --git a/docs/design/458-vpd-redaction-regression/README.md b/docs/design/458-vpd-redaction-regression/README.md new file mode 100644 index 0000000..3f2ab4e --- /dev/null +++ b/docs/design/458-vpd-redaction-regression/README.md @@ -0,0 +1,87 @@ +# 설계서: VPD/Redaction 회귀 테스트 매트릭스 자동화 (#458) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-25 +> **추적성** — Redmine: #458 · 관련 ADR: 없음 +> · 구현 파일: `scripts/vpd-ords-regression-test.sh`, `run.sh` · 테스트: `./run.sh backoffice-vpd-ords-test`, `mvn test` + +## 1. 목적 (Why) + +VPD filter, ORDS handler, Redaction 설정 변경 후에도 Bearer Token별 조회 행 수와 민감 컬럼 노출 결과가 유지되는지 자동으로 검증한다. + +## 2. 범위 (Scope) + +- **포함**: 임시 bearer token 생성, ORDS POST 호출, 응답 JSON 검증, token revoke, 실패 증거 파일 저장. +- **제외**: ORDS metadata 생성, VPD policy 생성, UI E2E 브라우저 테스트. + +## 3. 인수조건 (Acceptance Criteria) + +- [ ] SQLcl로 임시 token을 생성하고 종료 시 revoke한다. +- [ ] HR token은 3행 `[1,2,6]`, `CONTENTS` 미노출을 검증한다. +- [ ] SELF token은 1행 `[3]`, `CONTENTS` 미노출을 검증한다. +- [ ] ALL token은 6행 `[1,2,3,4,5,6]`, `CONTENTS` 노출을 검증한다. +- [ ] 실패 시 request/response 파일 위치를 출력한다. +- [ ] `./run.sh backoffice-vpd-ords-test`로 실행할 수 있다. + +## 4. 컨텍스트 & 제약 + +- SQLcl이 필요하다. `SQLCL_BIN`이 있으면 그 값을 쓰고, 없으면 PATH의 `sql`을 찾는다. +- DB 접속은 `.env`의 `BACKOFFICE_DB_USERNAME`, `BACKOFFICE_DB_PASSWORD`, `ADB_TNS`를 사용한다. +- ORDS base URL은 `BACKOFFICE_ORDS_BASE_URL`이 있으면 쓰고, 없으면 현재 백오피스 기본값을 사용한다. +- token 원문은 출력하지 않는다. + +## 5. 아키텍처 개요 + +``` +run.sh backoffice-vpd-ords-test + -> scripts/vpd-ords-regression-test.sh + -> SQLcl INSERT temp tokens + -> curl POST ORDS documents endpoint + -> Python JSON assertions + -> SQLcl revoke temp tokens +``` + +## 6. 데이터 모델 + +- 테스트 token description: `codex issue458 regression`. +- 사용자 매트릭스: + - `101`: HR 부서 권한 + - `102`: SELF 권한 + - `103`: ALL 권한 + `CONTENTS` 노출 권한 + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `run_sqlcl` | SQLcl에 SQL stdin을 전달 | bash function | SQL text | SQLcl output | SQLcl exit code | 단순 | +| `call_and_assert` | ORDS 호출 후 JSON 결과 검증 | bash function | label, token, expected rows | PASS/FAIL | 실패 시 exit 1 | **복잡** | + +## 8. 흐름 / 알고리즘 + +1. `.env`를 로드한다. +2. SQLcl/JDK/DB/ORDS 환경을 확인한다. +3. UUID 기반 token 3개를 생성한다. +4. SQLcl로 token hash를 DB에 insert한다. +5. ORDS endpoint를 호출해 `items` 행 수, DOC_ID, CONTENTS 노출 수를 검증한다. +6. 성공/실패와 관계없이 token을 revoke한다. + +## 9. 엣지케이스 & 에러 처리 + +- SQLcl 없음: 실행 전 명확한 메시지로 실패. +- ORDS 200 아님: response body 파일 경로 출력. +- JSON parse 실패: response body 일부와 파일 경로 출력. +- 중간 실패: cleanup trap으로 token revoke 시도. + +## 10. 테스트 계획 + +- `SQLCL_BIN=/tmp/sqlcl/sqlcl/bin/sql ./run.sh backoffice-vpd-ords-test` +- `mvn test` + +## 11. 리스크 & 대안 검토 + +- 선택: bash + SQLcl + curl + Python 표준 json. 배포 의존성을 추가하지 않고 운영자가 그대로 실행할 수 있다. +- 대안: JUnit 통합 테스트. Maven에서 외부 ORDS/ADB를 항상 요구하게 되어 로컬 단위 테스트가 불안정해진다. + +## 12. 미해결 질문 (Open Questions) + +- SQLcl/JDK 경로 자동 탐지는 #460 운영 런북에서 더 정리한다. diff --git a/run.sh b/run.sh index ddb4574..9d0dd91 100755 --- a/run.sh +++ b/run.sh @@ -10,6 +10,7 @@ # ./run.sh tests # 4-user (my/pg/both/none) 로 접속해서 행 필터 검증 # ./run.sh audit # admin 으로 정책/뷰/유저 상태 점검 # ./run.sh backoffice-support # Spring Boot 백오피스 보조 객체 생성 +# ./run.sh backoffice-vpd-ords-test # SQLcl + ORDS VPD/Redaction 회귀 테스트 # ./run.sh all # source → adb → tests → audit # ./run.sh teardown # ADB 측 객체 + 원격 link/cred 만 정리 (원격 PG/MySQL 데이터는 보존) # @@ -174,6 +175,12 @@ do_backoffice_support() { ok "backoffice-support 완료" } +do_backoffice_vpd_ords_test() { + log "=== backoffice-vpd-ords-test: SQLcl + ORDS VPD/Redaction 회귀 테스트 ===" + bash "$ROOT/scripts/vpd-ords-regression-test.sh" + ok "backoffice-vpd-ords-test 완료" +} + do_teardown() { log "=== teardown: ADB 측 객체 + dblink/credential 정리 ===" warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)" @@ -226,6 +233,7 @@ case "$CMD" in tests) do_prereq; do_tests ;; audit) do_prereq; do_audit ;; backoffice-support) do_prereq; do_backoffice_support ;; + backoffice-vpd-ords-test) do_backoffice_vpd_ords_test ;; teardown) do_prereq; do_teardown ;; all) do_prereq @@ -245,6 +253,6 @@ case "$CMD" in ok "=== DDS DONE — Deep Data Security 변형 셋업 + 검증 통과 ===" ;; *) - die "알 수 없는 명령: $CMD (사용: prereq|source|adb|tests|audit|backoffice-support|all|teardown | dds|dds-setup|dds-tests|dds-teardown)" + die "알 수 없는 명령: $CMD (사용: prereq|source|adb|tests|audit|backoffice-support|backoffice-vpd-ords-test|all|teardown | dds|dds-setup|dds-tests|dds-teardown)" ;; esac diff --git a/scripts/vpd-ords-regression-test.sh b/scripts/vpd-ords-regression-test.sh new file mode 100644 index 0000000..f7d8b78 --- /dev/null +++ b/scripts/vpd-ords-regression-test.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# VPD/Redaction ORDS regression test for the backoffice demo objects. +set -Eeuo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ -f "$ROOT/.env" ]]; then + # shellcheck disable=SC1091 + set -a; . "$ROOT/.env"; set +a +fi + +SQLCL_BIN="${SQLCL_BIN:-}" +if [[ -z "$SQLCL_BIN" ]]; then + SQLCL_BIN="$(command -v sql || true)" +fi + +if [[ -z "$SQLCL_BIN" || ! -x "$SQLCL_BIN" ]]; then + echo "[FAIL] SQLcl 실행 파일을 찾을 수 없습니다. SQLCL_BIN=/path/to/sql 로 지정하세요." >&2 + exit 1 +fi + +if [[ -n "${SQLCL_JAVA_HOME:-}" ]]; then + export JAVA_HOME="$SQLCL_JAVA_HOME" +fi + +DB_USER="${BACKOFFICE_DB_USERNAME:-${ADB_USER:-}}" +DB_PASSWORD="${BACKOFFICE_DB_PASSWORD:-${ADB_PASSWORD:-}}" +DB_TNS="${ADB_TNS:-}" +ORDS_BASE="${BACKOFFICE_ORDS_BASE_URL:-https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords}" +ORDS_PATH="${BACKOFFICE_REGRESSION_ORDS_PATH:-cb-ords/cb-agent-security/vpd/documents}" +DESCRIPTION="codex issue458 regression" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vpd-ords-regression.XXXXXX")" + +require_value() { + local name="$1" value="$2" + if [[ -z "$value" ]]; then + echo "[FAIL] $name 값이 필요합니다." >&2 + exit 1 + fi +} + +require_value BACKOFFICE_DB_USERNAME "$DB_USER" +require_value BACKOFFICE_DB_PASSWORD "$DB_PASSWORD" +require_value ADB_TNS "$DB_TNS" + +TOKEN_HR="codex_hr_$(uuidgen)" +TOKEN_SELF="codex_self_$(uuidgen)" +TOKEN_ALL="codex_all_$(uuidgen)" + +run_sqlcl() { + "$SQLCL_BIN" -s "$DB_USER/$DB_PASSWORD@$DB_TNS" +} + +cleanup_tokens() { + { + printf "%s\n" "WHENEVER SQLERROR CONTINUE" + printf "%s\n" "UPDATE cb_agent_bearer_key SET revoked_at = SYSDATE, active = 'N' WHERE description = '$DESCRIPTION';" + printf "%s\n" "COMMIT;" + printf "%s\n" "exit" + } | run_sqlcl >/dev/null || true +} + +trap cleanup_tokens EXIT + +insert_tokens() { + { + printf "%s\n" "WHENEVER SQLERROR EXIT SQL.SQLCODE" + printf "%s\n" "INSERT INTO cb_agent_bearer_key(key_id, user_id, key_hash, key_prefix, expires_at, active, description) VALUES (cb_agent_bearer_key_seq.NEXTVAL, 101, STANDARD_HASH('$TOKEN_HR', 'SHA256'), 'reg_hr', SYSDATE + (10/1440), 'Y', '$DESCRIPTION');" + printf "%s\n" "INSERT INTO cb_agent_bearer_key(key_id, user_id, key_hash, key_prefix, expires_at, active, description) VALUES (cb_agent_bearer_key_seq.NEXTVAL, 102, STANDARD_HASH('$TOKEN_SELF', 'SHA256'), 'reg_self', SYSDATE + (10/1440), 'Y', '$DESCRIPTION');" + printf "%s\n" "INSERT INTO cb_agent_bearer_key(key_id, user_id, key_hash, key_prefix, expires_at, active, description) VALUES (cb_agent_bearer_key_seq.NEXTVAL, 103, STANDARD_HASH('$TOKEN_ALL', 'SHA256'), 'reg_all', SYSDATE + (10/1440), 'Y', '$DESCRIPTION');" + printf "%s\n" "COMMIT;" + printf "%s\n" "exit" + } | run_sqlcl >"$WORK_DIR/token-insert.out" +} + +call_and_assert() { + local label="$1" token="$2" expected_count="$3" expected_ids="$4" expected_contents="$5" + local response_file="$WORK_DIR/${label}.response.json" + local request_file="$WORK_DIR/${label}.request.txt" + local url="${ORDS_BASE%/}/${ORDS_PATH}?limit=50" + + { + printf "POST %s\n" "$url" + printf "Authorization: Bearer ****%s\n" "${token: -6}" + printf "Content-Type: application/json\n" + printf "{}\n" + } >"$request_file" + + local status + status="$(curl -sS -o "$response_file" -w "%{http_code}" \ + -X POST "$url" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d "{}")" + + python3 - "$label" "$status" "$response_file" "$request_file" "$expected_count" "$expected_ids" "$expected_contents" <<'PY' +import json +import sys + +label, status, response_file, request_file, expected_count, expected_ids, expected_contents = sys.argv[1:8] +if status != "200": + print(f"[FAIL] {label}: HTTP {status}") + print(f" request={request_file}") + print(f" response={response_file}") + raise SystemExit(1) + +try: + with open(response_file, encoding="utf-8") as handle: + data = json.load(handle) +except Exception as exc: + print(f"[FAIL] {label}: JSON parse failed: {exc}") + print(f" request={request_file}") + print(f" response={response_file}") + raise SystemExit(1) + +rows = data.get("items", data.get("rows", [])) +ids = [row.get("DOC_ID", row.get("doc_id")) for row in rows] +contents_count = sum(1 for row in rows if "CONTENTS" in row or "contents" in row) +expected_ids_list = [int(value) for value in expected_ids.split(",") if value] + +failures = [] +if len(rows) != int(expected_count): + failures.append(f"rows expected {expected_count}, actual {len(rows)}") +if ids != expected_ids_list: + failures.append(f"ids expected {expected_ids_list}, actual {ids}") +if contents_count != int(expected_contents): + failures.append(f"rows_with_contents expected {expected_contents}, actual {contents_count}") + +if failures: + print(f"[FAIL] {label}: " + "; ".join(failures)) + print(f" request={request_file}") + print(f" response={response_file}") + raise SystemExit(1) + +print(f"[PASS] {label}: rows={len(rows)} ids={ids} rows_with_contents={contents_count}") +PY +} + +echo "[INFO] SQLcl: $SQLCL_BIN" +echo "[INFO] ORDS: ${ORDS_BASE%/}/${ORDS_PATH}" +echo "[INFO] Evidence: $WORK_DIR" + +insert_tokens +call_and_assert "HR" "$TOKEN_HR" "3" "1,2,6" "0" +call_and_assert "SELF" "$TOKEN_SELF" "1" "3" "0" +call_and_assert "ALL" "$TOKEN_ALL" "6" "1,2,3,4,5,6" "6" + +echo "[PASS] VPD/Redaction ORDS regression passed"