diff --git a/.env.example b/.env.example index ee80578..ca57789 100644 --- a/.env.example +++ b/.env.example @@ -26,11 +26,23 @@ export BACKOFFICE_DB_PASSWORD="${ADB_PASSWORD}" # --- (2b) Spring Boot 백오피스 --- export BACKOFFICE_ADMIN_USER="admin" export BACKOFFICE_ADMIN_PASSWORD="admin" +# 운영에서는 최초 한 번 생성한 {bcrypt} 해시를 .env에 보관합니다. 이를 사용하면 +# 지속 로그인 쿠키가 앱 재기동 후에도 유효합니다. 원문 비밀번호 값은 Git에 넣지 마세요. +export BACKOFFICE_ADMIN_PASSWORD_HASH="" +# 읽기 전용 조회 계정. 활성화하면 ROLE_VIEWER로 로그인 가능하며 POST/PUT/PATCH/DELETE는 차단됩니다. +export BACKOFFICE_GUEST_ENABLED="false" +export BACKOFFICE_GUEST_USER="guest" +export BACKOFFICE_GUEST_PASSWORD="" +export BACKOFFICE_GUEST_PASSWORD_HASH="" # 로컬 HTTP 개발 기본값. 외부 VM 배포 스크립트는 loopback/HTTPS 안전값으로 덮어씁니다. export BACKOFFICE_BIND_ADDRESS="0.0.0.0" export BACKOFFICE_FORWARD_HEADERS_STRATEGY="none" export BACKOFFICE_REQUIRE_HTTPS="false" export BACKOFFICE_SESSION_COOKIE_SECURE="false" +# HTTPS에서만 사용. key는 배포 환경의 secret으로 32자 이상 임의값을 넣고 Git에 저장하지 않습니다. +export BACKOFFICE_REMEMBER_ME_ENABLED="false" +export BACKOFFICE_REMEMBER_ME_KEY="" +export BACKOFFICE_REMEMBER_ME_DAYS="14" export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords" export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10" # ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로 @@ -41,10 +53,18 @@ export BACKOFFICE_ORDS_DB_PASSWORD="" # --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- export BACKOFFICE_AI_ENABLED="false" +export BACKOFFICE_AI_PROVIDER="openai" # openai | oci export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com export BACKOFFICE_AI_MODEL="" # 예: openai.gpt-5.4-mini export BACKOFFICE_AI_API_KEY="" export BACKOFFICE_AI_TIMEOUT_SECONDS="30" +# provider=oci이면 API key를 환경 변수로 복제하지 않고 OCI config profile로 요청에 +# 서명합니다. POC3_LLM_GPT55_OCI_*/OCI_GENAI_*가 있으면 아래 BACKOFFICE_* 값보다 +# fallback으로 사용됩니다. +export BACKOFFICE_AI_OCI_CONFIG_FILE="" +export BACKOFFICE_AI_OCI_PROFILE="DEFAULT" +export BACKOFFICE_AI_OCI_REGION="" +export BACKOFFICE_AI_OCI_COMPARTMENT_ID="" # 로컬 키 별칭은 .env에만 저장하세요. .env.example에는 원문 키를 넣지 않습니다. export VPDTEST1_API_KEY="" diff --git a/docs/06-agent-ords-vpd-dds-security-brief.md b/docs/06-agent-ords-vpd-dds-security-brief.md new file mode 100644 index 0000000..d98cf64 --- /dev/null +++ b/docs/06-agent-ords-vpd-dds-security-brief.md @@ -0,0 +1,1852 @@ +# 06 · ORDS 기반 Agent 사용자 식별·권한 매핑 및 VPD/DDS 데이터 접근 통제 설계 + +> 목적: ORDS 기반 Agent 요청의 사용자 식별, 권한 매핑, Oracle VPD/DDS 기반 데이터 접근 통제, 사번/부서코드 기반 행 단위 필터링 구조 정의 +> 범위: 권한 요청/승인 API 구현은 제외. DBA 수작업 등록을 전제로 권한 매핑 테이블 설계, 보안 정책 적용 방식, ADB 실행 검증 예제 포함 +> 용어: "Deep Security"는 Oracle **Deep Data Security(DDS)** 로 표기 + +--- + +## 목차 + +- [1. 설계 개요 및 적용 범위](#1-설계-개요-및-적용-범위) +- [2. 사용자 식별 및 권한 매핑 구조](#2-사용자-식별-및-권한-매핑-구조) +- [3. 권한 매핑 테이블 설계](#3-권한-매핑-테이블-설계) +- [4. VPD 기반 접근 통제 설계](#4-vpd-기반-접근-통제-설계) +- [5. DDS 기반 접근 통제 설계](#5-dds-기반-접근-통제-설계) +- [6. VPD/DDS 비교 및 적용 기준](#6-vpddds-비교-및-적용-기준) +- [7. ADB 실행 검증](#7-adb-실행-검증) +- [8. 결과 화면 및 운영 확인 항목](#8-결과-화면-및-운영-확인-항목) + +문서 흐름은 VPD와 DDS를 먼저 독립적으로 설명한 뒤, 비교 섹션에서 적용 기준을 정리하는 방식으로 구성한다. 고객 설명 시에는 1~3장에서 공통 구조를 먼저 설명하고, 4장은 VPD 경로, 5장은 DDS 경로, 6장은 적용 선택 기준으로 연결한다. + +--- + +## 1. 설계 개요 및 적용 범위 + +본 구조의 기본 원칙은 Agent 계층에서 데이터 권한을 판정하지 않는 것이다. ORDS는 요청자를 식별하고, DB는 보호 객체(VIEW/TABLE)에 연결된 정책으로 허용된 행(row)과 컬럼(column)만 반환한다. + +요청 처리 흐름은 다음과 같다. Agent 요청은 ORDS를 통과하고, ORDS는 `Authorization` 헤더 또는 DB 접속 사용자로 요청자를 식별한다. 이후 실제 데이터 제한은 DB 내부 정책에서 수행한다. 기본 적용 경로는 Bearer Key 기반 VPD이며, DDS는 DDS `END USER` 또는 지원 드라이버의 Security Context 전파가 가능한 경우의 확장 경로로 구분한다. + +![아키텍처 개요 및 적용 경로](assets/agent-ords-security-main-trunk.svg) + +요청 처리 절차: + +1. Agent가 ORDS API 호출 +2. ORDS가 요청 수신 +3. ORDS 또는 DB가 요청자를 식별 +4. DB Session Context 또는 DDS 보안 Identity에 권한 기준 생성 +5. Agent 요청은 보호 객체(VIEW/TABLE) 조회 +6. VPD가 행(row) 정책 적용. Redaction은 민감 컬럼 값을 NULL/마스킹하고, DDS는 컬럼(column) 허용/제외 적용 +7. 허용된 결과만 반환 + +구성 요소: + +| 구분 | 선택지 | 역할 | +|---|---|---| +| 사용자 식별 | DB User | Oracle 로그인 계정으로 요청자 식별 | +| 사용자 식별 | Bearer Key | ORDS Authorization 헤더의 키로 내부 사용자 식별 | +| 사용자 식별 | DDS END USER | DDS 보안 사용자로 식별 | +| 정책 적용 | VPD | `SYS_CONTEXT`, `p_object`, 권한 테이블로 행(row) 제한 | +| 정책 적용 | DDS | `DATA ROLE`, `DATA GRANT`로 행(row)/컬럼(column)/작업(action) 제한 | +| 컬럼 처리 | Redaction 또는 DDS 컬럼 Grant | Redaction은 값을 NULL/마스킹, DDS는 컬럼 허용/제외 | + +컬럼 처리 정리: + +| 방식 | 역할 | 이 문서의 설명 기준 | +|---|---|---| +| VPD 기본 정책 | 행(row) 조건 반환 | 컬럼 통제 수단으로 설명하지 않음 | +| Oracle Data Redaction | 컬럼 값을 NULL, 부분 마스킹, 정규식 마스킹 값으로 변환 | 컬럼 Grant가 아니라 값 마스킹/NULL 처리 | +| DDS DATA GRANT | `AS SELECT (ALL COLUMNS EXCEPT ...)`로 컬럼 허용/제외 | 컬럼 통제로 설명 | +| Column-level VPD 옵션 | `sec_relevant_cols`, `sec_relevant_cols_opt` 옵션이 있으나 운영 권한 모델로는 혼동 가능 | 본 구조에서는 사용하지 않고 설명 범위에서도 제외 | + +따라서 이 문서에서의 결론은 아래와 같다. + +```text +VPD = 행(row) 통제 +Redaction = 컬럼 값 NULL/마스킹 +DDS = 행(row) + 컬럼(column) + 작업(action) 통제 +``` + +주요 구분: + +> DB에 접속한 사용자와 실제 권한을 적용할 사용자는 다를 수 있다. 특히 ORDS 공통 계정으로 접속하는 구조에서는 `DB user != key user`다. + +설명 순서: + +1. 사용자 식별 방식과 권한 매핑 구조 정의 +2. Bearer Key 기반 VPD 경로 설명 +3. VPD에서 행(row) 통제와 Redaction 컬럼 값 마스킹 구분 +4. DDS에서 END USER, DATA ROLE, DATA GRANT 기반 통제 설명 +5. VPD와 DDS의 적용 조건, 운영 차이, 검증 결과 비교 + +보호 객체 범위: + +> 본 예제는 API 노출 지점과 우회 차단 구조를 설명하기 위해 VIEW를 주로 사용한다. 단, 지원 범위가 VIEW로 한정되는 것은 아니다. VPD와 DDS는 보호 정책을 VIEW 또는 TABLE 같은 DB 객체에 연결할 수 있다. VIEW는 원본 TABLE을 숨기고 API용 노출 객체를 분리하기 위한 래퍼 패턴이다. + +### 1.1 전체 처리 흐름 요약 + +다음 세 개의 다이어그램은 전체 구조를 요약한다. 이후 상세 섹션에서 동일한 흐름을 SQL, 정책, 검증 결과 기준으로 설명한다. + +![두 가지 사용자 식별 시나리오 화면](assets/agent-ords-security-two-scenarios.svg) + +| 확인 항목 | 의미 | +|---|---| +| 시나리오 1 - DB User 기반 | Oracle 접속 계정으로 요청자를 식별 | +| 시나리오 2 - Bearer Key 기반 | ORDS `Authorization` 헤더의 키로 내부 사용자를 식별 | +| 공통 하단 흐름 | 식별 이후에는 DB 보안 정책이 보호 객체(VIEW/TABLE)에 적용 | +| 기본 방향 | ORDS Bearer Key 기반은 VPD + Redaction을 기본 적용 | + +![권한 등록 매핑 화면](assets/agent-ords-security-permission-mapping-screen.svg) + +| 확인 항목 | 의미 | +|---|---| +| `APP_USER` | 실제 권한을 적용할 내부 사용자 | +| `AGENT_BEARER_KEY` | Bearer Key Hash로 내부 사용자를 찾는 매핑 | +| `USER_ROLE` | 사용자가 어떤 role을 갖는지 | +| `PERMISSION` | 어떤 보호 객체를 조회할 수 있는지 | +| `PERMISSION_RULE` | 보호 객체 내 허용 행(row) 조건 | +| `p_object` | Oracle이 VPD 함수에 넘겨주는 현재 조회 객체 이름 | + +![권한 미부여 및 차단 결과 화면](assets/agent-ords-security-no-permission-screen.svg) + +| 확인 항목 | 의미 | +|---|---| +| 권한 매핑 없음 | 보호 객체 DB 권한은 있어도 VPD 조건이 통과하지 못해 0건 | +| DB 권한 미부여 | VPD 판단 전 객체가 보이지 않아 `ORA-00942` 또는 `ORA-01031` | +| Bearer 헤더 없음 | ORDS Handler 필수값 차단. 예: `ORA-20101` | +| 잘못된 Key | Key Hash 매핑 실패. 예: `ORA-20002` | + +### 1.2 운영 권한 등록 항목 + +권한 요청/승인 API 구현은 본 범위에서 제외한다. 단, DBA가 수작업으로 등록하더라도 아래 항목은 사전에 정의되어야 한다. + +표의 객체명은 설계 설명용 논리명이다. ADB 실행 예제는 기존 객체와의 충돌을 방지하기 위해 `CB_APP_USER`, `CB_AGENT_BEARER_KEY`처럼 `CB_*` 접두어를 사용한다. 또한 `db_username`은 DB User 기반 시나리오를 위한 설계 컬럼이며, Bearer Key 중심 실행 예제의 `CB_APP_USER`에는 포함하지 않았다. + +| 등록 영역 | 필요한 값 | 저장 또는 적용 위치 | 의미 | +|---|---|---|---| +| 내부 사용자 | `user_id`, 사용자명, 사번, 부서코드, 활성 여부 | `APP_USER` | 실제 권한 적용 대상 | +| DB User 매핑 | Oracle username | `APP_USER.db_username` 또는 별도 매핑 컬럼 | DB User 기반 시나리오에서 사용자 식별 | +| Bearer Key 매핑 | Key Hash, Key Prefix, 만료일, 회수일, 활성 여부 | `AGENT_BEARER_KEY` | ORDS Authorization 헤더의 키를 내부 사용자로 변환 | +| 역할 | role id, role name | `APP_ROLE` | 권한 그룹 | +| 사용자-역할 | user id, role id | `USER_ROLE` | 사용자가 어떤 역할을 갖는지 | +| 보호 객체 권한 | target name, action | `PERMISSION` | 예: `CB_V_SEARCH_DOCUMENTS`, `SELECT` | +| 행 규칙 | rule type, rule value | `PERMISSION_RULE` | 예: `MY_DEPT=HR`, `SELF`, `ALL` | +| 민감 컬럼 처리 | 컬럼 값 표시 허용 여부 | `APP_USER.can_read_contents`, Redaction 정책 | 예: `contents` 원문 또는 NULL | +| 보호 객체 목록 | schema, object name, enabled | 보호 객체 목록 테이블 또는 배포 설정 | `DBMS_RLS.ADD_POLICY` 반복 등록 기준 | +| DDS 사용자 | end user name | `CREATE END USER` | DDS 직접 사용자 식별 | +| DDS 역할/권한 | DATA ROLE, DATA GRANT, Predicate, Column List | `CREATE DATA ROLE`, `CREATE DATA GRANT` | DDS 선언형 행/컬럼 권한 | + +운영 등록 항목은 두 축으로 구분한다. + +```text +요청 주체 식별 + -> DB User 또는 Bearer Key로 내부 사용자 식별 + +데이터 접근 범위 + -> role / permission / permission_rule 또는 DDS DATA GRANT로 결정 +``` + +### 1.3 권한 판정 절차 및 차단 유형 + +VPD의 동적 권한 판정은 `p_object`와 `permission.target_name` 매핑으로 수행된다. + +```text +Agent 요청 + -> ORDS가 사용자 식별 + -> DB Session Context에 USER_ID / EMP_NO / DEPT_CODE 저장 + -> 보호 객체 조회 + -> Oracle이 VPD 함수에 p_object 전달 + -> permission.target_name = p_object 인 권한 확인 + -> permission_rule이 행(row)의 컬럼 값과 일치하는지 확인 + -> 조건을 충족하는 행(row)만 반환 +``` + +| 확인 지점 | 통과 조건 | 실패 결과 | +|---|---|---| +| ORDS 헤더 | `Authorization: Bearer ` 형식 | `ORA-20101` 또는 401/403 | +| Key 매핑 | Key Hash가 활성 사용자와 매핑 | `ORA-20002`, Context 초기화 | +| DB 객체 권한 | ORDS runtime schema가 보호 객체 조회 가능 | `ORA-00942` 또는 `ORA-01031` | +| VPD 객체 권한 | `permission.target_name = p_object` 존재 | 0건 | +| VPD 행 규칙 | `MY_DEPT`, `SELF`, `ALL` 규칙 통과 | 해당 행(row) 제외 | +| 민감 컬럼 처리 | 컬럼 표시 Flag 또는 DDS 컬럼 Grant 통과 | Redaction NULL 또는 컬럼 제외 | +| DDS Identity | `END USER` 또는 `EndUserSecurityContext` 전파 | DATA GRANT 미적용, `ORA-00942` 가능 | + +따라서 차단 또는 미조회 결과는 발생 지점에 따라 의미가 다르다. + +```text +권한 매핑 없음 = SQL은 실행되지만 결과 0건 +DB 객체 권한 미부여 = 객체가 보이지 않거나 권한 오류 +Bearer Key 문제 = ORDS/DB Package 단계에서 차단 +DDS Context 없음 = DDS DATA GRANT가 적용될 사용자 Identity 없음 +``` + +### 1.4 설계 결론 + +| 구분 | 결론 | +|---|---| +| 기본 권장안 | ORDS가 `Authorization: Bearer `를 받고 DB 테이블에서 사용자를 매핑하는 구조는 **VPD + Redaction**을 기본 적용안으로 권장한다. | +| DDS 적용 조건 | DDS는 실제 사용자가 `END USER`로 접속하거나, 지원 드라이버 또는 호출 애플리케이션이 `EndUserSecurityContext`를 DB 호출 전에 전파할 수 있을 때 적합하다. | +| 유의 사항 | ORDS PL/SQL Handler가 키를 조회해 변수에 저장하는 것만으로는 DDS `END USER`가 되지 않는다. 이 경로는 ADB에서 차단 검증했다. | +| 운영 관리 | VPD/DDS 모두 객체별 적용이 필요하다. 대신 Dictionary View와 Inventory SQL로 적용 현황을 중앙 확인한다. | + +### 1.5 요구사항 대응 + +| 요구사항 | 대응 방안 | +|---|---| +| Agent를 Oracle User처럼 권한 제어 | 가능하다. DB User 기반은 `SESSION_USER`로 매핑하고, ORDS 공통 계정 기반은 Bearer Key를 내부 사용자로 매핑한다. | +| 사번/부서코드로 검색 데이터 필터링 | VPD는 `SYS_CONTEXT`에 사번/부서코드를 저장하고 정책 함수가 행(row) 조건을 반환한다. DDS는 `DATA GRANT ... WHERE dept_code = ...`로 선언한다. | +| ORDS API 개발 위치 | ORDS Handler는 Authorization 헤더 필수값 확인과 키 전달을 담당한다. 실제 권한 판단은 DB Package, VPD 함수, DDS DATA GRANT에서 수행한다. | +| Bearer Key 기반 시나리오 | VPD 권장. Key User가 많고 권한이 자주 변경되면 테이블 기반 권한 관리가 운영상 유리하다. | +| DDS 사용 시나리오 | DDS `END USER` 또는 Driver-level `EndUserSecurityContext` 전파가 가능할 때 권장한다. 단순 ORDS Handler Key Lookup만으로는 부족하다. | +| 권한 현황 확인 | VPD는 `DBA_POLICIES`, Redaction View, 업무 권한 테이블을 확인한다. DDS는 `DBA_DATA_GRANTS`, `DBA_DATA_ROLE_GRANTS`, `DBA_DATA_ROLES`, `DBA_END_USERS`를 확인한다. | + +### 1.6 검증 결과 요약 + +검증은 VPD, DDS, ORDS Handler 검증, 공통 인벤토리로 구분해 확인했다. + +VPD + Redaction 검증: + +| 항목 | 상태 | 근거 | +|---|---|---| +| Bearer Key -> Context 설정 | 실행 검증 완료 | `CB_ORDS`가 `cb_hr_key`, `cb_fin_key`, `cb_all_key`를 `CB_AGENT_CTX`에 저장 | +| 행(row) 제한 | 실행 검증 완료 | HR 3건, FIN 본인 1건, ALL 6건 | +| Redaction 값 마스킹/NULL | 실행 검증 완료 | `contents` 컬럼 값은 VPD가 아니라 Redaction이 처리. HR/FIN은 NULL, ALL은 원문 표시 | +| Key 오류 처리 | 실행 검증 완료 | Invalid Key는 `ORA-20002`, Context 초기화 후 0건 | +| 원본/권한 테이블 우회 차단 | 실행 검증 완료 | `CB_SEARCH_DOCUMENTS`, `CB_APP_USER`, `CB_AGENT_BEARER_KEY` 직접 조회는 `ORA-00942` | + +DDS 검증: + +| 항목 | 상태 | 근거 | +|---|---|---| +| Direct END USER 접속 | 실행 검증 완료 | `cb_dds_hr`, `cb_dds_fin`, `cb_dds_all`, `cb_dds_none`로 접속 | +| DATA GRANT 행(row) 제한 | 실행 검증 완료 | HR 3건, FIN 2건, ALL 6건 | +| DATA GRANT 컬럼(column) 허용/제외 | 실행 검증 완료 | HR/FIN은 `ALL COLUMNS EXCEPT contents`, ALL은 전체 컬럼 | +| 권한 없는 END USER | 실행 검증 완료 | `cb_dds_none`은 보호 객체 조회 시 `ORA-00942` | +| 원본/VPD 객체 우회 차단 | 실행 검증 완료 | DDS End User가 원본 TABLE 또는 VPD 보호 객체 직접 조회 시 `ORA-00942` | + +ORDS Handler 검증: + +| 항목 | 상태 | 근거 | +|---|---|---| +| Mandatory Bearer 헤더 | 실행 검증 완료 | 헤더가 없으면 `ORA-20101` | +| VPD Bearer Handler | 실행 검증 완료 | Handler Package가 Key를 Context로 저장하고 HR 행(row) 3건 반환 | +| DDS Bearer 단독 매핑 Handler | 차단 검증 완료 | Key를 `cb_dds_hr`로 매핑해도 `dds_context_username=null`, `ORA-00942`, `EXPECTED_BLOCKED` | +| 실제 ORDS HTTP Endpoint 호출 | 별도 환경 필요 | ADB에는 ORDS Package와 Handler 정의를 구성했다. 외부 URL 호출은 ORDS URL/인증 설정 확인 후 수행 | + +공통 확인: + +| 항목 | 상태 | 근거 | +|---|---|---| +| VPD/Redaction Inventory | 실행 검증 완료 | `DBA_POLICIES`, `REDACTION_POLICIES`에서 정책 확인 | +| DDS Inventory | 실행 검증 완료 | `DBA_DATA_GRANTS`, `DBA_DATA_ROLE_GRANTS`에서 DATA GRANT와 END USER 매핑 확인 | +| DDS Driver-level Security Context | 별도 구성 필요 | IAM/DB Access Token 및 지원 드라이버 설정 필요. 문서에는 공식 API 기준 경로를 설명 | + +### 1.7 권장 구현 구조 + +현재 요구사항의 주 경로는 아래 흐름이다. + +```text +Agent + -> ORDS Endpoint + -> Authorization 헤더 필수 확인 + -> Bearer Key Hash 조회 + -> 내부 user_id / 사번 / 부서코드 / 컬럼 권한 식별 + -> DB Session Context reset + set + -> 보호 객체(VIEW/TABLE) 조회 + -> VPD 행(row) 제한 + Redaction 값 NULL/마스킹 + -> 허용된 결과만 반환 +``` + +역할을 나누면 아래와 같다. + +| 계층 | 맡는 일 | 맡지 않는 일 | +|---|---|---| +| Agent | ORDS API 호출, `Authorization: Bearer ` 전달 | 행/컬럼 권한 판단 | +| ORDS Handler | 헤더 필수값 확인, 키를 DB Package로 전달, 오류를 HTTP 응답으로 변환 | 업무 권한 규칙 직접 구현 | +| DB Package | Key Hash 검증, 내부 사용자 조회, `SYS_CONTEXT` 값 reset/set | 보호 객체별 행 조건을 직접 SQL에 붙임 | +| VPD Function | 보호 객체 이름과 사용자 Context로 행 제한 조건 생성 | Bearer Key 원문 저장 | +| Redaction | 컬럼 권한에 따라 민감 컬럼 NULL 처리 | 행 제한 | +| Inventory SQL | VPD, Redaction, DDS 적용 현황 확인 | 업무 승인 사유 관리 | + +DDS는 아래 조건이 충족될 때 같은 구조의 확장 경로로 설명한다. + +```text +DDS END USER 직접 접속 + 또는 +지원 드라이버 또는 호출 애플리케이션이 EndUserSecurityContext를 DB 호출 전에 Attach + -> DATA ROLE 확인 + -> DATA GRANT 적용 + -> 허용된 행(row)/컬럼(column)/작업(action)만 반환 +``` + +### 1.8 실행 검증 완료 기준 + +아래 결과가 나오면 현재 요구사항의 실행 가능한 예제로 볼 수 있다. + +VPD 완료 기준: + +| 검증 | 기대 결과 | +|---|---| +| Context 없음 | 0건 | +| 잘못된 Bearer Key | `ORA-20002`, Context 초기화 후 0건 | +| `cb_hr_key` | HR 행(row) 3건, `contents` NULL | +| `cb_fin_key` | 본인 행(row) 1건, `contents` NULL | +| `cb_all_key` | 전체 행(row) 6건, `contents` 원문 표시 | +| 원본 TABLE 직접 조회 | `ORA-00942` 또는 직접 권한 미부여 | + +DDS 완료 기준: + +| 검증 | 기대 결과 | +|---|---| +| DDS `cb_dds_hr` | HR 행(row) 3건, `contents` NULL | +| DDS `cb_dds_fin` | FIN 행(row) 2건, `contents` NULL | +| DDS `cb_dds_all` | 전체 행(row) 6건, `contents` 원문 표시 | +| DDS `cb_dds_none` | `ORA-00942` | + +ORDS Handler 검증 완료 기준: + +| 검증 | 기대 결과 | +|---|---| +| Bearer 헤더 없음 | `ORA-20101` | +| VPD Handler `cb_hr_key` | HR 행(row) 3건, `contents` NULL | +| ORDS Handler의 DDS Bearer 단독 매핑 검증 | `dds_context_username=null`, `ORA-00942`, `EXPECTED_BLOCKED` | + +공통 완료 기준: + +| 검증 | 기대 결과 | +|---|---| +| 중앙 권한 확인 | `DBA_POLICIES`, `REDACTION_POLICIES`, `DBA_DATA_GRANTS`, `DBA_DATA_ROLE_GRANTS`에서 정책 확인 | + +### 1.9 VPD 적용 단위 기준 + +VPD는 보호할 TABLE 또는 VIEW에 **객체별로** 연결한다. `DBMS_RLS.ADD_POLICY`의 `object_name`은 실제 객체 이름을 받는 값이다. + +| 검토 항목 | 기준 | +|---|---| +| schema 전체에 한 번에 VPD를 걸 수 있는가 | 아니다. `ADD_POLICY` 1회는 보호 객체 1개에 적용된다. | +| `object_name => '*'` 또는 `%` 표현이 가능한가 | 아니다. wildcard로 전체 TABLE/VIEW를 지정하는 방식이 아니다. | +| `statement_types`에 여러 SQL 작업을 지정하면 전체 객체 적용인가 | 아니다. `SELECT, INSERT, UPDATE, DELETE`처럼 적용할 SQL 작업 종류를 지정하는 값이다. 보호 객체 wildcard가 아니다. | +| 객체가 많으면 어떻게 하는가 | 보호 객체 목록을 관리하고, 목록을 루프 돌면서 `ADD_POLICY`를 객체별로 생성한다. | +| 같은 정책 함수를 재사용할 수 있는가 | 가능하다. 정책 함수는 하나로 두고, `p_object` 값으로 현재 조회 객체를 구분한다. | +| 매핑 테이블에 권한이 없으면 어떻게 되는가 | VPD 조건이 통과하지 못하므로 해당 보호 객체 조회 결과는 0건이다. | + +정리: + +```text +전체 적용 = Wildcard 1회 등록 +아님 + +전체 적용 = 보호 객체 목록 전체를 돌며 객체별 ADD_POLICY 등록 +``` + +
+운영 검토 항목 - 적용 단위, 권한 조회, 누락 방지 + +| 검토 항목 | 기준 | +|---|---| +| Bearer Key만으로 DDS 사용자 권한까지 적용되는가 | ORDS Handler에서 Key를 읽는 것만으로는 적용되지 않는다. VPD는 `SYS_CONTEXT` 방식으로 가능하고, DDS는 `EndUserSecurityContext` 전파가 필요하다. | +| 권한 현황을 한눈에 볼 수 있는가 | 가능하다. VPD는 `DBA_POLICIES`와 업무 권한 테이블, DDS는 `DBA_DATA_GRANTS`, `DBA_DATA_ROLE_GRANTS`, `DBA_DATA_ROLES`, `DBA_END_USERS`로 확인한다. | +| VPD 정책을 모든 VIEW/TABLE에 한 번에 걸 수 있는가 | 아니다. `DBMS_RLS.ADD_POLICY`는 보호 객체 하나를 지정한다. `object_name => '*'` 같은 Wildcard 등록은 하지 않는다. 여러 객체는 같은 정책 함수를 재사용하되 객체별로 등록하거나 스크립트로 일괄 생성한다. | +| DDS도 객체별로 권한을 선언해야 하는가 | `DATA GRANT`는 보호 객체, 역할, 행/컬럼 조건 단위로 선언한다. 적용은 선언 단위지만 Dictionary View로 중앙 조회가 가능하다. | +| 객체가 많아지면 누락 위험은 어떻게 줄이는가 | 보호 객체 목록 테이블 또는 설정 파일을 기준으로 VPD/DDS 등록 스크립트를 생성하고, Dictionary 조회 결과를 검증 단계에 포함한다. | + +
+ +
+오류 코드 상세 - ORA 코드 해석과 정상 차단 여부 + +| 오류 코드 | 이 문서에서의 의미 | 정상 차단 여부 | 확인 방법 | +|---|---|---|---| +| `ORA-00942` | table 또는 view가 없거나, 현재 사용자에게 객체가 보이지 않음 | DDS에서 `DATA GRANT`가 없거나 원본 TABLE 권한을 숨긴 경우 정상 차단 | `DBA_DATA_GRANTS`, `DBA_DATA_ROLE_GRANTS`, `DBA_POLICIES`, 직접 Grant 여부 확인 | +| `ORA-20002` | 잘못되었거나 만료된 Bearer Key | 정상 차단. `RAISE_APPLICATION_ERROR`로 만든 사용자 정의 오류 | `agent_bearer_key`의 Hash, Active, Revoked, Expires 상태 확인 | +| `ORA-20101` | `Authorization: Bearer ` 헤더가 없거나 형식이 틀림 | 정상 차단. ORDS Handler Package에서 만든 사용자 정의 오류 | ORDS 요청 헤더와 Handler Bind Variable 확인 | +| `ORA-01031` | 권한 부족 | 우회 시도 테스트에서는 정상 차단 | 사용자가 `DBMS_SESSION`, 정책 변경, 원본 객체 접근 권한을 갖고 있는지 확인 | +| `ORA-02019` | 원격 DB 연결 식별자 또는 DB Link 접근 불가 | 원격 원본 직접 조회 차단 테스트에서는 정상 차단 | DB Link 존재 여부, 직접 Grant 여부, 네트워크/원격 접속 설정 확인 | + +주의할 점: + +| 구분 | 설명 | +|---|---| +| `ORA-00942` | 실제로 객체가 없을 때도 발생하고, 권한 미부여로 객체가 숨겨질 때도 발생한다. 보안 테스트에서는 Dictionary 조회 결과와 함께 해석해야 한다. | +| `ORA-20000`~`ORA-20999` | Oracle 표준 오류가 아니라 개발자가 `RAISE_APPLICATION_ERROR`로 정의하는 사용자 오류 범위다. 이 문서의 `ORA-20002`, `ORA-20101`이 여기에 해당한다. | +| 정상 차단 오류 | 보안 검증에서는 실패가 아니라 “우회가 막혔다”는 증거다. 단, 운영 장애 분석에서는 같은 코드라도 발생 위치와 사용자, 대상 객체를 함께 봐야 한다. | + +
+ +
+운영 책임 상세 - 누가 무엇을 관리하는지 + +| 영역 | 담당 역할 | 관리 항목 | +|---|---|---| +| Agent | API 호출 주체 | Bearer Key 전달, 요청 Parameter 구성 | +| ORDS/API | API Gateway 및 Handler | Mandatory `Authorization` 헤더 확인, Handler Package 호출, 오류 응답 변환 | +| DB 보안 Package | Key 사용자 식별 | Key Hash 조회, 사용자 활성 상태 확인, Session Context reset/set | +| VPD/Redaction | 데이터 제한 | 행 제한 정책, 민감 컬럼 처리, 보호 객체별 정책 연결 | +| DDS | 선언형 데이터 권한 | END USER, DATA ROLE, DATA GRANT, 컬럼 Grant | +| DBA/Security | 보안 객체 등록 | DB User, Role, Grant, VPD Policy, DDS Object, Inventory 점검 | +| 업무 승인 관리 | 권한 요청/승인 이력 | 요청자, 승인자, 사유, ticket, 만료일, 회수 이력 | + +권한 요청/승인 API는 본 범위에서 제외하지만, 테이블 설계에는 이력을 남길 자리를 둔다. 예를 들어 `permission_request`, `permission_approval`, `agent_bearer_key`의 `issued_at`, `expires_at`, `revoked_at` 같은 컬럼이 그 역할이다. + +
+ +
+성능·감사·운영 체크리스트 - 적용 후 점검 항목 + +| 구분 | 체크 항목 | +|---|---| +| Index | `agent_bearer_key(key_hash)`, `user_role(user_id, role_id)`, `permission(role_id, target_name, action_name)`, `permission_rule(perm_id, rule_type, rule_value)` | +| 업무 컬럼 | 행 제한에 쓰는 `dept_code`, `owner_emp_no`는 검색 대상 테이블 또는 VIEW 기반 TABLE에 Index 검토 | +| VPD 함수 | 함수 안에서 복잡한 업무 로직을 실행하지 말고, 권한 테이블을 확인하는 SQL 조건을 반환하는 구조 유지 | +| 권한 누락 방지 | 보호 객체 목록을 관리하고, `DBMS_RLS.ADD_POLICY` 또는 `DATA GRANT` 등록 결과를 Inventory SQL로 비교 | +| 우회 차단 | ORDS runtime schema와 Agent DB user에는 원본 TABLE, 권한 테이블 직접 조회 권한을 주지 않음 | +| key 관리 | key 원문 저장 금지, hash 저장, prefix만 표시, 만료/회수/교체 절차 운영 | +| 감사 로그 | request id, key id 또는 user id, ORDS endpoint, result count, 오류 코드, 실행 시각 기록 | +| 정기 점검 | `DBA_POLICIES`, `REDACTION_POLICIES`, `DBA_DATA_GRANTS`, `DBA_DATA_ROLE_GRANTS` 결과를 배포 전후 비교 | + +
+ +--- + +## 2. 사용자 식별 및 권한 매핑 구조 + +이 장에서는 Agent 요청이 DB 권한 적용 대상으로 변환되는 방식을 정리한다. DB User 기반, Bearer Key 기반, DDS END USER 기반을 구분하고, 본 요구사항의 기본 경로는 `DB user != Key User` 구조임을 명확히 한다. + +
+상세 설명 - DB User, Bearer Key, DDS END USER 식별 경로 + +### 2.1 DB User 기반 + +Agent 또는 ORDS가 Oracle 계정으로 DB에 접속하고, DB는 `SESSION_USER`를 기준으로 사용자를 식별한다. + +| Oracle 계정 | 사번 | 부서 | 역할 | +|---|---|---|---| +| `AGENT_HR_001` | `E10234` | `HR` | HR 검색 | +| `AGENT_FIN_002` | `E20411` | `FIN` | FIN 검색 | + +흐름: + +1. Agent 또는 ORDS가 `AGENT_HR_001`로 DB 접속 +2. DB가 `SYS_CONTEXT('USERENV', 'SESSION_USER')` 확인 +3. `app_user`에서 DB 계정에 해당하는 내부 사용자 조회 +4. 사용자-역할-권한 테이블에서 접근 범위 확인 +5. 보호 객체(VIEW/TABLE) 조회 시 선택한 정책 적용 +6. VPD 경로는 VPD가 행(row)을 제한하고, 민감 컬럼 값은 Redaction으로 NULL/마스킹 처리 +7. 컬럼 자체를 제외하려면 VIEW/TABLE 설계 또는 DDS DATA GRANT 사용 +8. DDS 경로는 DATA GRANT가 행(row)/컬럼(column)/작업(action)을 제한 + +DB User 기반은 설명과 감사가 단순하다. 다만 ORDS가 항상 같은 DB 계정으로 접속하면 DB 계정만으로 실제 사용자를 구분할 수 없다. + +### 2.2 Bearer Key 기반 + +ORDS가 공통 DB 계정으로 접속하는 경우에는 Authorization 헤더의 Bearer Key로 실제 사용자를 식별한다. + +흐름: + +1. Agent가 ORDS API 호출 +2. ORDS 헤더에 `Authorization: Bearer ` 포함 +3. ORDS Handler가 Bearer Key 필수 여부 확인 +4. Key가 없거나 일치하지 않으면 401/403 또는 사용자 정의 오류 반환 +5. Key Hash 계산 +6. `agent_bearer_key` 테이블에서 내부 사용자 조회 +7. 내부 사용자의 `USER_ID`, 사번, 부서코드, 컬럼 허용 Flag를 DB 세션에 저장 +8. 보호 객체(VIEW/TABLE) 조회 +9. VPD가 허용 행(row)만 반환 +10. Redaction이 컬럼 허용 Flag를 보고 민감 컬럼을 NULL 처리하거나 원문 표시 + +Bearer Key 테이블 예: + +```sql +CREATE TABLE agent_bearer_key ( + key_id NUMBER PRIMARY KEY, + user_id NUMBER NOT NULL REFERENCES app_user(user_id), + key_hash VARCHAR2(128) NOT NULL UNIQUE, + key_prefix VARCHAR2(16), + issued_at DATE DEFAULT SYSDATE NOT NULL, + expires_at DATE, + revoked_at DATE, + active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) +); +``` + +이 구조에서는 DB 계정이 하나여도 Key 사용자는 여러 명일 수 있다. + +| 레벨 | 예 | 의미 | +|---|---|---| +| DB 접속 사용자 | `CB_ORDS` | ORDS가 DB에 접속할 때 쓰는 계정 | +| Key 사용자 | `agent_hr`, `agent_fin_self`, `agent_all` | Bearer Key가 가리키는 내부 사용자 | +| 권한 규칙 | HR 행(row), 본인 행(row), `contents` 컬럼 허용 여부 | 실제 데이터 제한 기준 | + +따라서 Bearer Key 기반의 핵심은 아래와 같이 정리한다. + +> `DB user != key user`. DB User는 접속 계정이고, Key User는 실제 권한 적용 대상이다. + +### 2.3 DDS END USER 기반 + +DDS는 `END USER`, `DATA ROLE`, `DATA GRANT`로 권한을 선언한다. + +| DDS 객체 | 역할 | +|---|---| +| `END USER` | 스키마를 소유하지 않는 보안 사용자 | +| `DATA ROLE` | 데이터 권한 묶음 | +| `DATA GRANT` | 어떤 VIEW/TABLE의 어떤 행(row)/컬럼(column)/작업(action)을 허용하는지 선언 | + +Bearer Key가 많고 Key별 권한 변경이 잦은 구조라면 VPD의 권한 테이블 방식이 운영상 적합하다. 반대로 실제 사용자가 DDS `END USER`로 명확히 전파되고 정책을 DDL로 관리하려면 DDS가 적합하다. + +DDS에는 확장 객체로 `APPLICATION IDENTITY`도 있다. 이 객체는 DDS 객체다. 다만 본 문서의 두 가지 사용자 식별 시나리오에서는 제외한다. + +| DDS 확장 객체 | 의미 | 이 문서에서 제외한 이유 | +|---|---|---| +| `APPLICATION IDENTITY` | 특정 애플리케이션을 DB 안의 보안 Identity로 등록 | 현재 요청은 Agent/ORDS 자체 권한보다 사용자별 데이터 권한 설명이 핵심 | + +`APPLICATION IDENTITY`는 애플리케이션 공통 권한이나 OAuth/OIDC 기반 애플리케이션 식별이 필요할 때 검토한다. Bearer Key가 가리키는 내부 사용자별 권한을 대체하는 개념은 아니다. + +```sql +CREATE APPLICATION IDENTITY hcm_app + MAPPED TO 'AZURE_CLIENT_ID='; + +GRANT DATA ROLE hcm_role + TO hcm_app; +``` + +DDS에서 Bearer Key를 다룰 때는 두 단계를 구분해야 한다. ORDS Handler가 `Authorization` 헤더를 읽고 Key를 내부 사용자로 매핑하는 것은 가능하다. 그러나 그 매핑값이 자동으로 DDS `END USER`가 되지는 않는다. + +![DDS Bearer Key 처리 검증](assets/agent-ords-security-dds-bearer-probe.svg) + +ADB에서 ORDS Handler Package를 만들어 검증한 결과: + +| 검증 항목 | 결과 | +|---|---| +| ORDS Handler에서 `Authorization` 헤더 필수 처리 | 가능 | +| Handler가 Key를 내부 사용자 또는 DDS End User 이름으로 매핑 | 가능 | +| 같은 Handler에서 VPD Context 설정 후 조회 | 가능 | +| 같은 Handler에서 DDS `EndUserSecurityContext` 생성/Attach | 검증 결과 생성/Attach되지 않음. DB Session 안의 PL/SQL Handler 단계는 Driver-level Attach 지점이 아님 | +| `CB_ORDS` Session에서 Key만 매핑하고 DDS 보호 객체 조회 | `ORA-00942`, `dds_context_username=null` | + +따라서 단순 Bearer Key 테이블 Lookup 방식은 VPD 적용이 권장된다. DDS로 Bearer 기반 사용자 권한을 적용하려면 Key 검증 후 DDS `EndUserSecurityContext` Payload를 DB 호출 전에 전달하는 별도 드라이버 또는 호출 애플리케이션 경로가 필요하다. + +| Bearer 유형 | DDS 적용 가능 여부 | 설명 | +|---|---|---| +| IAM/OIDC Bearer Token | 가능 | Token이 End-user Identity와 Role Claim을 담고 있고, 지원 드라이버 또는 호출 애플리케이션이 DDS Security Context Payload로 DB에 전달해야 함 | +| 애플리케이션 자체 API Key | 조건부 가능 | Key를 검증한 뒤 Local `END USER`와 Security Context Lookup Key로 변환하고, DB 호출 전에 Security Context를 Attach해야 함 | +| ORDS PL/SQL Handler의 단순 DB 테이블 Lookup Key | VPD 권장 | Handler 내부에서 Key를 찾는 것만으로 DDS End User Context가 생성되지 않음 | + +DDS Security Context 전달 방식: + +| 항목 | 확인 내용 | +|---|---| +| 전달 단위 | Oracle Client Driver가 `EndUserSecurityContext` Payload를 DB Connection Metadata로 전달 | +| Driver 지원 | JDBC, Python, ODP.NET | +| Python API | `oracledb.create_end_user_security_context()`, `connection.set_end_user_security_context()`, `connection.clear_end_user_security_context()` | +| IAM 사용자 | `end_user_token` + `database_access_token` 전달 | +| Local 사용자 | `(end_user_name, security_context_lookup_key)` + `database_access_token` 전달 | +| DB Pool User 권한 | `CREATE SESSION`, `CREATE END USER SECURITY CONTEXT` 필요 | +| DB에서 읽는 값 | `ORA_END_USER_CONTEXT.username`, Custom Context는 `ORA_END_USER_CONTEXT...` | + +즉, Bearer Key를 DDS에 적용하려면 아래 변환이 필요하다. + +```text +Authorization: Bearer + -> 호출 애플리케이션 계층에서 Key 검증 + -> 내부 사용자 확인 + -> EndUserSecurityContext Payload 생성 + -> SQL 실행 전에 DB Connection에 Security Context Attach + -> DATA ROLE / DATA GRANT 적용 +``` + +반대로 이 변환을 하지 않고 ORDS가 공통 DB 계정으로만 접속하면, DDS는 Bearer Key별 사용자를 구분하지 못한다. 이 경우 `DB user=CB_ORDS`, `key user=cb_dds_hr` 같은 매핑값은 호출 애플리케이션 내부 변수일 뿐이고 DDS 보안 Identity는 아니다. + +지원 드라이버 또는 호출 애플리케이션 계층 기준 의사 코드: + +```python +import oracledb + +# 1. 호출 애플리케이션에서 Bearer API Key 검증 후 내부 사용자 식별 +end_user_name = "cb_dds_hr" +lookup_key = "" +database_access_token = "" + +# 2. DDS End-user Security Context Payload 생성 +user_context = oracledb.create_end_user_security_context( + # Local End-user 방식: (end user name, security context lookup key) + end_user_identity=(end_user_name, lookup_key), + database_access_token=database_access_token, + attributes={ + "hr.hcm_context": { + "emp_no": "E10234", + "dept_code": "HR" + } + } +) + +# 3. DB 연결에 Attach 후 조회 +connection.set_end_user_security_context(user_context) +try: + cursor = connection.cursor() + cursor.execute(""" + SELECT doc_id, title + FROM admin.cb_dds_v_search_documents + """) +finally: + connection.clear_end_user_security_context() +``` + +주의: + +| 항목 | 의미 | +|---|---| +| Local End User의 DATA ROLE | 호출 애플리케이션이 임의로 넣는 것이 아니라 DB에 Grant된 DATA ROLE이 적용됨 | +| `database_access_token` | 호출 애플리케이션이 DB에 Security Context를 Attach할 권한이 있음을 증명하는 Token | +| 현재 ADB 로컬 실행 예제 | DDS Direct `END USER` Logon은 실행 검증됨. ORDS Handler의 DDS Bearer 단독 매핑 경로는 차단됨. Driver 기반 Security Context Attach는 IAM/DB Access Token 구성이 필요하므로 별도 구성 필요 | + +검증 근거: + +| 문서 | 확인한 내용 | +|---|---| +| [End-User Security Context](https://docs.oracle.com/en/database/oracle/oracle-database/26/ddscg/end-user-security-context.html) | Driver가 End-user Security Context Payload를 Connection Metadata로 전달하고, DB가 이를 검증해 Context를 생성/Attach | +| [Configure the Database for Local End-User Authentication](https://docs.oracle.com/en/database/oracle/oracle-database/26/ddscg/configure-database-local-end-user-authentication.html) | Local End User는 호출 애플리케이션이 제공한 User Name과 Security Context Lookup Key로 식별 | +| [Configure Python Applications](https://docs.oracle.com/en/database/oracle/oracle-database/26/ddscg/configure-python-applications.html) | `create_end_user_security_context`, `set_end_user_security_context`, `clear_end_user_security_context` API 지원 | +| [Read End-User Context Attributes](https://docs.oracle.com/en/database/oracle/oracle-database/26/ddscg/read-end-user-context-attributes.html) | `ORA_END_USER_CONTEXT`로 현재 Security Context의 Attribute 참조 가능 | +| [ORDS parameter binding](https://docs.oracle.com/en/database/oracle/oracle-rest-data-services/25.3/orddg/ORDS-reference.html) | `ORDS.DEFINE_PARAMETER`로 HTTP 헤더를 Handler Bind Variable에 매핑 가능 | + +
+ +## 3. 권한 매핑 테이블 설계 + +이 장에서는 VPD 경로에서 사용할 업무 권한 테이블을 정의한다. 핵심 구조는 `사용자 -> 역할 -> 보호 객체 권한 -> 행 규칙`이며, Bearer Key는 내부 사용자로 매핑되는 식별 수단으로 둔다. + +
+상세 설명 - Key User, Role, Permission, 행 규칙 + +VPD에서 권한은 사용자, 역할, 보호 객체, 행(row) 조건으로 구분한다. + +```mermaid +%%{init: {'themeVariables': {'fontSize': '17px'}}}%% +erDiagram + APP_USER ||--o{ AGENT_BEARER_KEY : "key maps to user" + APP_USER ||--o{ USER_ROLE : "user has role" + APP_ROLE ||--o{ USER_ROLE : "assigned to user" + APP_ROLE ||--o{ PERMISSION : "role grants permission" + PERMISSION ||--o{ PERMISSION_RULE : "permission has row rule" + + APP_USER { + NUMBER user_id PK + VARCHAR2 db_username + VARCHAR2 employee_no + VARCHAR2 dept_code + CHAR can_read_contents + CHAR active + } + + AGENT_BEARER_KEY { + NUMBER key_id PK + NUMBER user_id FK + VARCHAR2 key_hash + DATE expires_at + CHAR active + } + + APP_ROLE { + NUMBER role_id PK + VARCHAR2 role_name + } + + USER_ROLE { + NUMBER user_id FK + NUMBER role_id FK + } + + PERMISSION { + NUMBER perm_id PK + NUMBER role_id FK + VARCHAR2 target_name + VARCHAR2 action_name + } + + PERMISSION_RULE { + NUMBER rule_id PK + NUMBER perm_id FK + VARCHAR2 rule_type + VARCHAR2 rule_value + } +``` + +테이블 의미: + +| 테이블 | 의미 | +|---|---| +| `APP_USER` | 내부 사용자. DB 계정 또는 Bearer Key가 최종적으로 가리키는 대상 | +| `AGENT_BEARER_KEY` | Bearer Key로 내부 사용자를 찾기 위한 테이블 | +| `APP_ROLE` | 권한 묶음 | +| `USER_ROLE` | 사용자와 역할 매핑 | +| `PERMISSION` | 어떤 보호 객체(VIEW/TABLE)를 어떤 작업으로 볼 수 있는지 | +| `PERMISSION_RULE` | 해당 보호 객체 안에서 어떤 행(row)을 볼 수 있는지 | + +`PERMISSION.target_name`에는 `V_SEARCH_DOCUMENTS`, `T_SEARCH_DOCUMENTS`, `V_EMPLOYEE_DOCS`처럼 보호 객체 이름을 등록한다. VPD 함수는 하드코딩된 객체 이름이 아니라 Oracle이 넘겨준 `p_object` 값으로 이 컬럼을 조회한다. + +
+ +## 4. VPD 기반 접근 통제 설계 + +이 장에서는 ORDS가 Bearer Key를 내부 사용자로 매핑한 뒤, DB Session Context와 VPD 정책 함수로 행(row)을 제한하는 방식을 설명한다. 컬럼 값 NULL/마스킹은 VPD가 아니라 Oracle Data Redaction으로 분리해 설명한다. + +
+상세 설명 - SYS_CONTEXT, p_object, EXISTS, Redaction + +VPD는 보호 객체(VIEW/TABLE)에 정책 함수를 연결한다. Agent나 ORDS가 권한 조건을 직접 붙이지 않아도, DB가 조회 시점에 조건을 자동으로 붙인다. + +![VPD 조회 조건 적용 흐름](assets/agent-ords-security-vpd-where-flow.svg) + +VPD를 이해할 때 필요한 세 가지: + +| 요소 | 역할 | +|---|---| +| `SYS_CONTEXT` | 현재 요청자 값 확인. 예: `USER_ID`, 사번, 부서코드 | +| `p_object` | 현재 조회 중인 보호 객체 이름 | +| `EXISTS` | 권한 테이블에 접근 근거가 있는지 확인 | + +ORDS Handler가 실행하는 SQL은 검색 조건만 갖는다. + +```sql +SELECT doc_id, title, owner_emp_no, dept_code +FROM app.v_search_documents +WHERE contains_text = :q; +``` + +권한 판단은 VPD 함수가 수행한다. + +```sql +EXISTS ( + SELECT 1 + FROM app.user_role ur + JOIN app.permission p + ON p.role_id = ur.role_id + JOIN app.permission_rule r + ON r.perm_id = p.perm_id + WHERE ur.user_id = TO_NUMBER(SYS_CONTEXT('AGENT_CTX', 'USER_ID')) + AND p.target_name = '' + AND p.action_name = 'SELECT' + AND ( + r.rule_type = 'ALL' + OR (r.rule_type = 'MY_DEPT' + AND dept_code = SYS_CONTEXT('AGENT_CTX', 'DEPT_CODE')) + OR (r.rule_type = 'SELF' + AND owner_emp_no = SYS_CONTEXT('AGENT_CTX', 'EMP_NO')) + ) +) +``` + +의미: + +| 판단 | 확인 방식 | 결과 | +|---|---|---| +| 이 보호 객체를 조회할 수 있는가 | `permission.target_name = p_object` | 없으면 0건 | +| 이 행(row)을 볼 수 있는가 | `permission_rule`과 행 컬럼 비교 | 조건에 맞는 행(row)만 반환 | + +즉, ORDS runtime schema에 보호 VIEW/TABLE 조회 권한이 있더라도 업무 권한 매핑이 없으면 결과는 나오지 않는다. + +```text +DB SELECT Grant 있음 ++ VPD policy 연결됨 ++ user_role / permission / permission_rule 매핑 없음 += 조회 가능하지만 결과 0건 +``` + +반대로 보호 객체 자체에 대한 DB 권한이 없거나 원본 TABLE을 직접 조회하려고 하면 VPD 판단 전 단계에서 객체가 보이지 않거나 권한 오류가 발생한다. + +```text +보호 VIEW/TABLE DB 권한 미부여 += ORA-00942 또는 ORA-01031 +``` + +### 4.1 SYS_CONTEXT 동작 + +![SYS_CONTEXT 동작 방식](assets/agent-ords-security-sys-context-flow.svg) + +ORDS Handler는 요청자를 식별한 뒤 같은 DB 세션에 값을 저장한다. + +```sql +DBMS_SESSION.SET_CONTEXT('AGENT_CTX', 'USER_ID', '42'); +DBMS_SESSION.SET_CONTEXT('AGENT_CTX', 'EMP_NO', 'E10234'); +DBMS_SESSION.SET_CONTEXT('AGENT_CTX', 'DEPT_CODE', 'HR'); +``` + +VPD 정책 함수는 조회 시점에 같은 세션에서 값을 읽는다. + +```sql +SYS_CONTEXT('AGENT_CTX', 'USER_ID') -- 42 +SYS_CONTEXT('AGENT_CTX', 'EMP_NO') -- E10234 +SYS_CONTEXT('AGENT_CTX', 'DEPT_CODE') -- HR +``` + +조회 대상 이름은 `SYS_CONTEXT`에 넣지 않는다. Oracle이 VPD 정책 함수를 호출할 때 `p_object` 인자로 현재 조회 중인 보호 객체 이름을 넘겨준다. + +### 4.2 여러 VIEW/TABLE 조회 + +여러 보호 객체가 한 SQL에 들어가도 원리는 같다. VPD는 정책이 연결된 각 VIEW/TABLE마다 따로 적용된다. + +```sql +SELECT d.doc_id, + d.title, + e.employee_name +FROM app.v_search_documents d +JOIN app.v_employee_docs e +ON e.owner_emp_no = d.owner_emp_no +WHERE d.contains_text = :q; +``` + +의미를 풀면 아래와 같다. + +```sql +SELECT d.doc_id, + d.title, + e.employee_name +FROM app.v_search_documents d +JOIN app.v_employee_docs e +ON e.owner_emp_no = d.owner_emp_no +WHERE d.contains_text = :q +AND EXISTS (V_SEARCH_DOCUMENTS 권한 확인) +AND EXISTS (V_EMPLOYEE_DOCS 권한 확인); +``` + +원본 TABLE을 직접 조회할 수 있게 열어두면 보호 객체를 우회할 수 있다. 따라서 API가 조회할 보호 객체만 권한을 주고, 원본 TABLE과 권한 테이블은 직접 조회 권한을 주지 않는다. + +### 4.3 컬럼 처리 + +VPD 기본 정책은 행(row) 제한을 담당한다. 컬럼을 허용하거나 제외하는 권한 모델로는 사용하지 않는다. 이 문서의 실행 예제에서 `contents` 컬럼을 NULL로 만드는 것은 VPD가 아니라 **Oracle Data Redaction**이다. + +정확히 나누면 아래와 같다. + +| 구분 | 역할 | 이 문서의 적용 | +|---|---|---| +| VPD 기본 정책 | `WHERE`에 붙을 행(row) 조건 반환 | 사용 | +| Redaction | 특정 컬럼 값을 NULL 또는 마스킹 값으로 변환 | 사용. 컬럼 Grant가 아니라 값 마스킹 | +| VIEW/TABLE 설계 | 애초에 노출 객체에서 컬럼을 제외 | 선택 가능 | +| DDS DATA GRANT | `AS SELECT (ALL COLUMNS EXCEPT ...)`로 컬럼 허용/제외 | DDS 경로에서 사용 | +| Column-level VPD 옵션 | `sec_relevant_cols`, `sec_relevant_cols_opt` 옵션이 있으나 이 구조의 권한 모델로는 사용하지 않음 | 미사용 | + +따라서 아래 예제에서 컬럼 값 NULL 처리는 `DBMS_REDACT.ADD_POLICY`가 수행한다. VPD는 어떤 행(row)이 반환될지를 결정하고, Redaction은 반환되는 행(row) 안의 `contents` 값을 보일지 NULL로 바꿀지를 결정한다. + +컬럼 허용/제외를 권한으로 선언해야 한다면 DDS의 `DATA GRANT` 또는 별도 VIEW/TABLE 설계를 사용한다. + +| 방식 | 의미 | 적합한 경우 | +|---|---|---| +| 보호 VIEW/TABLE 설계 | API에서 노출할 객체에 필요한 컬럼만 포함 | 특정 API에서 컬럼을 아예 제공하지 않을 때 | +| Oracle Data Redaction | 컬럼 값을 NULL, 부분 마스킹, 정규식 마스킹 값으로 변환 | 같은 객체를 쓰되 사용자 권한에 따라 컬럼 값을 숨길 때 | +| DDS DATA GRANT | 컬럼 허용/제외를 선언 | DDS END USER 또는 Security Context 기반 통제가 가능할 때 | +| 별도 VIEW/TABLE 분리 | 민감 컬럼이 있는 객체와 없는 객체를 분리 | 컬럼 권한이 단순하고 운영 분리가 쉬울 때 | + +예: + +```sql +BEGIN + DBMS_REDACT.ADD_POLICY( + object_schema => USER, + object_name => 'CB_V_SEARCH_DOCUMENTS', + column_name => 'CONTENTS', + policy_name => 'CB_CONTENTS_REDACT', + function_type => DBMS_REDACT.NULLIFY, + expression => + 'SYS_CONTEXT(''CB_AGENT_CTX'', ''CAN_READ_CONTENTS'') IS NULL + OR SYS_CONTEXT(''CB_AGENT_CTX'', ''CAN_READ_CONTENTS'') != ''Y''' + ); +END; +/ +``` + +| 상태 | 행(row) 제한 | `contents` 컬럼 | +|---|---|---| +| HR Key | HR 행(row)만 조회 | NULL | +| FIN Self Key | 본인 행(row)만 조회 | NULL | +| ALL Key | 전체 행(row) 조회 | 원문 표시 | + +### 4.4 VPD 정책 연결 + +`DBMS_RLS.ADD_POLICY`는 권한을 직접 부여하는 코드가 아니다. 보호 객체를 조회할 때 어떤 VPD 함수를 실행할지 연결하는 코드다. + +```sql +BEGIN + DBMS_RLS.ADD_POLICY( + object_schema => 'APP', + object_name => 'V_SEARCH_DOCUMENTS', + policy_name => 'P_AGENT_DOC_FILTER', + function_schema => 'APP', + policy_function => 'AGENT_DOC_VPD_FILTER', + statement_types => 'SELECT', + enable => TRUE + ); +END; +/ +``` + +파라미터 매핑: + +| `ADD_POLICY` 항목 | 예제 값 | 역할 | 연결되는 값 | +|---|---|---|---| +| `object_schema` | `APP` | VPD를 적용할 보호 객체의 소유 schema | VPD 함수의 `p_schema` | +| `object_name` | `V_SEARCH_DOCUMENTS` | VPD를 적용할 보호 객체 이름 | VPD 함수의 `p_object`, `permission.target_name` | +| `policy_name` | `P_AGENT_DOC_FILTER` | VPD 정책 관리 이름 | 정책 조회, 비활성화, 삭제 | +| `function_schema` | `APP` | VPD 정책 함수가 있는 schema | `APP.AGENT_DOC_VPD_FILTER` 호출 | +| `policy_function` | `AGENT_DOC_VPD_FILTER` | 행(row) 필터 조건을 반환하는 함수 | `EXISTS (...)` 반환 | +| `statement_types` | `SELECT` | 적용할 SQL 작업 | 조회 SQL에만 적용 | +| `enable` | `TRUE` | 정책 활성화 여부 | 등록 즉시 적용 | + +실행 시 흐름: + +1. `APP.V_SEARCH_DOCUMENTS`에 연결된 `P_AGENT_DOC_FILTER` 정책 확인 +2. `APP.AGENT_DOC_VPD_FILTER` 함수 호출 +3. `p_schema = 'APP'`, `p_object = 'V_SEARCH_DOCUMENTS'` 전달 +4. 함수가 `SYS_CONTEXT`와 권한 테이블 확인 +5. 함수가 반환한 조건을 원래 SQL에 추가 +6. 조건을 통과한 행(row)만 반환 + +적용 단위: + +| 검토 항목 | 기준 | +|---|---| +| `ADD_POLICY`에서 모든 객체를 한 번에 지정할 수 있는가 | 아니다. `object_schema`, `object_name`으로 보호 객체 하나를 지정한다. | +| `object_name => '*'` 또는 `%` 같은 Wildcard를 쓸 수 있는가 | 아니다. VPD `ADD_POLICY`의 `object_name`은 실제 TABLE, VIEW, SYNONYM 이름이다. | +| `statement_types`에 여러 SQL 작업을 지정하면 전체 객체 적용인가 | 아니다. `SELECT, INSERT, UPDATE, DELETE`처럼 적용할 SQL 작업 종류를 지정하는 값이다. 보호 객체 Wildcard가 아니다. | +| 같은 정책 함수를 여러 객체에 재사용할 수 있는가 | 가능하다. 같은 함수가 `p_object`를 받아 현재 조회 대상별 권한을 판단하게 만들 수 있다. | +| 객체가 많으면 어떻게 등록하는가 | 보호 객체 목록을 기준으로 `DBMS_RLS.ADD_POLICY`를 반복 실행하는 등록 스크립트를 만든다. | +| 정책이 붙지 않은 객체는 어떻게 되는가 | 그 객체에는 VPD가 적용되지 않는다. 그래서 원본 TABLE 직접 권한을 주지 않고, 노출 VIEW/TABLE 목록을 관리해야 한다. | + +여러 보호 객체에 같은 VPD 함수를 붙이는 등록 스크립트 예: + +```sql +BEGIN + FOR r IN ( + SELECT 'V_SEARCH_DOCUMENTS' AS object_name FROM dual + UNION ALL + SELECT 'V_EMPLOYEE_DOCS' AS object_name FROM dual + ) LOOP + DBMS_RLS.ADD_POLICY( + object_schema => 'APP', + object_name => r.object_name, + policy_name => 'P_AGENT_' || r.object_name, + function_schema => 'APP', + policy_function => 'AGENT_DOC_VPD_FILTER', + statement_types => 'SELECT', + enable => TRUE + ); + END LOOP; +END; +/ +``` + +이 방식에서 권한 함수는 `p_object`로 현재 보호 객체 이름을 받고, `permission.target_name`과 비교한다. 그래서 정책 함수는 하나로 유지하고, 정책 연결만 보호 객체별로 반복할 수 있다. + +보호 객체 목록 테이블을 쓰는 예: + +```sql +CREATE TABLE app_protected_object ( + object_name VARCHAR2(128) PRIMARY KEY, + enabled CHAR(1) DEFAULT 'Y' CHECK (enabled IN ('Y','N')) NOT NULL +); + +INSERT INTO app_protected_object(object_name) VALUES ('V_SEARCH_DOCUMENTS'); +INSERT INTO app_protected_object(object_name) VALUES ('V_EMPLOYEE_DOCS'); + +BEGIN + FOR r IN ( + SELECT object_name + FROM app_protected_object + WHERE enabled = 'Y' + ) LOOP + DBMS_RLS.ADD_POLICY( + object_schema => 'APP', + object_name => r.object_name, + policy_name => 'P_AGENT_' || r.object_name, + function_schema => 'APP', + policy_function => 'AGENT_DOC_VPD_FILTER', + statement_types => 'SELECT', + enable => TRUE + ); + END LOOP; +END; +/ +``` + +정리하면 “전체 적용”은 한 번의 Wildcard 지정이 아니라 “보호 객체 목록 전체를 돌면서 객체별 `ADD_POLICY`를 생성”하는 방식이다. + +정책 연결 확인: + +```sql +SELECT object_owner, + object_name, + policy_name, + function, + sel, + enable +FROM dba_policies +WHERE object_owner = 'APP' +ORDER BY object_name, policy_name; +``` + +
+ +## 5. DDS 기반 접근 통제 설계 + +이 장에서는 DDS `END USER`, `DATA ROLE`, `DATA GRANT`를 이용해 보호 객체의 행(row), 컬럼(column), 작업(action)을 선언형으로 통제하는 방식을 설명한다. Bearer Key를 DDS에 적용하려면 Key 조회 결과가 `EndUserSecurityContext`로 DB 호출 전에 전파되어야 한다. + +
+상세 설명 - END USER, DATA ROLE, DATA GRANT + +DDS는 Oracle 보안 오브젝트로 권한을 선언한다. VPD처럼 권한 테이블을 읽는 함수가 아니라, `END USER -> DATA ROLE -> DATA GRANT -> 보호 객체` 구조다. + +![DDS 보안 오브젝트 모델](assets/agent-ords-security-dds-object-model.svg) + +![DDS 설정 흐름](assets/agent-ords-security-dds-setup-flow.svg) + +`DATA GRANT`는 어떤 보호 객체(VIEW/TABLE)에 대해 어떤 행(row), 컬럼(column), 작업(operation)을 허용하는지 선언한다. + +| 항목 | DDS 표현 | +|---|---| +| 사용자 | `CREATE END USER` | +| 역할 | `CREATE DATA ROLE` | +| 대상 | `DATA GRANT ... ON ` | +| 행(row) 제한 | `WHERE dept_code = 'HR'` | +| 컬럼(column) 허용/제외 | `AS SELECT (ALL COLUMNS EXCEPT contents)` | +| 권한 미부여 | `ORA-00942`, 객체 자체가 보이지 않음 | + +참고로 DDS에는 `CREATE APPLICATION IDENTITY`도 있다. 이 객체는 애플리케이션 자체를 DB 보안 Identity로 등록하고 DATA ROLE을 부여할 때 사용한다. 본 문서의 실행 예제는 사용자별 권한 차이를 보여주기 위해 `END USER`만 사용한다. + +DDS에서 Bearer Key를 사용하려면 Key 자체를 `DATA GRANT`가 읽는 것이 아니라, Key 검증 결과가 DDS `EndUserSecurityContext`로 DB 호출 전에 전달되어야 한다. ORDS PL/SQL Handler 안에서 Key를 테이블 조회로 매핑하는 것만으로는 DDS 보안 사용자가 바뀌지 않는다. 이 패턴은 아래 ADB 검증에서 `EXPECTED_BLOCKED`로 확인했다. + +기본 예: + +```sql +CREATE END USER "agent_hr_001" + IDENTIFIED BY "&AGENT_HR_001_PASSWORD"; + +CREATE ROLE dds_connect_role; +GRANT CREATE SESSION TO dds_connect_role; + +CREATE DATA ROLE hr_search_role; +GRANT dds_connect_role TO hr_search_role; +GRANT DATA ROLE hr_search_role TO "agent_hr_001"; + +CREATE OR REPLACE DATA GRANT admin.dg_hr_search_documents + AS SELECT + ON admin.v_search_documents + WHERE dept_code = 'HR' + TO hr_search_role; +``` + +행(row) 제한과 컬럼(column) 허용/제외를 한 번에 선언하는 예: + +```sql +CREATE OR REPLACE DATA GRANT admin.dg_hr_search_documents + AS SELECT (ALL COLUMNS EXCEPT contents) + ON admin.v_search_documents + WHERE dept_code = 'HR' + TO hr_search_role; +``` + +여러 보호 객체를 함께 조회하면 각 대상에 대한 `DATA GRANT`가 필요하다. + +```sql +CREATE OR REPLACE DATA GRANT admin.dg_hr_search_documents + AS SELECT + ON admin.v_search_documents + WHERE dept_code = 'HR' + TO hr_search_role; + +CREATE OR REPLACE DATA GRANT admin.dg_hr_employee_docs + AS SELECT + ON admin.v_employee_docs + WHERE dept_code = 'HR' + TO hr_search_role; +``` + +`v_search_documents` 권한만 있고 `v_employee_docs` 권한이 없으면, 두 보호 객체를 함께 조회하는 SQL은 `v_employee_docs` 접근 단계에서 실패하거나 허용되지 않는다. + +DDS 적용과 확인 단위: + +| 검토 항목 | 기준 | +|---|---| +| DDS도 객체별로 스크립트가 필요한가 | `DATA GRANT`는 보호 객체, DATA ROLE, 조건 단위로 선언한다. 객체/역할/조건이 다르면 별도 `DATA GRANT`가 필요하다. | +| 권한을 한눈에 확인할 수 있는가 | 가능하다. `DBA_DATA_GRANTS`와 `DBA_DATA_ROLE_GRANTS`를 조합하면 DATA ROLE, 보호 객체, 행 조건, 제외 컬럼을 볼 수 있다. | +| 한눈에 볼 수 없는 것은 무엇인가 | 승인 요청 사유, 업무 결재 상태, Ticket 번호 같은 업무 메타데이터는 DDS Dictionary에 없다. 별도 승인 테이블이나 Ticket 시스템에 남겨야 한다. | +| 운영에서 권장하는 방식 | `DATA GRANT` 이름 규칙과 보호 객체 목록을 표준화하고, Dictionary 조회 결과를 정기 점검 또는 배포 검증에 포함한다. | + +DDS 권한 요약 조회: + +```sql +SELECT grant_name, + object_name, + grantee, + privilege, + COALESCE( + LISTAGG(column_name, ', ') WITHIN GROUP (ORDER BY column_name), + 'ALL COLUMNS' + ) AS allowed_columns, + COALESCE(MAX(granted_with_all_columns_except), '-') AS excluded_columns, + predicate +FROM dba_data_grants +WHERE owner = 'ADMIN' +GROUP BY grant_name, object_name, grantee, privilege, predicate +ORDER BY object_name, grant_name; +``` + +DDS end user별 권한 매트릭스 조회: + +```sql +SELECT rg.grantee AS end_user_name, + rg.data_role, + dg.grant_name, + dg.object_name, + dg.predicate, + COALESCE(MAX(dg.granted_with_all_columns_except), '-') AS excluded_columns +FROM dba_data_role_grants rg +LEFT JOIN dba_data_grants dg +ON dg.grantee = rg.data_role +GROUP BY rg.grantee, + rg.data_role, + dg.grant_name, + dg.object_name, + dg.predicate +ORDER BY rg.grantee, dg.object_name, dg.grant_name; +``` + +
+ +## 6. VPD/DDS 비교 및 적용 기준 + +이 장에서는 앞에서 분리 설명한 VPD와 DDS를 운영 기준으로 비교한다. 비교 기준은 사용자 식별 방식, 권한 저장 위치, 행/컬럼 통제 범위, Bearer Key 처리 방식, 적용 단위, 권한 미부여 시 결과로 둔다. + +
+상세 설명 - 운영 선택 기준 + +| 항목 | VPD | DDS | +|---|---|---| +| 권한 표현 방식 | PL/SQL 정책 함수가 SQL 조건 반환 | `CREATE DATA GRANT`로 권한 선언 | +| 정책 적용 단위 | `DBMS_RLS.ADD_POLICY` 1회당 보호 객체 1개 | `DATA GRANT` 1개당 보호 객체, 역할, 조건 1묶음 | +| 권한 저장 위치 | 업무 권한 테이블 | Oracle Dictionary의 DDS 보안 오브젝트 | +| 중앙 확인 방법 | `DBA_POLICIES`, `REDACTION_POLICIES`, 업무 권한 테이블 | `DBA_DATA_GRANTS`, `DBA_DATA_ROLE_GRANTS`, `DBA_DATA_ROLES`, `DBA_END_USERS` | +| 사용자 식별 | DB 계정, Bearer Key, `SYS_CONTEXT` 조합 | `END USER`, `DATA ROLE` | +| DB user와 실제 사용자 | 공통 DB 계정 아래 다수 key 사용자를 테이블로 구분 가능 | DDS 사용자가 실제 사용자에 가깝게 매핑되어야 명확 | +| Bearer Key 처리 | Key Hash를 테이블에서 찾아 Context 저장 | Key User를 DDS `EndUserSecurityContext`로 전파해야 가능. ORDS Handler의 DDS Bearer 단독 매핑은 차단됨 | +| 보호 객체 | VIEW 또는 TABLE | VIEW 또는 TABLE | +| 행(row) 제한 | `EXISTS`, `dept_code`, `owner_emp_no` 조건 반환 | `DATA GRANT ... WHERE ...` | +| 컬럼(column) 허용/제외 | VIEW/TABLE 설계. Redaction은 값 마스킹/NULL | `AS SELECT (ALL COLUMNS EXCEPT ...)` | +| 권한 미부여 시 | 0건 반환 가능 | 객체 자체가 보이지 않음. 예: `ORA-00942` | +| 권한 변경 | 권한 테이블 `INSERT/UPDATE/DELETE` | `CREATE OR REPLACE DATA GRANT`, `DROP DATA GRANT`, `GRANT DATA ROLE` | +| 운영상 주의 | 정책을 붙이지 않은 객체는 보호되지 않음 | 승인 사유 같은 업무 메타데이터는 Dictionary에 없음 | +| 적합한 경우 | key 사용자 많음, 권한 변경 잦음, 테이블 기반 운영 | 역할이 안정적이고 정책을 DDL로 명확히 관리 | + +선택 기준: + +| 선택지 | 추천 상황 | 설명 | +|---|---|---| +| VPD | Bearer Key 사용자가 많고 세분화된 업무 권한 테이블이 필요한 경우 | `DB user != key user` 구조에 적합 | +| DDS | DDS `END USER` Identity를 명확히 전파할 수 있는 경우 | 선언형 보안 오브젝트 관리에 적합 | +| 둘 다 비교 | 전환기 또는 기능 검증 | 같은 VIEW/TABLE에 동시에 걸지 말고 별도 보호 객체로 분리 | + +
+ +## 7. ADB 실행 검증 + +이 장에서는 ADB에 생성한 로컬 테이블 기준으로 VPD + Redaction, DDS, ORDS Handler Bearer 검증을 실행한 스크립트와 결과를 제시한다. + +
+상세 설명 - 실행 가능한 VPD/DDS 스크립트와 결과 + +이 예제는 RDS, DB Link, Postgres, MySQL을 사용하지 않는다. ADB 안에 로컬 테이블을 만들고, 같은 흐름에서 VPD 경로와 DDS 경로를 각각 확인한다. + +실행: + +```bash +./scripts/run_agent_ords_security_adb_local.sh +``` + +실행 파일: + +| 파일 | 역할 | +|---|---| +| `scripts/run_agent_ords_security_adb_local.sh` | 전체 실행 | +| `sql/adb/16_agent_ords_security_local_cleanup.sql` | `CB_*` 예제 객체 정리 | +| `sql/adb/17_agent_ords_security_local_vpd_setup.sql` | VPD용 로컬 테이블, 권한 테이블, context, redaction, 정책 생성 | +| `sql/adb/18_agent_ords_security_local_vpd_test.sql` | `CB_ORDS`로 Bearer Key 기반 VPD 테스트 | +| `sql/adb/19_agent_ords_security_local_dds_setup.sql` | DDS용 로컬 테이블, END USER, DATA ROLE, DATA GRANT 생성 | +| `sql/adb/20_agent_ords_security_local_dds_test.sql` | DDS end user별 조회 테스트 | +| `sql/adb/21_agent_ords_security_ords_enable_schema.sql` | `CB_ORDS` schema를 ORDS에 enable | +| `sql/adb/22_agent_ords_security_ords_handler_setup.sql` | ORDS Module/Handler와 Handler Package 생성 | +| `sql/adb/23_agent_ords_security_ords_handler_test.sql` | Handler Package 직접 실행으로 VPD/DDS Bearer 경로 검증 | +| `sql/adb/24_agent_ords_security_inventory.sql` | VPD/DDS 정책과 권한을 중앙 조회 | + +### 7.1 VPD + Redaction 실행 검증 + +VPD 예제의 흐름: + +1. ORDS runtime schema `CB_ORDS`가 DB에 접속 +2. ORDS Handler가 Bearer Key를 내부 사용자로 매핑 +3. 내부 사용자 값을 `CB_AGENT_CTX`에 저장 +4. `CB_V_SEARCH_DOCUMENTS` 조회 +5. VPD가 행(row) 제한 +6. Redaction이 `contents` 값을 NULL/마스킹 처리 + +사용한 핵심 스크립트: + +```sql +CREATE TABLE cb_search_documents ( + doc_id NUMBER PRIMARY KEY, + title VARCHAR2(100) NOT NULL, + owner_emp_no VARCHAR2(20) NOT NULL, + dept_code VARCHAR2(20) NOT NULL, + contents VARCHAR2(4000), + created_at DATE DEFAULT SYSDATE NOT NULL +); + +CREATE OR REPLACE VIEW cb_v_search_documents AS +SELECT doc_id, title, owner_emp_no, dept_code, contents, created_at +FROM cb_search_documents; + +CREATE TABLE cb_app_user ( + user_id NUMBER PRIMARY KEY, + user_name VARCHAR2(50) NOT NULL, + employee_no VARCHAR2(20) NOT NULL, + dept_code VARCHAR2(20) NOT NULL, + can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL, + active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL +); + +INSERT INTO cb_app_user VALUES (101, 'agent_hr', 'E10234', 'HR', 'N', 'Y'); +INSERT INTO cb_app_user VALUES (102, 'agent_fin_self', 'E2001', 'FIN', 'N', 'Y'); +INSERT INTO cb_app_user VALUES (103, 'agent_all', 'E99999', 'HQ', 'Y', 'Y'); + +INSERT INTO cb_agent_bearer_key(key_id, user_id, key_hash, key_prefix) +VALUES (1, 101, STANDARD_HASH('cb_hr_key', 'SHA256'), 'cb_hr'); +``` + +Bearer Key 매핑: + +```sql +CREATE OR REPLACE CONTEXT cb_agent_ctx USING cb_agent_ctx_pkg; + +CREATE OR REPLACE PACKAGE BODY cb_agent_ctx_pkg AS + PROCEDURE set_user_by_bearer(p_bearer_key IN VARCHAR2) AS + v_user_id cb_app_user.user_id%TYPE; + v_emp_no cb_app_user.employee_no%TYPE; + v_dept_code cb_app_user.dept_code%TYPE; + v_can_read_contents cb_app_user.can_read_contents%TYPE; + BEGIN + SELECT u.user_id, u.employee_no, u.dept_code, u.can_read_contents + INTO v_user_id, v_emp_no, v_dept_code, v_can_read_contents + FROM cb_agent_bearer_key k + JOIN cb_app_user u + ON u.user_id = k.user_id + WHERE k.key_hash = STANDARD_HASH(p_bearer_key, 'SHA256') + AND k.active = 'Y' + AND k.revoked_at IS NULL + AND (k.expires_at IS NULL OR k.expires_at > SYSDATE) + AND u.active = 'Y'; + + set_user_values(v_user_id, v_emp_no, v_dept_code, v_can_read_contents); + EXCEPTION + WHEN NO_DATA_FOUND THEN + clear_user; + RAISE_APPLICATION_ERROR(-20002, 'Invalid or expired Bearer key'); + END; +END; +/ +``` + +VPD와 Redaction: + +```sql +CREATE OR REPLACE FUNCTION cb_agent_doc_vpd_filter( + p_schema IN VARCHAR2, + p_object IN VARCHAR2 +) RETURN VARCHAR2 +AUTHID DEFINER +AS + v_user_id VARCHAR2(30); + v_target_name VARCHAR2(128); +BEGIN + v_user_id := SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'); + + IF v_user_id IS NULL THEN + RETURN '1 = 0'; + END IF; + + v_target_name := REPLACE(UPPER(p_object), '''', ''''''); + + RETURN + 'EXISTS ( + SELECT 1 + FROM admin.cb_user_role ur + JOIN admin.cb_permission p ON p.role_id = ur.role_id + JOIN admin.cb_permission_rule r ON r.perm_id = p.perm_id + WHERE ur.user_id = TO_NUMBER(SYS_CONTEXT(''CB_AGENT_CTX'', ''USER_ID'')) + AND p.target_name = ''' || v_target_name || ''' + AND p.action_name = ''SELECT'' + AND ( + r.rule_type = ''ALL'' + OR (r.rule_type = ''MY_DEPT'' + AND dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')) + OR (r.rule_type = ''SELF'' + AND owner_emp_no = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')) + ) + )'; +END; +/ + +BEGIN + DBMS_RLS.ADD_POLICY( + object_schema => USER, + object_name => 'CB_V_SEARCH_DOCUMENTS', + policy_name => 'CB_AGENT_DOC_POLICY', + function_schema => USER, + policy_function => 'CB_AGENT_DOC_VPD_FILTER', + statement_types => 'SELECT', + enable => TRUE + ); +END; +/ + +BEGIN + DBMS_REDACT.ADD_POLICY( + object_schema => USER, + object_name => 'CB_V_SEARCH_DOCUMENTS', + column_name => 'CONTENTS', + policy_name => 'CB_CONTENTS_REDACT', + function_type => DBMS_REDACT.NULLIFY, + expression => + 'SYS_CONTEXT(''CB_AGENT_CTX'', ''CAN_READ_CONTENTS'') IS NULL + OR SYS_CONTEXT(''CB_AGENT_CTX'', ''CAN_READ_CONTENTS'') != ''Y''' + ); +END; +/ +``` + +실행 결과: + +```text +=== VPD 1. No Bearer key / context: fail closed === +ROWS_VISIBLE +------------ +0 + +=== VPD 2. Authorization: Bearer cb_hr_key -> HR department rows === +CTX_USER_ID CTX_EMP_NO CTX_DEPT_COD CTX_CAN_READ_CONTENTS +------------ ------------ ------------ ---------------------- +101 E10234 HR N + +DOC_ID TITLE OWNER_EMP_NO DEPT_CODE CONTENTS +------ ------------------------ ------------ --------- -------- +1 HR payroll guide E1001 HR +2 HR recruiting plan E1002 HR +6 HR benefits notice E1003 HR + +ROWS_VISIBLE +------------ +3 + +=== VPD 3. Authorization: Bearer cb_fin_key -> self row only === +CTX_USER_ID CTX_EMP_NO CTX_DEPT_COD CTX_CAN_READ_CONTENTS +------------ ------------ ------------ ---------------------- +102 E2001 FIN N + +DOC_ID TITLE OWNER_EMP_NO DEPT_CODE CONTENTS +------ ------------------------ ------------ --------- -------- +3 Finance close checklist E2001 FIN + +ROWS_VISIBLE +------------ +1 + +=== VPD 4. Authorization: Bearer cb_all_key -> all rows === +ROWS_VISIBLE +------------ +6 + +DOC_ID TITLE OWNER_EMP_NO DEPT_CODE CONTENTS +------ ------------------------ ------------ --------- --------------------------- +1 HR payroll guide E1001 HR Payroll policy and HR guide +2 HR recruiting plan E1002 HR Recruiting plan for HR team +3 Finance close checklist E2001 FIN Monthly close checklist + +=== VPD 5. Invalid Bearer key -> ORA-20002 and context cleared === +ORA-20002: Invalid or expired Bearer key + +ROWS_VISIBLE_AFTER_INVALID_KEY +------------------------------ +0 + +=== VPD 6. Bypass attempts === +SELECT COUNT(*) FROM admin.cb_search_documents +ORA-00942: table or view "ADMIN"."CB_SEARCH_DOCUMENTS" does not exist +``` + +VPD 결과 해석: + +| 요청 상태 | 적용된 사용자 | 결과 | +|---|---|---| +| Bearer Key 없음 | 없음 | 0건 | +| `cb_hr_key` | `USER_ID=101`, `DEPT_CODE=HR`, `CAN_READ_CONTENTS=N` | HR 행(row) 3건, `contents` NULL | +| `cb_fin_key` | `USER_ID=102`, `EMP_NO=E2001`, `CAN_READ_CONTENTS=N` | 본인 행(row) 1건, `contents` NULL | +| `cb_all_key` | `USER_ID=103`, `CAN_READ_CONTENTS=Y` | 전체 6건, `contents` 원문 표시 | +| 잘못된 Key | Context 초기화 | 오류 후 0건 | + +### 7.2 DDS 실행 검증 + +DDS 예제의 흐름: + +1. DDS `END USER`로 접속 +2. `DATA ROLE`로 권한 묶음 확인 +3. `DATA GRANT`가 보호 객체와 행/컬럼 조건 적용 +4. 권한이 없으면 객체가 보이지 않음 + +사용한 핵심 스크립트: + +```sql +CREATE TABLE cb_dds_documents ( + doc_id NUMBER PRIMARY KEY, + title VARCHAR2(100) NOT NULL, + owner_emp_no VARCHAR2(20) NOT NULL, + dept_code VARCHAR2(20) NOT NULL, + contents VARCHAR2(4000), + created_at DATE DEFAULT SYSDATE NOT NULL +); + +CREATE OR REPLACE VIEW cb_dds_v_search_documents AS +SELECT doc_id, title, owner_emp_no, dept_code, contents, created_at +FROM cb_dds_documents; + +CREATE END USER "cb_dds_hr" IDENTIFIED BY "CbDds#Hr2026Local1"; +CREATE END USER "cb_dds_fin" IDENTIFIED BY "CbDds#Fin2026Local1"; +CREATE END USER "cb_dds_all" IDENTIFIED BY "CbDds#All2026Local1"; +CREATE END USER "cb_dds_none" IDENTIFIED BY "CbDds#None2026Local1"; + +CREATE ROLE cb_dds_connect_role; +GRANT CREATE SESSION TO cb_dds_connect_role; + +CREATE DATA ROLE cb_dds_hr_role; +CREATE DATA ROLE cb_dds_fin_role; +CREATE DATA ROLE cb_dds_all_role; +CREATE DATA ROLE cb_dds_connect_only_role; + +GRANT cb_dds_connect_role TO cb_dds_hr_role; +GRANT cb_dds_connect_role TO cb_dds_fin_role; +GRANT cb_dds_connect_role TO cb_dds_all_role; +GRANT cb_dds_connect_role TO cb_dds_connect_only_role; + +GRANT DATA ROLE cb_dds_hr_role TO "cb_dds_hr"; +GRANT DATA ROLE cb_dds_fin_role TO "cb_dds_fin"; +GRANT DATA ROLE cb_dds_all_role TO "cb_dds_all"; +GRANT DATA ROLE cb_dds_connect_only_role TO "cb_dds_none"; + +CREATE DATA GRANT admin.cb_dg_hr_docs + AS SELECT (ALL COLUMNS EXCEPT contents) + ON admin.cb_dds_v_search_documents + WHERE dept_code = 'HR' + TO cb_dds_hr_role; + +CREATE DATA GRANT admin.cb_dg_fin_docs + AS SELECT (ALL COLUMNS EXCEPT contents) + ON admin.cb_dds_v_search_documents + WHERE dept_code = 'FIN' + TO cb_dds_fin_role; + +CREATE DATA GRANT admin.cb_dg_all_docs + AS SELECT + ON admin.cb_dds_v_search_documents + TO cb_dds_all_role; +``` + +실행 결과: + +```text +=== cb_dds_hr === +END_USER_NAME +---------------- +"cb_dds_hr" + +ROWS_VISIBLE +------------ +3 + +DOC_ID TITLE OWNER_EMP_NO DEPT_CODE CONTENTS +------ -------------------- ------------ --------- -------- +1 HR payroll guide E1001 HR +2 HR recruiting plan E1002 HR +6 HR benefits notice E1003 HR + +=== cb_dds_fin === +ROWS_VISIBLE +------------ +2 + +DOC_ID TITLE OWNER_EMP_NO DEPT_CODE CONTENTS +------ ------------------------ ------------ --------- -------- +3 Finance close checklist E2001 FIN +4 Finance audit memo E2002 FIN + +=== cb_dds_all === +ROWS_VISIBLE +------------ +6 + +DOC_ID TITLE OWNER_EMP_NO DEPT_CODE CONTENTS +------ ------------------------ ------------ --------- --------------------------- +1 HR payroll guide E1001 HR Payroll policy and HR guide +2 HR recruiting plan E1002 HR Recruiting plan for HR team +3 Finance close checklist E2001 FIN Monthly close checklist +4 Finance audit memo E2002 FIN Audit memo for finance team +5 Sales forecast E3001 SALES Quarterly sales forecast +6 HR benefits notice E1003 HR Benefits notice for employees + +=== cb_dds_none === +FROM admin.cb_dds_v_search_documents +ORA-00942: table or view "ADMIN"."CB_DDS_V_SEARCH_DOCUMENTS" does not exist +``` + +DDS 결과 해석: + +| DDS 사용자 | DATA ROLE | DATA GRANT | 결과 | +|---|---|---|---| +| `cb_dds_hr` | `cb_dds_hr_role` | `WHERE dept_code = 'HR'`, `EXCEPT contents` | 3건, `contents` NULL | +| `cb_dds_fin` | `cb_dds_fin_role` | `WHERE dept_code = 'FIN'`, `EXCEPT contents` | 2건, `contents` NULL | +| `cb_dds_all` | `cb_dds_all_role` | 행 제한 없음, 컬럼 제외 없음 | 6건, `contents` 원문 표시 | +| `cb_dds_none` | `cb_dds_connect_only_role` | 없음 | `ORA-00942` | + +### 7.3 ORDS Handler Bearer 검증 + +이 검증은 ORDS Handler가 `Authorization` 헤더를 받아 처리하는 구조를 ADB 안에서 재현한다. 외부 ORDS URL 호출은 하지 않고, Handler가 호출하는 같은 Package를 `CB_ORDS`로 직접 실행했다. + +ORDS schema enable: + +```sql +BEGIN + ORDS.ENABLE_SCHEMA( + p_enabled => TRUE, + p_schema => 'CB_ORDS', + p_url_mapping_type => 'BASE_PATH', + p_url_mapping_pattern => 'cb-ords', + p_auto_rest_auth => FALSE + ); + COMMIT; +END; +/ +``` + +Header를 Handler Bind Variable로 매핑: + +```sql +ORDS.DEFINE_PARAMETER( + p_module_name => 'cb.agent.security', + p_pattern => 'vpd/documents', + p_method => 'POST', + p_name => 'Authorization', + p_bind_variable_name => 'auth_header', + p_source_type => 'HEADER', + p_param_type => 'STRING', + p_access_method => 'IN' +); +``` + +VPD Handler 핵심: + +```sql +FUNCTION vpd_search_json(p_authorization IN VARCHAR2) RETURN CLOB AS + v_bearer_key VARCHAR2(4000); +BEGIN + admin.cb_agent_ctx_pkg.clear_user; + v_bearer_key := extract_bearer_key(p_authorization); + admin.cb_agent_ctx_pkg.set_user_by_bearer(v_bearer_key); + + -- 이후 admin.cb_v_search_documents 조회. + -- VPD는 CB_AGENT_CTX 값을 읽어 허용 행(row)만 반환한다. +END; +``` + +DDS Bearer 검증 핵심: + +```sql +FUNCTION dds_bearer_probe_json(p_authorization IN VARCHAR2) RETURN CLOB AS + v_bearer_key VARCHAR2(4000); + v_mapped_end_user VARCHAR2(128); +BEGIN + v_bearer_key := extract_bearer_key(p_authorization); + v_mapped_end_user := mapped_dds_end_user(v_bearer_key); + + SELECT ORA_END_USER_CONTEXT.username + INTO v_dds_context_username + FROM dual; + + EXECUTE IMMEDIATE + 'SELECT COUNT(*) FROM admin.cb_dds_v_search_documents' + INTO v_rows_visible; +END; +``` + +실행 결과: + +```text +=== ORDS handler probe 1. VPD path with mandatory Bearer header === +{ + "scenario": "VPD_BEARER_HEADER", + "db_user": "CB_ORDS", + "context_user_id": "101", + "context_emp_no": "E10234", + "context_dept_code": "HR", + "context_read_contents": "N", + "rows": [ + {"doc_id":1,"title":"HR payroll guide","dept_code":"HR","contents":null}, + {"doc_id":2,"title":"HR recruiting plan","dept_code":"HR","contents":null}, + {"doc_id":6,"title":"HR benefits notice","dept_code":"HR","contents":null} + ] +} + +=== ORDS handler probe 2. VPD path without Bearer header === +ORA-20101: Authorization header must be Bearer + +=== ORDS handler probe 3. DDS path with Bearer header only === +{ + "scenario": "DDS_BEARER_HANDLER_PROBE", + "db_user": "CB_ORDS", + "bearer_mapped_end_user": "cb_dds_hr", + "dds_context_username": null, + "query_error": "ORA-00942: table or view \"ADMIN\".\"CB_DDS_V_SEARCH_DOCUMENTS\" does not exist", + "result": "EXPECTED_BLOCKED" +} + +=== ORDS handler probe 4. DDS path with all-access Bearer header only === +{ + "scenario": "DDS_BEARER_HANDLER_PROBE", + "db_user": "CB_ORDS", + "bearer_mapped_end_user": "cb_dds_all", + "dds_context_username": null, + "query_error": "ORA-00942: table or view \"ADMIN\".\"CB_DDS_V_SEARCH_DOCUMENTS\" does not exist", + "result": "EXPECTED_BLOCKED" +} +``` + +해석: + +| 항목 | 의미 | +|---|---| +| VPD + Bearer 헤더 | ORDS Handler가 Key를 검증하고 `SYS_CONTEXT`를 채우면 정상 동작 | +| Bearer 헤더 없음 | Mandatory Header로 처리되어 차단 | +| DDS + Bearer 단독 매핑 | Key를 `cb_dds_hr`로 매핑해도 DDS Security Context가 없으므로 차단 | +| DDS 적용 조건 | 지원 드라이버 또는 호출 애플리케이션이 `EndUserSecurityContext`를 DB 호출 전에 Attach해야 함 | + +### 7.4 중앙 권한 인벤토리 + +VPD와 DDS 모두 적용 결과를 Dictionary View로 확인할 수 있다. 이 예제에서는 `sql/adb/24_agent_ords_security_inventory.sql`을 실행해 정책 연결과 DDS Grant Matrix를 확인한다. + +VPD 정책 연결 확인: + +```sql +SELECT object_owner, + object_name, + policy_name, + pf_owner, + function, + sel, + enable +FROM dba_policies +WHERE object_owner = 'ADMIN' +AND object_name LIKE 'CB%' +ORDER BY object_name, policy_name; +``` + +Redaction 정책 확인: + +```sql +SELECT object_owner, + object_name, + policy_name, + enable, + expression +FROM redaction_policies +WHERE object_owner = 'ADMIN' +AND object_name LIKE 'CB%' +ORDER BY object_name, policy_name; +``` + +DDS Grant 요약: + +```sql +SELECT grant_name, + object_name, + grantee, + privilege, + COALESCE( + LISTAGG(column_name, ', ') WITHIN GROUP (ORDER BY column_name), + 'ALL COLUMNS' + ) AS allowed_columns, + COALESCE(MAX(granted_with_all_columns_except), '-') AS excluded_columns, + predicate +FROM dba_data_grants +WHERE owner = 'ADMIN' +AND object_name LIKE 'CB%' +GROUP BY grant_name, object_name, grantee, privilege, predicate +ORDER BY object_name, grant_name; +``` + +DDS End User별 권한 매트릭스: + +```sql +SELECT rg.grantee AS end_user_name, + rg.data_role, + dg.grant_name, + dg.object_name, + dg.predicate, + COALESCE(MAX(dg.granted_with_all_columns_except), '-') AS excluded_columns +FROM dba_data_role_grants rg +LEFT JOIN dba_data_grants dg +ON dg.grantee = rg.data_role +WHERE rg.grantee LIKE 'cb\_dds\_%' ESCAPE '\' +GROUP BY rg.grantee, + rg.data_role, + dg.grant_name, + dg.object_name, + dg.predicate +ORDER BY rg.grantee, dg.object_name, dg.grant_name; +``` + +실행 결과 요약: + +```text +=== Inventory 1. VPD policies attached to protected objects === +OBJECT_OWNER OBJECT_NAME POLICY_NAME FUNCTION +------------ ---------------------- -------------------- ------------------------ +ADMIN CB_V_SEARCH_DOCUMENTS CB_AGENT_DOC_POLICY CB_AGENT_DOC_VPD_FILTER + +=== Inventory 2. Redaction policies attached to protected objects === +OBJECT_OWNER OBJECT_NAME POLICY_NAME ENABLE +------------ ---------------------- -------------------- ------ +ADMIN CB_V_SEARCH_DOCUMENTS CB_CONTENTS_REDACT YES + +=== Inventory 3. DDS grant summary, one row per DATA GRANT === +GRANT_NAME OBJECT_NAME GRANTEE EXCLUDED_COLUMNS PREDICATE +--------------- -------------------------- ---------------- ---------------- --------------- +CB_DG_ALL_DOCS CB_DDS_V_SEARCH_DOCUMENTS CB_DDS_ALL_ROLE - 1 = 1 +CB_DG_FIN_DOCS CB_DDS_V_SEARCH_DOCUMENTS CB_DDS_FIN_ROLE CONTENTS dept_code='FIN' +CB_DG_HR_DOCS CB_DDS_V_SEARCH_DOCUMENTS CB_DDS_HR_ROLE CONTENTS dept_code='HR' + +=== Inventory 4. DDS end user -> data role -> data grant matrix === +END_USER_NAME DATA_ROLE GRANT_NAME PREDICATE +-------------- ------------------------- --------------- --------------- +cb_dds_all CB_DDS_ALL_ROLE CB_DG_ALL_DOCS 1 = 1 +cb_dds_fin CB_DDS_FIN_ROLE CB_DG_FIN_DOCS dept_code='FIN' +cb_dds_hr CB_DDS_HR_ROLE CB_DG_HR_DOCS dept_code='HR' +cb_dds_none CB_DDS_CONNECT_ONLY_ROLE - - +``` + +확인 가능한 것과 별도 관리가 필요한 것: + +| 구분 | 확인 방법 | +|---|---| +| 어떤 객체에 VPD가 붙었는지 | `DBA_POLICIES` | +| 어떤 컬럼이 Redaction 대상인지 | `REDACTION_POLICIES`, `REDACTION_COLUMNS` | +| 어떤 DDS DATA GRANT가 있는지 | `DBA_DATA_GRANTS` | +| 어떤 DDS END USER가 어떤 DATA ROLE을 받았는지 | `DBA_DATA_ROLE_GRANTS` | +| 승인 요청자, 승인 일시, 사유, Ticket 번호 | DDS/VPD Dictionary만으로는 부족. 별도 승인 이력 테이블 또는 Ticket 시스템 필요 | + +
+ +## 8. 결과 화면 및 운영 확인 항목 + +이 장에서는 고객 설명에 사용할 화면 형태의 예시를 정리한다. 사용자 식별 시나리오, 권한 매핑, Authorization 헤더 처리, VPD 결과, DDS 결과, 중앙 권한 인벤토리를 순서대로 확인한다. + +
+상세 설명 - 시나리오, 권한 등록, 실행 결과 확인 + +### 8.1 두 사용자 식별 시나리오 + +![두 가지 사용자 식별 시나리오 화면](assets/agent-ords-security-two-scenarios.svg) + +확인 항목: + +| 항목 | 의미 | +|---|---| +| DB User 기반 | Oracle 접속 계정으로 요청자를 식별 | +| Bearer Key 기반 | ORDS Authorization 헤더의 키로 내부 사용자를 식별 | +| 공통 처리 | 식별 후 DB 보안 정책이 행/컬럼을 제한 | +| 기본 권장 흐름 | ORDS Bearer Key 기반은 VPD + Redaction을 기본으로 적용 | + +### 8.2 권한 등록/매핑 + +![권한 등록 매핑 화면](assets/agent-ords-security-permission-mapping-screen.svg) + +확인 항목: + +| 항목 | 의미 | +|---|---| +| `APP_USER` | key 또는 DB user가 최종적으로 가리키는 내부 사용자 | +| `AGENT_BEARER_KEY` | Bearer Key Hash와 내부 사용자 매핑 | +| `USER_ROLE` | 사용자와 역할 연결 | +| `PERMISSION` | 어떤 보호 객체를 어떤 action으로 볼 수 있는지 | +| `PERMISSION_RULE` | 해당 보호 객체 안에서 어떤 행(row)을 볼 수 있는지 | +| `p_object` 매칭 | VPD가 현재 조회 객체 이름을 `permission.target_name`과 비교 | + +### 8.3 Authorization 헤더 기반 사용자 매핑 + +![Authorization 헤더 기반 사용자 매핑 화면](assets/agent-ords-security-bearer-key-screen.svg) + +확인 항목: + +| 항목 | 의미 | +|---|---| +| ORDS Header `Bearer Key` | 필수값. 없으면 차단 | +| ORDS Handler | Key를 받아 내부 사용자로 매핑 | +| `key_hash` | DB에 저장된 비교값. 원문 Key 저장 금지 | +| 사번/부서코드 | key가 가리키는 내부 사용자 정보 | +| DB 조회 결과 | VPD/DDS 정책 적용 후 허용된 데이터만 반환 | + +### 8.4 권한 미부여 및 차단 결과 + +![권한 미부여 및 차단 결과 화면](assets/agent-ords-security-no-permission-screen.svg) + +확인 항목: + +| 항목 | 의미 | +|---|---| +| 매핑 없음 | 보호 객체 DB 권한은 있어도 VPD 조건이 통과하지 못해 0건 | +| DB 권한 미부여 | VPD 판단 전 객체가 보이지 않아 `ORA-00942` 또는 `ORA-01031` | +| Bearer 헤더 없음 | ORDS Handler 필수값 차단. 예: `ORA-20101` | +| 잘못된 key | key hash 매핑 실패. 예: `ORA-20002` | + +### 8.5 VPD 결과 + +![VPD 결과 화면](assets/agent-ords-security-vpd-screen.svg) + +확인 항목: + +| 항목 | 의미 | +|---|---| +| DB user | Oracle 세션 사용자 | +| Key User | Bearer Key가 매핑한 실제 권한 사용자 | +| 행(row) 제한 | 권한 테이블과 VPD 함수로 적용 | +| 민감 컬럼 값 처리 | Redaction으로 NULL/마스킹 | + +### 8.6 DDS 결과 + +![DDS 결과 화면](assets/agent-ords-security-dds-screen.svg) + +확인 항목: + +| 항목 | 의미 | +|---|---| +| DDS user | `END USER` | +| DATA ROLE | 권한 묶음 | +| DATA GRANT | 보호 객체, 행(row), 컬럼(column) 허용/제외 | +| 권한 미부여 | `ORA-00942`, 객체 자체가 보이지 않음 | + +### 8.7 중앙 권한 인벤토리 + +![중앙 권한 인벤토리 화면](assets/agent-ords-security-audit-screen.svg) + +확인 항목: + +| 항목 | 의미 | +|---|---| +| VPD policy | 어느 보호 객체에 어떤 정책 함수가 붙었는지 | +| Redaction policy | 어떤 컬럼이 NULL 또는 마스킹 대상인지 | +| DDS DATA GRANT | End User/DATA ROLE별 행/컬럼 권한 | +| 누락 확인 | 보호 객체 목록과 Dictionary 조회 결과를 비교 | + +
diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4f685e7 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,63 @@ +# vpd-permission-poc 문서 아키텍처 (Documentation Map) + +이 프로젝트의 문서는 **Diátaxis** 프레임워크 + **ADR** + **설계서(Design Spec)** 를 +결합한 구조를 따른다. 모든 페르소나는 문서를 만들거나 참조할 때 이 지도를 기준으로 한다. + +## 디렉토리 구조 + +``` +docs/ + README.md ← (이 파일) 문서 지도 · 인덱스 + design/ ← 설계서: 구현 "전"에 작성하는 필수 산출물 (Design-First 게이트) + _TEMPLATE.md 기능 설계서 템플릿 + _FN_TEMPLATE.md 함수별 설계서 템플릿 + -/ 기능 1개(이슈 1개)당 폴더 + README.md 기능 설계서 (전체 설계 + 함수 명세 표) + fn-.md 복잡한 함수만 개별 함수 설계서 + adr/ ← Architecture Decision Records: 가로지르는 결정 기록 + _TEMPLATE.md + NNNN-.md + reference/ ← 레퍼런스: 구현된 모듈/함수/설정 사양 (구현 "후" 동기화) + guides/ ← How-to / 사용 가이드 / 튜토리얼 (사용자·운영자 대상) + pipeline/ ← 개발 프로세스 문서 (큐 프로토콜·런북) +``` + +## Diátaxis 사분면 매핑 + +| 사분면 | 목적 | 여기서 위치 | +|--------|------|-------------| +| **Tutorials** (학습) | 처음 사용자가 따라하기 | `guides/` (getting-started) | +| **How-to** (문제해결) | 특정 작업 수행 | `guides/` | +| **Reference** (정보) | 정확한 사양 조회 | `reference/` | +| **Explanation** (이해) | 왜 이렇게 설계했나 | `design/`, `adr/` | + +## 문서 종류와 책임 + +| 문서 | 작성 페르소나 | 시점 | 한 줄 | +|------|---------------|------|-------| +| 기능 설계서 `design/<id>/README.md` | **Architect** | 구현 **전** | 무엇을·어떻게 만들지의 청사진 | +| 함수 설계서 `design/<id>/fn-*.md` | **Architect** | 구현 **전** | 복잡 함수의 계약·알고리즘·테스트 | +| ADR `adr/NNNN-*.md` | **Architect** | 결정 시 | 되돌리기 어려운 선택과 근거 | +| 레퍼런스 `reference/*` | **Developer/Documenter** | 구현 **후** | 실제 코드 사양 | +| 가이드 `guides/*` | **Documenter** | 릴리스 시 | 사용/운영 방법 | + +## 핵심 규칙 — Design-First (하드 게이트) + +> **설계서 없이는 코드 없음.** 어떤 함수든 구현 전에 그 함수가 설계서로 덮여 있어야 한다 +> (단순 함수: 기능 설계서의 함수 명세 표 / 복잡 함수: 개별 `fn-*.md`). +> Developer 는 설계서가 없으면 구현을 거부하고 Architect 단계로 반려한다. +> 자세한 기준은 `CLAUDE.md` §2 참조. + +## 명명 · 추적성 규칙 + +- 설계서 폴더: `design/<issue-id>-<kebab-slug>/` (예: `design/45-trailing-stop/`). +- 함수 설계서: `fn-<function_name>.md` (예: `fn-calc_trailing_stop.md`). +- ADR: 4자리 일련번호 `adr/0001-<title>.md`, 번호 재사용 금지. +- 모든 설계서·ADR 상단에 **추적성 헤더**(Redmine 이슈, 관련 ADR, 구현 파일, 테스트)를 둔다. +- 코드 ↔ 설계서 양방향 링크: 설계서는 구현 파일 경로를, 코드 주석/문서는 설계서 경로를 가리킨다. + +## 문서 수명주기 + +`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지). +구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다. +``` diff --git a/docs/adr/_TEMPLATE.md b/docs/adr/_TEMPLATE.md new file mode 100644 index 0000000..1211b6e --- /dev/null +++ b/docs/adr/_TEMPLATE.md @@ -0,0 +1,24 @@ +<!-- ADR 템플릿. 복사해서 adr/NNNN-<kebab-title>.md (4자리 일련번호). --> + +# ADR-NNNN: <제목> + +> **상태**: Proposed <!-- Proposed | Accepted | Superseded by ADR-XXXX --> +> **날짜**: <YYYY-MM-DD> · **결정자**: [AI] Architect · **관련 이슈**: #<id> + +## 맥락 (Context) +무엇이 이 결정을 강제하는가. 배경·제약·요구. + +## 결정 (Decision) +우리는 무엇을 하기로 했는가. (명확한 한 문단) + +## 근거 (Rationale) +왜 이 선택인가. 핵심 트레이드오프. + +## 결과 (Consequences) +- **긍정**: ... +- **부정 / 비용**: ... +- **후속 작업**: ... + +## 검토한 대안 (Alternatives Considered) +- **<대안 A>** — 기각 사유: ... +- **<대안 B>** — 기각 사유: ... diff --git a/docs/assets/agent-ords-security-audit-screen.svg b/docs/assets/agent-ords-security-audit-screen.svg new file mode 100644 index 0000000..e51904b --- /dev/null +++ b/docs/assets/agent-ords-security-audit-screen.svg @@ -0,0 +1,22 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="620" viewBox="0 0 1120 620" role="img" aria-labelledby="title desc"> + <title id="title">보안 인벤토리 확인 화면 + VPD, Redaction, DDS 권한 적용 현황을 확인하는 화면. + + + + + + ADMIN Inventory - Policy and Data Grant Matrix + === VPD / Redaction 적용 객체 === + OBJECT_NAME POLICY ENABLE + + CB_V_SEARCH_DOCUMENTS CB_AGENT_DOC_POLICY YES + CB_V_SEARCH_DOCUMENTS CB_CONTENTS_REDACT YES + === DDS END USER -> DATA ROLE -> DATA GRANT === + cb_dds_hr CB_DDS_HR_ROLE dept_code='HR' EXCEPT CONTENTS + cb_dds_fin CB_DDS_FIN_ROLE dept_code='FIN' EXCEPT CONTENTS + cb_dds_all CB_DDS_ALL_ROLE 1=1 ALL COLUMNS + cb_dds_none CONNECT_ONLY no DATA GRANT ORA-00942 + 확인 SQL: DBA_POLICIES, REDACTION_POLICIES, DBA_DATA_GRANTS + 결과: 객체별 정책과 End User별 Grant를 한 번에 대조 + diff --git a/docs/assets/agent-ords-security-bearer-key-screen.svg b/docs/assets/agent-ords-security-bearer-key-screen.svg new file mode 100644 index 0000000..6d5b1e9 --- /dev/null +++ b/docs/assets/agent-ords-security-bearer-key-screen.svg @@ -0,0 +1,22 @@ + + Bearer Key ORDS 요청 화면 + ORDS Bearer Key 조회, 사용자 매핑, 필터링된 검색 결과 요약 화면. + + + + + + ORDS - Header Bearer Key 사용자 매핑 + === 수신 요청 === + POST /ords/cb-ords/cb-agent-security/vpd/documents + Authorization: Bearer cb_hr_key + === ORDS 처리 로직: 필수값 확인 === + Authorization Header 필수. 없으면 ORA-20101 또는 401/403 + === ORDS 처리 로직: Key 매핑 === + 1. Key Hash 계산 -> SHA256: 4A91...7C20 + 2. CB_AGENT_BEARER_KEY 조회 -> USER_ID=101, KEY_ID=1 + 3. Context reset + set -> EMP_NO=E10234, DEPT_CODE=HR + === 보호 객체 조회 === + CB_V_SEARCH_DOCUMENTS 조회 -> HR 행 3건, CONTENTS=NULL + 결과: ORDS 처리 로직이 Header Key를 사용자로 매핑 + diff --git a/docs/assets/agent-ords-security-dds-bearer-probe.svg b/docs/assets/agent-ords-security-dds-bearer-probe.svg new file mode 100644 index 0000000..078d609 --- /dev/null +++ b/docs/assets/agent-ords-security-dds-bearer-probe.svg @@ -0,0 +1,72 @@ + + DDS Bearer Key 처리 검증 + ORDS Handler가 Bearer Key를 읽어도 DDS EndUserSecurityContext가 붙지 않으면 DATA GRANT가 Key 사용자를 인식하지 못하는 흐름. + + + + + + + + + DDS Bearer Key 검증 결과 + ORDS Handler의 Header 매핑과 DDS EndUserSecurityContext 전파는 별도 단계 + + + 1. Agent 요청 + Authorization: + Bearer cb_hr_key + + + + + 2. ORDS Handler + Header 필수값 확인 + Key를 내부 사용자로 매핑 + + + + + 3. DB Session + SESSION_USER = CB_ORDS + DDS username = null + + + + + 검증된 차단 결과 + mapped_end_user=cb_dds_hr + dds_context_username=null + ORA-00942 / EXPECTED_BLOCKED + + + Key 사용자명을 변수로 + 보관하는 것만으로는 + DDS 사용자가 되지 않음 + + + DDS로 Bearer 적용 시 필요한 경로 + 지원 드라이버 또는 호출 앱 계층이 + DB 호출 전에 EndUserSecurityContext Attach + + + + + DDS DATA GRANT 적용 + DB가 End-user Identity와 DATA ROLE 인식 + 보호 객체(VIEW/TABLE) 조회 허용 + diff --git a/docs/assets/agent-ords-security-dds-object-model.svg b/docs/assets/agent-ords-security-dds-object-model.svg new file mode 100644 index 0000000..6b4d07f --- /dev/null +++ b/docs/assets/agent-ords-security-dds-object-model.svg @@ -0,0 +1,88 @@ + + DDS 보안 오브젝트 모델 + END USER, APPLICATION IDENTITY, DATA ROLE, DATA GRANT, 보호 객체와 중앙 확인 뷰의 관계. + + + + + + + + + + DDS 보안 오브젝트 모델 + 권한은 업무 매핑 테이블이 아니라 Oracle 보안 오브젝트와 DATA GRANT로 선언 + + 사용자 식별 + + END USER + 스키마를 소유하지 않는 + DDS 보안 사용자 + + + APPLICATION IDENTITY + 애플리케이션 자체 권한이 + 필요할 때 쓰는 확장 + + 역할 묶음 + + DATA ROLE + 데이터 권한 묶음 + CB_DDS_HR_ROLE + + 권한 선언 + + DATA GRANT + 보호 객체에 대한 작업, 행, 컬럼 범위 + + AS SELECT + + WHERE dept_code = 'HR' + + ALL COLUMNS EXCEPT contents + + 보호 대상 + + VIEW / TABLE + ADMIN.CB_DDS_V_SEARCH_DOCUMENTS + DATA GRANT가 있는 범위만 조회 가능 + + + GRANT + + 선택 확장 + + TO + + ON + + + DATA GRANT 없음 + 권한 미부여 END USER에게는 객체 자체가 보이지 않음 + ORA-00942 + + + 중앙 확인 + DBA_DATA_ROLE_GRANTS + DBA_DATA_GRANTS / DBA_DATA_ROLES + + + 요점: DDS는 사용자별 권한을 DATA ROLE과 DATA GRANT로 선언하고, Dictionary View로 적용 상태를 확인한다. + diff --git a/docs/assets/agent-ords-security-dds-screen.svg b/docs/assets/agent-ords-security-dds-screen.svg new file mode 100644 index 0000000..a93d62f --- /dev/null +++ b/docs/assets/agent-ords-security-dds-screen.svg @@ -0,0 +1,22 @@ + + DDS 결과 화면 + DDS 권한이 없을 때 조회 대상이 보이지 않는 흐름을 요약한 화면. + + + + + + sqlplus - DDS DATA GRANT 테스트 + === DDS 사용자 확인 === + END_USER_NAME + "cb_dds_hr" + === DDS 보호 객체 조회 결과 === + OBJECT_NAME ROWS_VISIBLE CONTENTS + CB_DDS_V_SEARCH_DOCUMENTS 3 NULL + cb_dds_none same object ORA-00942: 조회 대상 없음 + === 우회 시도 결과 === + ADMIN.CB_DDS_DOCUMENTS 직접 조회 - ORA-00942 + ADMIN.CB_SEARCH_DOCUMENTS 직접 조회 - ORA-00942 + ORDS Bearer 단독 DDS 검증 - EXPECTED_BLOCKED + 결과: DATA GRANT가 없으면 행 0건이 아니라 객체 자체가 숨겨짐 + diff --git a/docs/assets/agent-ords-security-dds-setup-flow.svg b/docs/assets/agent-ords-security-dds-setup-flow.svg new file mode 100644 index 0000000..63ea57b --- /dev/null +++ b/docs/assets/agent-ords-security-dds-setup-flow.svg @@ -0,0 +1,97 @@ + + DDS 설정과 확인 흐름 + DDS 보호 객체 준비, END USER와 DATA ROLE 생성, DATA GRANT 선언, 중앙 권한 인벤토리 확인 흐름. + + + + + + + + + + DDS 설정과 확인 흐름 + 적용은 DATA GRANT 단위로 선언하고, 확인은 Dictionary View에서 통합 조회 + + + 1. 보호 객체 준비 + CREATE VIEW / TABLE admin.cb_dds_v_search_documents + + + + + 2. 사용자와 역할 생성 + CREATE END USER "cb_dds_hr" + CREATE DATA ROLE cb_dds_hr_role + + + + + 3. DATA GRANT 선언 + CREATE DATA GRANT admin.cb_dg_hr_docs + AS SELECT (ALL COLUMNS EXCEPT contents) + ON admin.cb_dds_v_search_documents + WHERE dept_code = 'HR' TO cb_dds_hr_role + + + + + 4. 조회 시 자동 적용 + 허용 행과 컬럼만 반환. 권한 미부여 시 ORA-00942. + + + 5. 권한 인벤토리 확인 + 적용 상태는 아래 Dictionary View를 조합해 확인 + + + DBA_DATA_GRANTS + 객체, 조건, 컬럼 범위 + + + DBA_DATA_ROLE_GRANTS + END USER와 역할 매핑 + + END_USER + DATA_ROLE + ROW / COLUMN + + + cb_dds_hr + HR_ROLE + HR / EXCEPT contents + + + cb_dds_fin + FIN_ROLE + FIN / EXCEPT contents + + + cb_dds_all + ALL_ROLE + ALL / ALL COLUMNS + + + cb_dds_none + CONNECT_ONLY + - / ORA-00942 + + + 운영 포인트 + 승인 사유와 ticket 번호는 별도 이력 테이블에 보관 + diff --git a/docs/assets/agent-ords-security-main-trunk.svg b/docs/assets/agent-ords-security-main-trunk.svg new file mode 100644 index 0000000..69077a8 --- /dev/null +++ b/docs/assets/agent-ords-security-main-trunk.svg @@ -0,0 +1,85 @@ + + ORDS Agent 데이터 접근 통제 아키텍처 + Agent 요청에서 ORDS 사용자 식별, DB 보안 정책 적용, 허용 결과 반환까지의 아키텍처와 DB User, Bearer Key, VPD, DDS 적용 경로. + + + + + + + + + Agent 권한 매핑 및 데이터 접근 통제 + 사용자 식별 방식은 갈라지지만, 최종 데이터 제한은 보호 객체(VIEW/TABLE)에서 DB가 적용한다. + + + 1. Agent + 검색 요청 + + + + 2. ORDS + 요청 수신 + + + + 3. 사용자 식별 + DB User 또는 Key + + + + 4. 권한 기준 생성 + Context / DDS Identity + + + + 5. 보호 객체 + VIEW/TABLE 조회 + 정책 적용 + + + 식별 경로 A + SESSION_USER + DB 계정으로 사용자 매핑 + + + + 식별 경로 B + Authorization: Bearer + Key로 내부 사용자 매핑 + + + + 식별 경로 C + END USER + DDS 보안 사용자 + + + + VPD 적용 경로 + SYS_CONTEXT + p_object + EXISTS + 권한 테이블 기반 행 제한 + Redaction 값 마스킹 + + + + DDS 적용 경로 + DATA ROLE + DATA GRANT + 행 / 컬럼 / 작업 통제 + 선언형 제한 + + + + 결과: 허용된 행과 컬럼만 반환 + diff --git a/docs/assets/agent-ords-security-no-permission-screen.svg b/docs/assets/agent-ords-security-no-permission-screen.svg new file mode 100644 index 0000000..f3ebea6 --- /dev/null +++ b/docs/assets/agent-ords-security-no-permission-screen.svg @@ -0,0 +1,31 @@ + + 권한 미부여 및 차단 결과 화면 + 권한 미부여 시 VPD 0건, DB 권한 오류, Bearer Key 오류가 어떻게 다른지 보여주는 화면. + + + + + + 권한 미부여 및 차단 결과 비교 + + === Case A. DB SELECT Grant 있음 + VPD Policy 있음 + 매핑 없음 === + SELECT COUNT(*) FROM ADMIN.CB_V_SEARCH_DOCUMENTS; + ROWS_VISIBLE + 0 + 해석: SQL은 실행되지만 VPD EXISTS가 통과하지 못함 + + === Case B. 보호 객체 DB 권한 미부여 === + SELECT COUNT(*) FROM ADMIN.CB_V_SEARCH_DOCUMENTS; + ORA-00942: table or view does not exist + 해석: VPD 판단 전 객체가 보이지 않음 + + === Case C. Bearer Key 누락 또는 형식 오류 === + Authorization Header 없음 + ORA-20101: Authorization header must be Bearer <key> + 해석: ORDS Handler 필수값 차단 + + === Case D. Bearer Key가 틀리거나 만료됨 === + Authorization: Bearer invalid_key + ORA-20002: Invalid or expired Bearer key + 해석: Key Hash 매핑 실패, Context 초기화 + diff --git a/docs/assets/agent-ords-security-permission-mapping-screen.svg b/docs/assets/agent-ords-security-permission-mapping-screen.svg new file mode 100644 index 0000000..a0d8cdf --- /dev/null +++ b/docs/assets/agent-ords-security-permission-mapping-screen.svg @@ -0,0 +1,29 @@ + + 권한 등록 매핑 화면 + Bearer Key 사용자를 Role, Permission, 행 규칙으로 등록하는 운영 화면. + + + + + + ADMIN - Agent 권한 등록 매핑 + + === 1. 내부 사용자와 Bearer Key 등록 === + APP_USER: USER_ID=101, USER_NAME=agent_hr, EMP_NO=E10234, DEPT_CODE=HR, READ_CONTENTS=N + AGENT_BEARER_KEY: KEY_ID=1, USER_ID=101, KEY_HASH=SHA256(cb_hr_key), ACTIVE=Y + + === 2. 사용자 -> 역할 -> 보호 객체 권한 === + USER_ROLE: USER_ID=101 -> ROLE_ID=10 + APP_ROLE: ROLE_ID=10 -> HR_SEARCH_ROLE + PERMISSION: ROLE_ID=10 -> TARGET_NAME=CB_V_SEARCH_DOCUMENTS, ACTION=SELECT + + === 3. 행 / 컬럼 제한 === + PERMISSION_RULE: PERM_ID=100, RULE_TYPE=MY_DEPT, RULE_VALUE=HR + COLUMN RULE: APP_USER.CAN_READ_CONTENTS=N -> DBMS_REDACT가 CONTENTS를 NULL 처리 + + === 4. 조회 시 VPD 매칭 === + p_object=CB_V_SEARCH_DOCUMENTS + permission.target_name=CB_V_SEARCH_DOCUMENTS + rule_type=MY_DEPT + SYS_CONTEXT(DEPT_CODE)=HR -> HR 행만 반환 + 결과: Key User의 role/permission/rule이 있어야 행이 반환됨 + diff --git a/docs/assets/agent-ords-security-sys-context-flow.svg b/docs/assets/agent-ords-security-sys-context-flow.svg new file mode 100644 index 0000000..46781d5 --- /dev/null +++ b/docs/assets/agent-ords-security-sys-context-flow.svg @@ -0,0 +1,60 @@ + + SYS_CONTEXT 동작 방식 + ORDS 처리 로직이 DB 세션에 값을 저장하고 VPD 함수가 SYS_CONTEXT로 값을 읽어 EXISTS 권한 조회에 사용하는 흐름. + + + + + + + + + + SYS_CONTEXT 동작 방식 + 현재 DB 세션에 저장된 요청자 값을 읽어 권한 테이블 EXISTS 조회에 사용 + + + 1. ORDS 처리 로직 + DB 계정 또는 Bearer Key로 + 요청자를 식별 + + + + + 2. DB 세션에 값 저장 + DBMS_SESSION.SET_CONTEXT + AGENT_CTX.EMP_NO = E10234 + AGENT_CTX.DEPT_CODE = HR + + + + + 3. EXISTS 조회에 사용 + SYS_CONTEXT('AGENT_CTX','EMP_NO') + SYS_CONTEXT('AGENT_CTX','DEPT_CODE') + + + 같은 DB 세션 안에서만 유효 + 요청마다 기존 값을 지우고 새 값을 저장 + 다른 요청자 정보가 섞이지 않도록 처리 + + + 직접 조작 방지 + CONTEXT는 지정된 패키지를 통해서만 설정 + 일반 사용자가 임의로 값을 바꾸지 못하게 구성 + + + 요점: SET_CONTEXT는 저장, SYS_CONTEXT는 조회, EXISTS가 권한 판단 + diff --git a/docs/assets/agent-ords-security-two-scenarios.svg b/docs/assets/agent-ords-security-two-scenarios.svg new file mode 100644 index 0000000..5a67aef --- /dev/null +++ b/docs/assets/agent-ords-security-two-scenarios.svg @@ -0,0 +1,105 @@ + + 두 가지 사용자 식별 및 권한 매핑 시나리오 + DB User 기반과 Bearer Key 기반의 사용자 식별 차이와 공통 DB 보안 적용 흐름. + + + + + + + + + + 두 가지 사용자 식별 시나리오 + 사용자 식별 방식은 다르고, 권한 적용은 DB 보안 정책에서 동일하게 수행 + + + 시나리오 1 - DB User 기반 + + + DB 접속 계정 + SESSION_USER + AGENT_HR_001 + + + + + 사용자 매핑 + app_user + user_role + + + + + 식별 + 사번 + 부서 + + + DB 계정별 권한 등록. DBA가 사용자-역할-권한 테이블 관리 + + + 시나리오 2 - Bearer Key 기반 + + + ORDS Header + Bearer Key + 없으면 차단 + + + + + Key 검증 + key_hash + agent_key + + + + + 식별 + 사번 + 부서 + + + ORDS 처리 로직이 Header 값을 받아 내부 사용자로 매핑 + + + + + + 현재 요청 사용자 정보 + USER_ID / 사번 / 부서 저장 + + + 보호 객체 조회 + VIEW 또는 TABLE에 연결된 정책 적용 + + + + + VPD: WHERE 조건 자동 추가 + + + DDS: DATA GRANT WHERE 적용 + + + + + + 결과: 권한 범위 데이터만 반환 + diff --git a/docs/assets/agent-ords-security-vpd-screen.svg b/docs/assets/agent-ords-security-vpd-screen.svg new file mode 100644 index 0000000..f74d3ee --- /dev/null +++ b/docs/assets/agent-ords-security-vpd-screen.svg @@ -0,0 +1,22 @@ + + VPD 결과 화면 + VPD 행 필터링과 우회 시도 차단 결과를 요약한 화면. + + + + + + sqlplus - VPD 보안 정책 테스트 + === DB 세션과 Key User Context === + DB_USER KEY_USER_ID EMP_NO DEPT_CODE READ_CONTENTS + CB_ORDS 101 E10234 HR N + === CB_V_SEARCH_DOCUMENTS 조회 결과 === + KEY ROWS_VISIBLE CONTENTS + cb_hr_key 3 NULL + cb_all_key 6 원문 표시 + === 우회 시도 결과 === + read ADMIN.CB_SEARCH_DOCUMENTS - ORA-00942 + read ADMIN.CB_AGENT_BEARER_KEY - ORA-00942 + Invalid Bearer Key - ORA-20002 + 결과: Agent가 아니라 DB가 최종 필터링 + diff --git a/docs/assets/agent-ords-security-vpd-where-flow.svg b/docs/assets/agent-ords-security-vpd-where-flow.svg new file mode 100644 index 0000000..e3b2a5e --- /dev/null +++ b/docs/assets/agent-ords-security-vpd-where-flow.svg @@ -0,0 +1,73 @@ + + VPD EXISTS 권한 조건 적용 흐름 + ORDS 조회 SQL에 VPD가 p_object 기준 EXISTS 권한 조건을 추가해 VIEW/TABLE 접근과 행 접근을 판단하는 흐름. + + + + + + + + + + VPD: EXISTS로 권한 테이블 확인 + p_object는 현재 조회 대상, SYS_CONTEXT는 요청자 식별값, EXISTS는 실제 권한 판단 + + + 1. ORDS가 실행한 SQL + 권한 조건 없음 + SELECT doc_id, title + FROM app.v_search_documents + WHERE contains_text = :q; + + + + + 2. VPD 정책 함수 + 조회 대상과 요청자 확인 + p_object = 현재 VIEW/TABLE + USER_ID = SYS_CONTEXT(...) + RETURN EXISTS (...) + target_name = p_object + + + + + 3. DB가 합쳐서 적용 + 조회 SQL 뒤에 권한 조건 추가 + WHERE contains_text = :q + AND EXISTS ( + permission.target = p_object + row rule matched) + + + VIEW/TABLE 접근 판단 + target_name = p_object + 없으면 결과 0건 + + + 행 접근 판단 + permission_rule이 행 컬럼과 일치 + 조건에 맞는 행만 반환 + + + 권한 매핑 없음 + EXISTS가 false + 해당 행은 제외 + + + 요점: p_object는 조회 대상, SYS_CONTEXT는 요청자, EXISTS가 권한 판단 + diff --git a/docs/design/546-hermes-vm-deploy/README.md b/docs/design/546-hermes-vm-deploy/README.md index 5cca7b5..c7ad7a4 100644 --- a/docs/design/546-hermes-vm-deploy/README.md +++ b/docs/design/546-hermes-vm-deploy/README.md @@ -1,5 +1,30 @@ # Redmine #546 - 외부 VM 배포 설계 +## 현재 기준 배포 대상 + +이 문서의 최신 운영 기준은 아래와 같다. 과거 `hermes`/`130.162.134.59` 기록은 초기 개발·실험 배포 이력으로만 본다. + +| 구분 | 값 | +| --- | --- | +| 개발·빌드 VM | `hermes` | +| 공개 서비스 배포 VM | `opc@161.33.6.45` (`vnic-aidp-poc`) | +| 공개 주소 | `https://kb.cloud-handson.com` | +| DNS | `kb.cloud-handson.com → 161.33.6.45` | +| VM 서비스 | `vpd-backoffice.service` | +| 앱 수신 주소 | `127.0.0.1:8080` | +| 공개 프록시 | Nginx `80/443 → 127.0.0.1:8080` | +| 인증서 | Let’s Encrypt / Certbot Nginx plugin | +| 앱 디렉터리 | `/home/opc/apps/vpd-backoffice` | +| Wallet 디렉터리 | `/home/opc/apps/vpd-backoffice/wallet` | + +반복 배포 시 완료 판정은 반드시 공개 주소 기준으로 한다. + +```bash +curl -k -sS https://kb.cloud-handson.com/login +``` + +`hermes` 내부의 `8082` 응답만 확인하고 완료 처리하지 않는다. `8082`는 과거/개발 배포 경로에 해당할 수 있다. + ## 프로젝트 개요 VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이블로 제어하는 Spring Boot 관리 도구다. 사용자는 Oracle DB schema user가 아니라 Bearer Token으로 식별되는 application user이며, 사용자/그룹/역할/권한/행 규칙/컬럼 NULL 처리 설정이 VPD policy function과 ORDS 조회 결과에 반영된다. @@ -16,7 +41,8 @@ VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이 ## 배포 방식 -- 기본 대상 SSH host는 `hermes`로 두되, 스크립트 인자로 다른 SSH alias를 받을 수 있게 한다. +- 기본 개발/빌드 host는 `hermes`일 수 있으나, 공개 서비스 배포 대상은 `opc@161.33.6.45`다. +- 배포 스크립트의 기본값이 `hermes`인 경우, 운영 배포에서는 반드시 `--host` 또는 SSH alias가 `161.33.6.45`를 가리키는지 확인한다. - 로컬에서 `mvn -DskipTests package`로 jar를 빌드한다. - 원격 디렉토리 기본값은 `~/apps/vpd-backoffice`다. - 배포 패키지 구성: @@ -39,9 +65,9 @@ VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이 - `scripts/deploy-backoffice-vm.sh`가 jar, `.env`, wallet, start/stop/status 스크립트를 원격에 설치할 수 있다. - 대상 alias가 없으면 명확하게 실패한다. - 배포 후 원격 `status.sh`와 HTTP `/login` 헬스체크가 가능하다. -- OCI NSG와 VM firewalld에서 운영자 IP 기준 `8082/tcp` 접근을 허용한다. +- 현재 공개 운영에서는 Nginx 80/443만 외부에 열고, Spring Boot 애플리케이션 포트는 VM 내부 loopback으로 제한한다. -## 배포 결과 +## 과거 hermes 배포 결과 - 배포 대상: `hermes` / `opc@130.162.134.59` - 원격 경로: `/home/opc/apps/vpd-backoffice` @@ -77,8 +103,8 @@ VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이 - `mvn test` - `scripts/deploy-backoffice-vm.sh --dry-run` -- `ssh hermes` 접속 확인 -- `scripts/deploy-backoffice-vm.sh --host hermes --skip-build` +- 운영 배포 대상 SSH alias가 `opc@161.33.6.45`를 가리키는지 확인 +- 운영 배포 대상의 `systemctl status vpd-backoffice` 확인 - 원격 내부 `/login` HTTP 200 - 외부 `/login` HTTP 200 - `systemctl status vpd-backoffice`가 `active (running)`이고 앱 로그에서 `vpd-backoffice-pool` 연결이 성공한다. diff --git a/docs/design/620-poc4-mcp-discovery-streamable-http/README.md b/docs/design/620-poc4-mcp-discovery-streamable-http/README.md new file mode 100644 index 0000000..bb0ce9d --- /dev/null +++ b/docs/design/620-poc4-mcp-discovery-streamable-http/README.md @@ -0,0 +1,200 @@ +# 설계서: PoC_4 MCP Discovery UI — KB VPD Streamable HTTP 연동 정비 (#620) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-07-09 +> **추적성** — Redmine: #620 · 관련 ADR: 없음 +> · 구현 파일: `apps/poc4/mcp_discovery_ui.py`, `config/mcp_servers.json`, `config/mcp_servers.sample.json`, VPD Backoffice의 `/mcp` endpoint · 테스트: PoC_4 단위 테스트 및 실제 MCP HTTP smoke test + +## 1. 목적 (Why) + +PoC_4의 MCP Discovery UI가 KB VPD MCP를 사용자별 Bearer 토큰으로 안전하게 호출하면서도, 환경별 URL·전송 방식·오류 처리를 하나의 명확한 계약으로 관리한다. + +현재 UI의 기본 호출 흐름은 동작한다. 다만 `kb_mcp` 설정에는 `endpoint_url`과 `base_url_env`가 함께 있고, UI는 `endpoint_url`을 우선 사용한다. 따라서 `KB_MCP_BASE_URL`을 바꿔도 실제 호출 대상이 바뀌지 않는다. 또한 `custom_python`은 로컬 8500 MCP용 이름이므로 외부 KB VPD MCP의 통신 계약을 설명하지 못한다. + +## 2. 범위 (Scope) + +- **포함**: + - `apps/poc4/mcp_discovery_ui.py`의 KB MCP endpoint, 인증 헤더, JSON-RPC, 오류 처리 정비 + - `config/mcp_servers.json` 및 sample의 KB MCP 선언 정비 + - KB MCP의 단일 도구 `ords.query.kb_select_ai_vpd` 호출 계약 문서화 + - VPD Backoffice `/mcp`과의 HTTP 상태·프로토콜 버전 호환성 점검 및 필요한 최소 보완 +- **제외 (out of scope)**: + - 기존 `kb_vector_mcp` 및 `custom_python` 8500 RAG MCP의 변경 + - VPD 권한 규칙, Select AI SQL 생성 규칙, ORDS 비즈니스 로직 변경 + - OAuth Authorization Server, 사용자 로그인/토큰 발급 UI 재설계 + - VPD 토큰 원문을 환경변수·레지스트리에 저장하는 방식 + +## 3. 인수조건 (Acceptance Criteria) + +- [ ] KB MCP URL은 `KB_MCP_BASE_URL` 하나에서만 해석되고 `/mcp` path가 안전하게 결합된다. +- [ ] `initialize`, `notifications/initialized`, `tools/list`, `tools/call`의 모든 HTTP 요청에 현재 선택된 사용자의 `Authorization: Bearer `만 전송된다. +- [ ] Bearer 토큰은 JSON-RPC arguments, SQLite 대화 이력, UI 결과, 애플리케이션 로그에 저장·출력되지 않는다. +- [ ] 호출 가능한 도구는 `ords.query.kb_select_ai_vpd` 하나이며, 인자는 `prompt`와 `limit`만 허용된다. +- [ ] 단일 도구 구성에서는 LLM router를 호출하지 않고 허용 도구를 직접 선택한다. +- [ ] 토큰 없음/위조/만료는 HTTP `401`, 유효 토큰의 권한 부족은 HTTP `403`으로 UI에 구분 표시된다. +- [ ] 잘못된 Origin의 브라우저 요청은 MCP 서버에서 거부되며, 허용 Origin 및 Origin 없는 네이티브 MCP client 정책이 문서화된다. +- [ ] 기존 `kb_vector_mcp` 및 로컬 8500 MCP 회귀 테스트가 통과한다. + +## 4. 컨텍스트 & 제약 + +- KB VPD MCP endpoint: `https://kb.cloud-handson.com/mcp` +- 보호 대상: `ords.query.kb_select_ai_vpd`는 ORDS를 거쳐 VPD 컨텍스트가 적용된 Select AI 조회를 실행한다. +- 토큰 주체: VPD 권한은 정적 서비스 계정이 아니라 현재 선택된 `KB_STAKEHOLDERS` 사용자 토큰에 의해 결정된다. +- UI의 VPD token preset 파일은 데모 편의 기능일 뿐이다. 운영에서는 OS 소유자 전용 권한(`0600`)으로 관리하고 형상관리·로그·SQLite에서 제외한다. +- UI가 현재 사용하는 `2025-11-25` MCP protocol version과 서버의 지원 버전은 handshake에서 협상해야 한다. 지원하지 않는 버전을 무조건 강제하지 않는다. +- 현재 서버는 stateless JSON-RPC POST 호출로도 동작한다. 서버가 `Mcp-Session-Id`를 발급하면 client는 이후 요청에만 그 값을 포함한다. + +## 5. 아키텍처 개요 + +I/O는 Discovery UI의 HTTP transport와 VPD Backoffice `/mcp`에 한정한다. URL 결합, 허용 도구 검증, 요청·응답 검증, 안전한 오류 변환은 순수 함수로 분리해 네트워크 없이 테스트한다. + +``` +VPD 사용자 선택 / 토큰 입력 + │ (원문은 요청 메모리에만 존재) + ▼ +PoC_4 MCP Discovery UI + ├─ KB_MCP_BASE_URL + "/mcp" + ├─ tool allowlist 검증 + └─ Authorization: Bearer + │ + ▼ HTTPS JSON-RPC / Streamable HTTP +VPD Backoffice MCP (/mcp) + ├─ Origin·토큰 검증 + ├─ tools/list: metadata only + └─ tools/call: ords.query.kb_select_ai_vpd + │ + ▼ +ORDS Select AI API → VPD context → Oracle ADB +``` + +## 6. 데이터 모델 + +### 6.1 KB MCP registry 선언 + +`config/mcp_servers.json`의 KB 선언은 다음 계약을 따른다. 값은 예시이며, 토큰 값은 넣지 않는다. + +```json +{ + "id": "kb_mcp", + "enabled": true, + "provider": "kb_vpd_streamable_http", + "transport": "streamable_http", + "base_url_env": "KB_MCP_BASE_URL", + "endpoint_path": "/mcp", + "auth_delivery": "per_request_vpd_bearer", + "timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS", + "default_tool": "ords.query.kb_select_ai_vpd", + "tool_allowlist": ["ords.query.kb_select_ai_vpd"], + "router_mode": "direct", + "description": "KB VPD Select AI MCP; the current user's VPD bearer is sent only in the Authorization header." +} +``` + +환경 변수는 아래 두 값만 필요하다. + +```dotenv +KB_MCP_BASE_URL=https://kb.cloud-handson.com +POC3_MCP_TIMEOUT_SECONDS=90 +``` + +`endpoint_url`, `token_env`, `POC3_MCP_TOKEN`, `BACKOFFICE_MCP_ACCESS_TOKEN`은 KB MCP 선언에 두지 않는다. URL은 registry에 하드코딩하지 않고, VPD 토큰은 사용자별 요청에서만 받는다. + +### 6.2 MCP 요청 + +모든 요청은 다음 헤더를 사용한다. + +```http +Accept: application/json, text/event-stream +Content-Type: application/json +MCP-Protocol-Version: +Authorization: Bearer +``` + +`tools/call` body의 `arguments`는 아래와 같이 제한한다. + +```json +{ + "name": "ords.query.kb_select_ai_vpd", + "arguments": { + "prompt": "담당 고객의 보험료 상세를 보여줘", + "limit": 50 + } +} +``` + +경계 검증 규칙: + +- tool name은 정확히 allowlist 값 하나와 일치해야 한다. +- `prompt`는 문자열이며 서버와 동일한 최대 길이를 적용한다. +- `limit`은 정수 `1..100`으로 clamp한다. +- 토큰은 공백·`Bearer ` prefix를 정규화한 뒤 헤더에만 넣는다. +- redirect는 허용하지 않는다. 다른 origin으로 Authorization이 전달되어서는 안 된다. + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `resolve_kb_mcp_endpoint` | base URL과 고정 path를 안전하게 결합 | `(server, environ) -> str` | registry, env | HTTPS MCP URL | 누락/비정상 URL | 단순 | +| `validate_kb_mcp_server` | KB 선언의 provider·transport·allowlist를 검증 | `(mapping) -> McpServer` | registry row | typed server | 계약 위반 | 단순 | +| `mcp_headers` | 요청별 VPD bearer 헤더 생성 | `(token, version, session_id) -> dict` | 사용자 토큰 | 안전한 headers | 토큰 형식 오류 | 단순 | +| `discover_kb_tools` | initialize 및 tools/list 후 단일 도구를 검증 | `(server, token) -> McpDiscoveryResult` | endpoint, token | tool descriptor | auth/protocol 오류 | **복잡** | +| `call_kb_select_ai` | 고정 도구에 prompt·limit을 전달 | `(server, token, prompt, limit) -> result` | 사용자 요청 | tool result | auth/timeout/JSON-RPC 오류 | **복잡** | +| `to_public_mcp_error` | HTTP/JSON-RPC 오류를 안전한 UI 메시지로 변환 | `(exception) -> PublicMcpError` | 내부 오류 | 사용자 메시지 | 원문 노출 금지 | 단순 | + +## 8. 흐름 / 알고리즘 + +1. UI는 `KB_MCP_BASE_URL`을 읽고 `/mcp`만 결합한다. registry의 임의 `endpoint_url`은 KB 서버에 허용하지 않는다. +2. 사용자가 VPD token preset 또는 일회성 Bearer를 선택한다. 토큰은 현재 실행 변수에만 유지한다. +3. client는 `initialize`를 보내고 서버가 반환한 protocol version과 선택 가능한 session ID를 검증한다. +4. `notifications/initialized`를 보낸 뒤 `tools/list`를 실행한다. +5. 응답 목록이 정확히 허용 도구를 포함하는지, 해당 input schema가 `prompt`, `limit` 계약에 맞는지 확인한다. +6. KB 서버는 단일 도구이므로 router model을 호출하지 않고 `ords.query.kb_select_ai_vpd`를 직접 선택한다. +7. `tools/call`은 prompt·clamp된 limit만 body에 넣고 VPD Bearer는 Authorization에만 넣는다. +8. 결과는 화면용 안전 projection만 SQLite에 저장한다. Authorization 헤더와 원문 token은 저장하지 않으며, 진단이 필요하면 단방향 token fingerprint만 별도 보존할 수 있다. + +## 9. 엣지케이스 & 에러 처리 + +| 상황 | client 처리 | 서버 기대 동작 | +|------|-------------|----------------| +| 토큰 없음 | 호출 전 안내, 네트워크 요청 없음 | 해당 없음 | +| 토큰 위조·만료 | `401` → “토큰이 유효하지 않거나 만료됨” | `WWW-Authenticate` 포함 가능 | +| 유효하지만 권한 없음 | `403` → “이 사용자에게 조회 권한 없음” | VPD fail-closed 유지 | +| allowlist 밖 도구 | 호출 전 차단 | tools/call에서도 차단 | +| 429 | 안전하게 재시도하지 않고 잠시 후 재시도 안내 | rate limit 정책 적용 | +| timeout | tools/call 자동 재시도 금지 | request ID 기반 감사 추적 | +| session ID 미발급 | stateless POST로 진행 | session을 요구하지 않음 | +| session ID 발급 | 이후 요청에 `Mcp-Session-Id` 포함 | 세션 소유·만료 검증 | +| redirect | 즉시 실패 | Authorization 전달 금지 | +| Origin 불일치 | 브라우저 UI에 일반 오류 표시 | `403`으로 거부 | + +## 10. 테스트 계획 + +- registry 단위 테스트 + - `KB_MCP_BASE_URL`만으로 endpoint가 `https://kb.cloud-handson.com/mcp`가 되는지 검증 + - KB registry에 `endpoint_url`, `token_env`, `custom_python`이 있으면 fail-closed 되는지 검증 + - vector MCP 설정은 기존 형식으로 계속 로드되는지 검증 +- HTTP transport 단위 테스트 + - initialize/tools/list/tools/call 모두 Authorization header가 있고 JSON body에는 token key가 없는지 검증 + - `401`, `403`, `429`, timeout, redirect, malformed JSON-RPC 응답을 안전한 메시지로 변환하는지 검증 + - session header 반환/재전송 및 stateless fallback을 검증 +- 통합 smoke test + - 허용된 VPD 사용자 토큰으로 `tools/list`와 `tools/call` 성공 + - 잘못된 토큰은 `401`, 타 사용자 권한은 `403` + - 설계사와 지점장 토큰으로 동일 질문을 실행해 VPD 행/컬럼 결과가 서로 다른지 확인 +- 비밀정보 점검 + - chat SQLite, Streamlit log, 예외 메시지에서 토큰 원문 검색 결과 0건 + +## 11. 리스크 & 대안 검토 + +- **선택**: KB MCP 전용 `kb_vpd_streamable_http` 선언을 도입하고, 로컬 8500용 `custom_python`과 분리한다. 외부 HTTPS/VPD Bearer 계약을 코드와 운영 화면에서 명확히 할 수 있다. +- **대안 1 — 기존 `custom_python` 재사용**: 동작은 시킬 수 있으나 provider 이름과 endpoint 제약이 실제 KB 서버와 맞지 않아 로컬 MCP와 외부 VPD MCP가 섞인다. +- **대안 2 — 고정 MCP access token 사용**: 구현은 간단하지만 모든 사용자가 동일 VPD 주체가 되어 데이터 권한 분리가 무너진다. 채택하지 않는다. +- **대안 3 — 즉시 OAuth 2.1 전환**: 표준 상호운용성에는 유리하지만 현재 데모의 VPD token 발급·검증 체계를 대체하므로 별도 인증 서버 설계가 필요하다. +- 롤백: 새 registry 선언을 비활성화하고 기존 KB 선언을 복원한다. DB VPD 정책·ORDS endpoint·토큰 데이터는 변경하지 않는다. + +## 12. 미해결 질문 (Open Questions) + +- VPD Backoffice MCP endpoint가 현재 지원할 MCP protocol version을 어떤 값으로 공식 고정할지 결정이 필요하다. +- Streamable HTTP의 GET/SSE 및 `Mcp-Session-Id`를 완전 지원할지, stateless POST profile로 운영할지 결정이 필요하다. +- 데모 이후 사용자 VPD bearer를 OAuth 2.1 access token으로 전환할지, 현 토큰을 resource-server token으로 계속 운영할지 결정이 필요하다. +- chat 대화 이력의 `basis_json`/`details_json`에 민감 데이터 보존 기간과 삭제 정책을 별도로 정해야 한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/README.md b/docs/design/621-vpd-aso-persona-page-review/README.md new file mode 100644 index 0000000..fd4b151 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/README.md @@ -0,0 +1,83 @@ +# VPD·ASO 권한 운영 페이지별 페르소나 리뷰 + +> 상태: Review only +> 작성일: 2026-07-13 +> 범위: 현재 Spring Boot 백오피스 화면을 기준으로, 구현 변경 없이 페이지별 개선 의견만 정리 + +## 1. 이번 리뷰의 전제 + +이 백오피스의 핵심 목적은 Oracle DB에서 VPD와 ASO(Data Redaction)를 조합해 사용자별 데이터 접근 범위를 운영하는 것이다. + +- VPD는 행을 남기거나 제외한다. 토큰이 직접 VPD 함수에 전달되는 것이 아니라, 토큰 검증 후 세션 컨텍스트(`CB_AGENT_CTX`)에 들어간 값이 VPD 필터의 조건으로 쓰인다. +- ASO는 컬럼 값을 원문 또는 마스킹 값으로 반환한다. ASO 정책은 세션 컨텍스트의 `MR_` 같은 값을 보고 원문 표시 여부를 판단한다. +- 백오피스에서 등록하는 접근 규칙은 최종 SQL 그 자체가 아니라, VPD 필터가 읽어 SQL predicate로 바꾸는 매핑 데이터다. +- 일반 운영자는 SQL 함수 구조보다 “이 사용자에게 어떤 테이블/행/컬럼이 어떻게 보이는가”를 먼저 이해해야 한다. + +## 2. 리뷰 페르소나 + +| 페르소나 | 관심사 | 실패로 보는 상황 | +|---|---|---| +| 일반 사용자 | 검증 토큰으로 실제 결과를 확인하고 싶다 | VPD, ASO, ORDS, MCP 용어 때문에 무엇을 눌러야 할지 모름 | +| 운영 관리자 | 사용자·역할·권한·마스킹 설정을 안전하게 바꾸고 싶다 | 설정 변경의 영향 범위와 검증 방법이 분리되어 있음 | +| 적용 담당자 | 업무 규칙을 DB 적용 가능한 설정으로 옮기고 싶다 | “본인계약”, “채널내 전체”, “원문 허용”이 실제 필터/컨텍스트와 어떻게 연결되는지 안 보임 | +| DB 관리자 | DB에 어떤 정책과 스크립트가 적용됐는지 확인하고 싶다 | 백오피스 설정과 DBMS_RLS/DBMS_REDACT 실제 상태가 맞는지 증적이 부족함 | + +## 3. 공통 개선 원칙 + +1. 기본 화면은 “현재 상태, 주요 행동, 검증 버튼” 중심으로 둔다. +2. SQL, 패키지, ORDS, VPD 함수, Redaction expression은 Advanced 또는 도움말로 보낸다. +3. 모든 권한 화면은 `업무 표현 → 저장 설정 → VPD/ASO 해석 → 검증 결과` 순서로 설명한다. +4. VPD와 ASO를 섞어 말하지 않는다. + - VPD: 어떤 행을 볼 수 있는가. + - ASO: 허용된 행의 어떤 컬럼을 원문으로 볼 수 있는가. +5. 권한 설정 화면에서는 “이 조건이 그대로 DB에 붙는다”가 아니라 “이 설정값을 필터가 읽어 WHERE 조건을 만든다”라고 표현한다. +6. 간단한 설정을 기본으로 두고, 조건식·정책명·패키지명·스키마명은 Advanced에서 확인하게 한다. + +## 4. 페이지별 리뷰 파일 + +| 영역 | 페이지 | 리뷰 파일 | +|---|---|---| +| 시작 | 로그인 | [00-login.md](pages/00-login.md) | +| 시작 | 대시보드 | [01-dashboard.md](pages/01-dashboard.md) | +| 권한 주체 | 사용자 | [02-users.md](pages/02-users.md) | +| 권한 주체 | 그룹 | [03-groups.md](pages/03-groups.md) | +| 권한 주체 | 역할 | [04-roles.md](pages/04-roles.md) | +| 권한 설정 | 접근 규칙 | [05-permissions.md](pages/05-permissions.md) | +| 권한 설정 | 원문 조회 허용 사용자 | [06-user-masking-rules.md](pages/06-user-masking-rules.md) | +| 권한 설정 | 사용자별 접근 확인 | [07-effective-matrix.md](pages/07-effective-matrix.md) | +| 보호·검증 | 보호 상태 | [08-vpd-policies.md](pages/08-vpd-policies.md) | +| 보호·검증 | 마스킹 규칙 | [09-masking-rules.md](pages/09-masking-rules.md) | +| 보호·검증 | 검증 세션 | [10-tokens.md](pages/10-tokens.md) | +| 보호·검증 | 접근 검증 | [11-probe.md](pages/11-probe.md) | +| 연동 | 조회 대상 | [12-objects.md](pages/12-objects.md) | +| 연동 | 정형 데이터 조회 | [13-structured-data.md](pages/13-structured-data.md) | +| 연동 | 조회 연동 | [14-ords-handlers.md](pages/14-ords-handlers.md) | +| 연동 | 지식 검색 | [15-vector-knowledge.md](pages/15-vector-knowledge.md) | +| 연동 | 대화형 검색 | [16-mcp-chatbot.md](pages/16-mcp-chatbot.md) | +| 연동 | 검색 해석 | [17-mcp-reasoning.md](pages/17-mcp-reasoning.md) | +| 연동 | MCP 서비스 | [18-mcp-sse.md](pages/18-mcp-sse.md) | +| 연동 | 연동 점검 | [19-mcp-client-demo.md](pages/19-mcp-client-demo.md) | +| 운영 | 운영 현황 | [20-operation-status.md](pages/20-operation-status.md) | +| 관리자 | VPD 필터 구조 | [21-vpd-filter-runtime.md](pages/21-vpd-filter-runtime.md) | +| 관리자 | DB 메타데이터 | [22-schema-metadata.md](pages/22-schema-metadata.md) | +| 관리자 | 보안 SQL 스크립트 | [23-security-sql-scripts.md](pages/23-security-sql-scripts.md) | +| 관리자 | 고급 접근 조건 | [24-vpd-filter-policies.md](pages/24-vpd-filter-policies.md) | +| 관리자 | 시스템 설정 | [25-settings.md](pages/25-settings.md) | +| 관리자 | DB 준비 상태 | [26-settings-database.md](pages/26-settings-database.md) | + +## 5. 적용 우선순위 제안 + +| 우선순위 | 대상 | 이유 | +|---|---|---| +| P0 | 접근 규칙, 마스킹 규칙, 원문 조회 허용 사용자, 접근 검증 | 사용자가 VPD/ASO의 차이와 실제 적용 방식을 가장 많이 혼동하는 지점 | +| P0 | 보호 상태, 운영 현황 | DB 실제 적용 상태와 백오피스 설정 상태를 구분해야 장애 판단이 가능 | +| P1 | 사용자, 그룹, 역할, 사용자별 접근 확인 | 권한 주체와 상속 경로를 업무 담당자가 이해하기 쉽게 해야 함 | +| P1 | DB 메타데이터, 보안 SQL 스크립트, VPD 필터 구조 | 적용 담당자와 DB 관리자의 증적 확인 화면 | +| P2 | MCP/Select AI/Vector 연동 화면 | 기능 자체보다 권한이 적용된 호출 흐름과 지연 원인을 보여주는 방향으로 정리 | + +## 6. 구현 전 확인할 설계 판단 + +- ASO는 컬럼 마스킹만 담당하고, 행 접근은 VPD만 담당한다는 원칙을 화면 문구와 메뉴명에 일관되게 반영한다. +- “원문 조회 허용 사용자”는 현재 사용자 단위 UNMASK 예외 중심이다. 향후 “내 담당 고객은 원문, 타인은 마스킹” 같은 조건부 원문 표시가 필요하면 VPD로 행 범위를 먼저 제한하고 ASO 컨텍스트 계산 방식을 확장해야 한다. +- 지점장 집계 요구는 ASO 마스킹 컬럼에 직접 `SUM`을 걸어 해결한다고 가정하면 안 된다. 집계 전용 trusted path나 별도 검증 가능한 API 설계가 필요하다. +- Select AI용 메타데이터 화면은 자연어 질의 품질에 직접 영향을 주므로, 단순 주석 편집이 아니라 “이 컬럼이 어떤 업무 의미인지”를 사람이 이해하고 보강하는 화면으로 다뤄야 한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/00-login.md b/docs/design/621-vpd-aso-persona-page-review/pages/00-login.md new file mode 100644 index 0000000..b0e7b69 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/00-login.md @@ -0,0 +1,28 @@ +# 로그인 페이지 리뷰 + +- URL: `/login` +- 현재 목적: 백오피스 계정으로 VPD 권한 운영 콘솔에 진입한다. +- VPD/ASO 관련성: 로그인 계정은 백오피스 운영 권한이고, 검증 토큰의 업무 사용자와 다르다. + +## 페르소나 의견 + +- 일반 사용자: 로그인한 계정과 나중에 발급하는 VPD 검증 토큰의 사용자가 같은 것인지 헷갈릴 수 있다. +- 운영 관리자: 관리자 계정과 guest 계정의 차이가 첫 화면에서 보이면 안전하다. +- 적용 담당자: “운영 콘솔 로그인”과 “DB 접근 토큰 검증”이 다른 단계임을 알아야 한다. +- DB 관리자: 이 로그인은 DB 계정 로그인이 아니라 애플리케이션 계정이라는 점이 명확해야 한다. + +## 가벼운 개선 + +1. 로그인 카드 하단에 “이 계정은 백오피스 화면 접근용이며, 실제 VPD 검증은 검증 세션 토큰으로 수행합니다.” 문구를 추가한다. +2. guest 계정은 읽기 전용임을 로그인 후 배너뿐 아니라 로그인 화면 안내에도 짧게 표시한다. +3. 로그인 유지 체크박스는 “이 브라우저에서 유지”처럼 보안 범위를 명확히 쓴다. + +## Advanced로 둘 내용 + +- 세션 쿠키 만료 정책 +- 권한별 접근 가능 URL +- guest read-only 서버 정책 + +## 우선순위 + +P2. 혼동을 줄이는 문구 개선이 중심이고, 권한 계산 로직에는 영향이 없다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/01-dashboard.md b/docs/design/621-vpd-aso-persona-page-review/pages/01-dashboard.md new file mode 100644 index 0000000..df029a1 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/01-dashboard.md @@ -0,0 +1,28 @@ +# 대시보드 리뷰 + +- URL: `/` +- 현재 목적: 권한 운영 흐름, 메뉴 안내, 현재 검증 가능한 데이터를 보여준다. +- VPD/ASO 관련성: VPD 행 필터와 ASO 컬럼 마스킹의 전체 업무 흐름을 처음 설명하는 화면이다. + +## 페르소나 의견 + +- 일반 사용자: “권한 관리 → 보호·검증 → 연동” 흐름은 좋지만, VPD와 ASO의 차이는 한 문장으로 먼저 보여야 한다. +- 운영 관리자: 오늘 해야 할 일, 예를 들면 “접근 규칙 수정”, “검증 세션 발급”, “DB 적용 상태 확인”이 바로 보여야 한다. +- 적용 담당자: 업무 규칙이 필터 조건으로 변환되는 구조가 대시보드에서 먼저 잡혀야 한다. +- DB 관리자: 실제 DB 정책 상태와 백오피스 설정 상태가 어디에서 확인되는지 바로 연결되어야 한다. + +## 가벼운 개선 + +1. 상단에 `VPD = 행 제한`, `ASO = 컬럼 마스킹`, `Probe = 실제 결과 검증` 3개 요약 카드를 둔다. +2. “권한 설정값은 VPD 함수가 읽어 WHERE 조건으로 바꿉니다”라는 짧은 설명을 접근 규칙 카드에 붙인다. +3. “현재 DB 적용 상태” 요약을 보호 상태 또는 운영 현황으로 바로 연결한다. + +## Advanced로 둘 내용 + +- VPD 함수 내부 흐름 +- ASO Data Redaction expression +- ORDS/MCP 호출 구조 + +## 우선순위 + +P1. 홈에서 전체 모델을 정확히 잡으면 다른 화면의 설명량을 줄일 수 있다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/02-users.md b/docs/design/621-vpd-aso-persona-page-review/pages/02-users.md new file mode 100644 index 0000000..c81402b --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/02-users.md @@ -0,0 +1,28 @@ +# 사용자 페이지 리뷰 + +- URL: `/users` +- 현재 목적: 백오피스 권한 모델의 사용자 생성, 목록 확인, 사용자 역할 부여를 수행한다. +- VPD/ASO 관련성: 사용자는 VPD 필터가 읽는 역할·권한의 출발점이며, ASO 원문 허용 설정의 대상이 된다. + +## 페르소나 의견 + +- 일반 사용자: 여기의 사용자가 실제 보험 설계사/지점장인지, 백오피스 계정인지 구분하기 어렵다. +- 운영 관리자: 사용자 선택 후 “이 사용자가 최종적으로 어떤 역할과 보호 객체를 갖는지”를 바로 보고 싶다. +- 적용 담당자: `KB_STAKEHOLDERS`와 토큰 주체의 관계가 사용자 화면에서 보이지 않으면 업무 사용자 매핑을 놓칠 수 있다. +- DB 관리자: 사용자 추가가 DB 계정 생성이 아니라 애플리케이션 권한 데이터 추가라는 점이 필요하다. + +## 가벼운 개선 + +1. 사용자 상세에 “직접 역할, 그룹 상속 역할, 원문 허용 컬럼, 발급 가능 토큰” 요약을 추가한다. +2. VPD 데모 사용자라면 `KB_STAKEHOLDERS.USER_ID` 매핑 상태를 배지로 표시한다. +3. 역할 부여 후 바로 “사용자별 접근 확인”과 “접근 검증”으로 이동하는 버튼을 둔다. + +## Advanced로 둘 내용 + +- 내부 테이블명 `CB_APP_USER`, `CB_USER_ROLE` +- 그룹 상속 SQL +- DB 계정과 애플리케이션 사용자의 차이 상세 + +## 우선순위 + +P1. 권한 주체를 이해해야 접근 규칙과 토큰 검증의 혼동이 줄어든다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/03-groups.md b/docs/design/621-vpd-aso-persona-page-review/pages/03-groups.md new file mode 100644 index 0000000..0bc92ea --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/03-groups.md @@ -0,0 +1,27 @@ +# 그룹 페이지 리뷰 + +- URL: `/groups` +- 현재 목적: 그룹 생성, 그룹 사용자 연결, 그룹 역할 연결을 관리한다. +- VPD/ASO 관련성: 그룹 역할은 VPD 필터의 effective role 계산에 포함된다. + +## 페르소나 의견 + +- 일반 사용자: 그룹이 실제 조직 지점인지, 권한 묶음인지 알기 어렵다. +- 운영 관리자: 그룹에 역할을 추가하면 몇 명의 사용자가 영향을 받는지 먼저 봐야 한다. +- 적용 담당자: 지점·채널 같은 업무 조직과 권한 그룹의 차이를 표시해야 오해가 줄어든다. +- DB 관리자: 그룹은 VPD predicate에 직접 들어가는 값이 아니라 역할 상속 경로라는 점이 필요하다. + +## 가벼운 개선 + +1. 그룹 선택 시 영향 사용자 수와 상속 역할 목록을 상단에 고정한다. +2. “그룹은 역할을 묶어 부여하는 운영 단위입니다. 지점/채널 조건은 접근 규칙 또는 stakeholder context에서 평가됩니다.” 문구를 추가한다. +3. 그룹 역할 변경 후 사용자별 접근 확인으로 이동시키는 CTA를 제공한다. + +## Advanced로 둘 내용 + +- `CB_USER_GROUP`, `CB_GROUP_ROLE` 조인 구조 +- active group만 effective role에 포함되는 조건 + +## 우선순위 + +P1. 운영 관리자가 대량 영향 변경을 안전하게 이해하는 데 필요하다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/04-roles.md b/docs/design/621-vpd-aso-persona-page-review/pages/04-roles.md new file mode 100644 index 0000000..fead035 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/04-roles.md @@ -0,0 +1,28 @@ +# 역할 페이지 리뷰 + +- URL: `/roles` +- 현재 목적: 역할 생성과 역할 목록 관리를 수행한다. +- VPD/ASO 관련성: 역할은 접근 규칙과 원문 조회 예외를 연결하는 핵심 단위다. + +## 페르소나 의견 + +- 일반 사용자: 역할 이름만 봐서는 어떤 데이터 범위를 의미하는지 알기 어렵다. +- 운영 관리자: 역할 삭제나 이름 변경 전에 연결된 사용자·그룹·권한 수가 먼저 보여야 한다. +- 적용 담당자: “설계사”, “지점장”, “VPD 관리자” 같은 업무 역할과 DB 정책 적용 결과를 같이 보고 싶다. +- DB 관리자: 역할은 Oracle role이 아니라 백오피스 권한 테이블의 역할이라는 점이 명확해야 한다. + +## 가벼운 개선 + +1. 역할 목록에 연결 사용자 수, 그룹 수, 접근 규칙 수, 원문 허용 규칙 수를 표시한다. +2. 역할 상세에서 “이 역할이 만드는 VPD/ASO 효과”를 업무 문장으로 요약한다. +3. 삭제 버튼은 영향 상세 확인 후 노출한다. + +## Advanced로 둘 내용 + +- 내부 role_id +- 권한 테이블 조인 구조 +- Oracle DB role과의 차이 + +## 우선순위 + +P1. 접근 규칙의 주체가 역할이므로 사용자 영향도를 쉽게 보여줘야 한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/05-permissions.md b/docs/design/621-vpd-aso-persona-page-review/pages/05-permissions.md new file mode 100644 index 0000000..1ca3165 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/05-permissions.md @@ -0,0 +1,36 @@ +# 접근 규칙 페이지 리뷰 + +- URL: `/permissions` +- 현재 목적: 역할별 보호 객체 접근 규칙을 등록하고 관리한다. +- VPD/ASO 관련성: 이 화면의 설정은 VPD 필터가 읽어 대상 테이블의 WHERE predicate로 변환한다. + +## 페르소나 의견 + +- 일반 사용자: `CUST_ID = token stakeholder` 같은 축약 표현은 실제 적용 방식을 이해하기 어렵다. +- 운영 관리자: 저장한 조건이 “최종 SQL”인지 “필터가 읽는 설정값”인지 명확해야 한다. +- 적용 담당자: 업무 조건을 고르는 방식이어야 한다. 예: 본인 계약, 내 채널 계약, 전체 허용, 정적 SQL 조건. +- DB 관리자: STATIC_SQL 같은 고급 조건은 검증·차단 규칙과 함께 보여야 한다. + +## 가벼운 개선 + +1. 기본 입력은 업무 조건 선택형으로 둔다. + - 전체 행 허용 + - 본인 계약 + - 내 채널 계약 + - 담당 고객 + - 내 채널 고객 +2. 각 조건 옆에 “필터 변환 예시”를 접힌 형태로 보여준다. + - 예: 본인 계약 → `FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')` +3. 목록에는 저장된 rule_type보다 업무 문장을 먼저 보여준다. +4. STATIC_SQL은 Advanced 영역으로 이동하고, “검증된 컬럼 조건만 허용”을 명시한다. + +## Advanced로 둘 내용 + +- rule_type, rule_column, rule_value 원본 +- 생성되는 VPD predicate 예시 +- safe_static_predicate 방어 규칙 +- ALLOW/DENY 결합 방식 + +## 우선순위 + +P0. 사용자가 가장 많이 오해하는 화면이다. “설정값을 필터가 WHERE로 바꾼다”는 표현을 반드시 강화해야 한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/06-user-masking-rules.md b/docs/design/621-vpd-aso-persona-page-review/pages/06-user-masking-rules.md new file mode 100644 index 0000000..1afbf90 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/06-user-masking-rules.md @@ -0,0 +1,28 @@ +# 원문 조회 허용 사용자 페이지 리뷰 + +- URL: `/user-masking-rules` +- 현재 목적: ASO 마스킹 대상 컬럼에 대해 특정 사용자에게 원문 조회 예외를 준다. +- VPD/ASO 관련성: VPD로 허용된 행 안에서 특정 컬럼을 원문으로 볼 수 있는지 결정한다. + +## 페르소나 의견 + +- 일반 사용자: “원문 허용”이 행 접근 권한까지 주는 것처럼 보일 수 있다. +- 운영 관리자: 특정 사용자에게 컬럼 원문을 허용하면 어떤 테이블/컬럼에 영향이 있는지 바로 봐야 한다. +- 적용 담당자: “자기 고객이면 원문, 타 고객이면 마스킹” 같은 조건부 요구는 현재 단순 UNMASK 예외와 다르다는 점이 필요하다. +- DB 관리자: 이 설정은 ASO expression이 읽는 세션 context를 만드는 입력값이라는 설명이 필요하다. + +## 가벼운 개선 + +1. 상단 문구를 “행 접근은 VPD가 결정하고, 이 화면은 허용된 행의 컬럼 원문 표시만 결정합니다.”로 고정한다. +2. 사용자 선택 시 “이 사용자가 VPD로 볼 수 있는 행 범위” 링크를 접근 검증으로 연결한다. +3. 단순 원문 허용과 조건부 원문 허용의 차이를 안내한다. + +## Advanced로 둘 내용 + +- `set_masking_rule_context(user_id)` 흐름 +- `MR_` 세션 컨텍스트 +- `DBMS_REDACT.UPDATE_POLICY_EXPRESSION` 적용 방식 + +## 우선순위 + +P0. VPD와 ASO 권한을 섞어 이해하면 운영 사고로 이어질 수 있다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/07-effective-matrix.md b/docs/design/621-vpd-aso-persona-page-review/pages/07-effective-matrix.md new file mode 100644 index 0000000..9ae41f5 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/07-effective-matrix.md @@ -0,0 +1,28 @@ +# 사용자별 접근 확인 페이지 리뷰 + +- URL: `/effective-matrix` +- 현재 목적: 사용자별 최종 역할과 보호 객체 접근 현황을 확인한다. +- VPD/ASO 관련성: VPD가 사용할 effective role과 접근 규칙의 근거를 설명한다. + +## 페르소나 의견 + +- 일반 사용자: 이 화면이 실제 DB 조회 결과인지, 설정상 예상 결과인지 구분이 필요하다. +- 운영 관리자: 사용자 한 명을 선택하면 직접 역할, 그룹 역할, 최종 접근 객체가 한 흐름으로 보여야 한다. +- 적용 담당자: “왜 이 사용자가 이 객체를 볼 수 있는가”의 근거 경로가 필요하다. +- DB 관리자: 실제 DB 정책 적용 여부는 별도 화면이라는 구분이 필요하다. + +## 가벼운 개선 + +1. 상단에 “이 화면은 설정 기반 예상 권한입니다. 실제 조회 결과는 접근 검증에서 확인합니다.”를 표시한다. +2. 사용자별 카드에 `직접 역할 → 그룹 상속 → 최종 역할 → 접근 객체` 타임라인을 둔다. +3. 각 보호 객체에서 `접근 검증`으로 바로 이동하게 한다. + +## Advanced로 둘 내용 + +- effective role SQL +- ALLOW/DENY 결합 로직 +- 그룹 active 여부 반영 방식 + +## 우선순위 + +P1. 운영자가 변경 전후 영향 확인에 사용하는 중심 화면으로 만들 필요가 있다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/08-vpd-policies.md b/docs/design/621-vpd-aso-persona-page-review/pages/08-vpd-policies.md new file mode 100644 index 0000000..84ad590 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/08-vpd-policies.md @@ -0,0 +1,28 @@ +# 보호 상태 페이지 리뷰 + +- URL: `/vpd-policies` +- 현재 목적: 보호 대상 객체와 DB에 적용된 VPD 정책 상태를 확인하고 연결한다. +- VPD/ASO 관련성: VPD 함수와 테이블 연결 상태를 DBMS_RLS 기준으로 확인하는 화면이다. + +## 페르소나 의견 + +- 일반 사용자: “보호 적용”이 행 필터만 의미하는지, 컬럼 마스킹도 포함하는지 혼동할 수 있다. +- 운영 관리자: 백오피스 설정은 있는데 DB 정책이 빠진 상태를 쉽게 알아야 한다. +- 적용 담당자: 보호 객체별로 “설정 있음 / DB 적용됨 / 검증 성공” 3단계를 나눠 보고 싶다. +- DB 관리자: policy_name, function_schema, function_name, enable 상태가 증적으로 필요하다. + +## 가벼운 개선 + +1. 배지를 `설정됨`, `DB VPD 적용`, `최근 검증 성공`으로 분리한다. +2. 컬럼 마스킹은 별도 ASO 상태임을 마스킹 규칙 화면으로 연결한다. +3. 보호 연결 버튼 옆에 “연결 후 접근 검증 필요” 문구를 둔다. + +## Advanced로 둘 내용 + +- `DBMS_RLS.ADD_POLICY`/`DROP_POLICY` +- policy function owner +- object schema와 policy schema + +## 우선순위 + +P0. “설정은 했는데 DB에 적용됐는지”를 판단하는 핵심 운영 화면이다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/09-masking-rules.md b/docs/design/621-vpd-aso-persona-page-review/pages/09-masking-rules.md new file mode 100644 index 0000000..5ec6772 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/09-masking-rules.md @@ -0,0 +1,33 @@ +# 마스킹 규칙 페이지 리뷰 + +- URL: `/masking-rules` +- 현재 목적: 사전 정의 마스킹 방식, 마스킹 대상 컬럼, 컬럼별 기본 규칙, DB ASO 정책 동기화를 관리한다. +- VPD/ASO 관련성: ASO Data Redaction 정책과 컬럼 원문/마스킹 판단의 중심 화면이다. + +## 페르소나 의견 + +- 일반 사용자: ASO가 행 접근을 막는 기능으로 오해될 수 있다. +- 운영 관리자: 컬럼 연결을 해제했을 때 DB Redaction 정책도 같이 해제됐는지 확인해야 한다. +- 적용 담당자: 마스킹 방식과 대상 컬럼 등록, 사용자 원문 허용이 서로 어떤 순서인지 알아야 한다. +- DB 관리자: DBMS_REDACT 정책명, 적용 expression, 활성 여부가 필요하다. + +## 가벼운 개선 + +1. 화면 상단에 3단계 흐름을 둔다. + - 대상 컬럼 등록 + - 기본 마스킹 방식 연결 + - 원문 조회 허용 사용자 지정 +2. 각 컬럼 행에 `백오피스 연결 상태`와 `DB ASO 적용 상태`를 따로 표시한다. +3. “VPD로 허용된 행 안에서만 마스킹 여부가 의미 있습니다.” 문구를 반복 노출한다. +4. 숫자 컬럼 마스킹과 집계의 관계는 도움말로 분리한다. `SUM`이 원문 합계를 보장한다고 표현하면 안 된다. + +## Advanced로 둘 내용 + +- Redaction function type +- policy expression +- `MR_` context +- DBMS_REDACT 오류 코드와 동기화 로그 + +## 우선순위 + +P0. ASO 설정의 실제 DB 적용 여부를 화면에서 바로 판단할 수 있어야 한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/10-tokens.md b/docs/design/621-vpd-aso-persona-page-review/pages/10-tokens.md new file mode 100644 index 0000000..6b38e8a --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/10-tokens.md @@ -0,0 +1,32 @@ +# 검증 세션 페이지 리뷰 + +- URL: `/tokens` +- 현재 목적: 이해관계자 기준 Bearer token을 발급하고 발급 이력을 확인한다. +- VPD/ASO 관련성: 토큰은 세션 context를 만들기 위한 입력이고, VPD/ASO는 그 context를 읽는다. + +## 페르소나 의견 + +- 일반 사용자: 발급된 토큰이 어디에 쓰이는지 한 번 더 안내가 필요하다. +- 운영 관리자: 어떤 사용자/이해관계자/역할로 토큰이 발급됐는지 확인해야 한다. +- 적용 담당자: stakeholder의 role, channel, user_id가 context로 어떻게 들어가는지 알아야 한다. +- DB 관리자: 토큰 원문 보관 여부와 만료 정책이 중요하다. + +## 가벼운 개선 + +1. 토큰 발급 결과에 “이 토큰으로 세팅되는 context” 요약을 표시한다. + - `USER_ID` + - `STAKEHOLDER_USER_ID` + - `STAKEHOLDER_ROLE` + - `STAKEHOLDER_CHANNEL` +2. 발급 직후 접근 검증으로 넘길 때 토큰을 자동 선택한다. +3. 토큰 오류 시 “권한이 없거나 만료된 토큰”처럼 사용자 행동 기준 오류를 표시한다. + +## Advanced로 둘 내용 + +- bearer key 저장 방식 +- context package 내부 함수 +- 만료/폐기 SQL + +## 우선순위 + +P0. 토큰이 권한 자체가 아니라 context 설정 입력이라는 점을 명확히 해야 한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/11-probe.md b/docs/design/621-vpd-aso-persona-page-review/pages/11-probe.md new file mode 100644 index 0000000..702b24a --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/11-probe.md @@ -0,0 +1,29 @@ +# 접근 검증 페이지 리뷰 + +- URL: `/probe` +- 현재 목적: 토큰과 보호 객체를 사용해 실제 ORDS/DB 조회 결과를 확인한다. +- VPD/ASO 관련성: VPD 행 필터와 ASO 컬럼 마스킹이 실제 결과에 어떻게 반영됐는지 확인하는 최종 검증 화면이다. + +## 페르소나 의견 + +- 일반 사용자: “내가 이 사용자라면 실제로 무엇을 볼 수 있나”만 빠르게 보고 싶다. +- 운영 관리자: 설정 변경 후 검증 결과와 이전 결과를 비교하고 싶다. +- 적용 담당자: 결과에 VPD predicate와 ASO 마스킹 여부가 같이 보이면 원인 파악이 쉽다. +- DB 관리자: 재현 SQL, 실행 컨텍스트, 적용 정책 증적이 필요하다. + +## 가벼운 개선 + +1. 결과를 `세션 context`, `VPD 행 결과`, `ASO 컬럼 마스킹`, `원본 응답` 4개 탭으로 나눈다. +2. 잘못된 토큰은 DB 오류처럼 보이지 않게 “토큰 권한 없음/만료/인식 불가”로 반환한다. +3. 마스킹된 컬럼은 결과 테이블에서 별도 아이콘이나 툴팁으로 표시한다. + +## Advanced로 둘 내용 + +- SQL trace +- VPD predicate +- ORDS handler source +- DB cursor 증적 + +## 우선순위 + +P0. 이 화면은 모든 설정 변경의 성공 기준이다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/12-objects.md b/docs/design/621-vpd-aso-persona-page-review/pages/12-objects.md new file mode 100644 index 0000000..bf07581 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/12-objects.md @@ -0,0 +1,28 @@ +# 조회 대상 페이지 리뷰 + +- URL: `/objects` +- 현재 목적: ORDS 조회 대상과 보호 객체 후보를 등록하고 관리한다. +- VPD/ASO 관련성: 보호 객체로 등록된 테이블이 VPD/ASO 정책 적용과 검증의 단위가 된다. + +## 페르소나 의견 + +- 일반 사용자: 조회 대상, 보호 대상, ORDS handler의 차이를 구분하기 어렵다. +- 운영 관리자: 새 테이블을 등록하면 다음에 무엇을 해야 하는지 안내가 필요하다. +- 적용 담당자: 업무명, 테이블명, 키 컬럼, 권한 조건 후보를 같이 관리해야 한다. +- DB 관리자: 스키마와 객체명 검증, 실제 존재 여부, 권한 여부가 필요하다. + +## 가벼운 개선 + +1. 객체 등록 후 다음 행동을 `보호 연결`, `접근 규칙 추가`, `접근 검증`으로 안내한다. +2. 목록에 업무명과 DB 객체명을 같이 표시하되, 업무명을 먼저 보여준다. +3. ASO 컬럼 대상 등록은 마스킹 규칙 화면으로 명확히 연결한다. + +## Advanced로 둘 내용 + +- ORDS module/template/handler 연결 +- schema owner +- 테이블 존재 확인 SQL + +## 우선순위 + +P1. 신규 테이블 온보딩 흐름을 단순하게 만드는 것이 핵심이다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/13-structured-data.md b/docs/design/621-vpd-aso-persona-page-review/pages/13-structured-data.md new file mode 100644 index 0000000..b2e7e15 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/13-structured-data.md @@ -0,0 +1,31 @@ +# 정형 데이터 조회 페이지 리뷰 + +- URL: `/structured-data` +- 현재 목적: KB 원장성 테이블을 관리자용으로 미리 조회한다. +- VPD/ASO 관련성: 실제 사용자별 적용 결과가 아니라 관리자용 원장 미리보기라는 점이 중요하다. + +## 페르소나 의견 + +- 일반 사용자: 여기 결과가 본인 토큰 기준인지 관리자 원본 기준인지 헷갈릴 수 있다. +- 운영 관리자: 데모 데이터 구조를 확인하는 용도로는 좋지만, 권한 검증과 분리되어야 한다. +- 적용 담당자: 각 테이블의 업무 의미, 주요 조인 키, VPD 조건 후보가 같이 보여야 한다. +- DB 관리자: 원장 데이터 조회가 마스킹 정책을 우회하는 관리자 조회인지 표시가 필요하다. + +## 가벼운 개선 + +1. 상단에 “관리자용 데이터 미리보기이며, 사용자별 결과는 접근 검증에서 확인합니다.”를 더 강하게 표시한다. +2. 각 테이블에 권한 기준 컬럼을 표시한다. + - 고객: `CUST_ID` + - 계약: `FC_ID`, `FC_CHANNEL`, `CUST_ID` + - 보상/외부보유: `CUST_ID` +3. 접근 검증으로 바로 이동하는 버튼을 둔다. + +## Advanced로 둘 내용 + +- 전체 컬럼 목록 +- 샘플 SQL +- 테이블 조인 구조 + +## 우선순위 + +P1. 관리자 미리보기와 사용자별 VPD 결과의 차이를 분명히 해야 한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/14-ords-handlers.md b/docs/design/621-vpd-aso-persona-page-review/pages/14-ords-handlers.md new file mode 100644 index 0000000..bfc70f1 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/14-ords-handlers.md @@ -0,0 +1,29 @@ +# 조회 연동 페이지 리뷰 + +- URL: `/ords-handlers` +- 현재 목적: ORDS handler와 source를 확인한다. +- VPD/ASO 관련성: 외부 호출이 어떤 DB 세션에서 context를 세팅하고 조회하는지 확인하는 기술 화면이다. + +## 페르소나 의견 + +- 일반 사용자: ORDS handler source는 기본 화면에서 볼 필요가 거의 없다. +- 운영 관리자: endpoint 상태와 보호 객체 연결 여부가 먼저 필요하다. +- 적용 담당자: handler가 토큰을 받아 context를 세팅한 뒤 조회하는 흐름이 중요하다. +- DB 관리자: handler source와 DB package, 정책 적용 대상이 증적으로 필요하다. + +## 가벼운 개선 + +1. 기본 화면은 endpoint, method, 보호 객체, 최근 검증 상태만 보여준다. +2. source와 수정 기능은 Advanced 상세로 접는다. +3. handler가 VPD/ASO를 우회하지 않는 이유를 짧게 표시한다. + +## Advanced로 둘 내용 + +- ORDS source +- `set_vpd_context` 호출 +- SQL trace +- handler 재배포 절차 + +## 우선순위 + +P1. 운영 화면과 개발자 화면의 밀도를 분리해야 한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/15-vector-knowledge.md b/docs/design/621-vpd-aso-persona-page-review/pages/15-vector-knowledge.md new file mode 100644 index 0000000..a0ef5ad --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/15-vector-knowledge.md @@ -0,0 +1,28 @@ +# 지식 검색 페이지 리뷰 + +- URL: `/vector-knowledge` +- 현재 목적: 지식자료 등록, 접근 정책 설정, 권한 기반 검색을 수행한다. +- VPD/ASO 관련성: 정형 VPD/ASO와 달리 벡터 지식 검색은 문서 단위 접근 정책과 RAG 검색 품질을 다룬다. + +## 페르소나 의견 + +- 일반 사용자: 정형 데이터 권한과 벡터 검색 권한이 같은 방식인지 헷갈릴 수 있다. +- 운영 관리자: 자료 등록, 접근 정책, 검색 검증이 한 화면에 섞이면 운영 순서가 흐려진다. +- 적용 담당자: 상품/회사/약관 메타데이터가 부족하면 RAG 결과가 경쟁사 근거로 치우칠 수 있다. +- DB 관리자: VPD/ASO가 직접 적용되는 테이블 조회와 벡터 검색 정책을 구분해야 한다. + +## 가벼운 개선 + +1. `자료 등록`, `접근 정책`, `검색 검증`을 탭 또는 단계로 분리한다. +2. 검색 결과에 “정형 MCP 결과 없음 / 벡터 근거만 있음” 같은 출처 구분을 표시한다. +3. 상품 필터와 문서 메타데이터 품질 점검 링크를 DB 메타데이터 화면과 연결한다. + +## Advanced로 둘 내용 + +- embedding 상태 +- hybrid rerank 파라미터 +- vector table/source table 구조 + +## 우선순위 + +P2. VPD/ASO 핵심 화면보다 후순위지만, MCP 질의 품질에는 중요하다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/16-mcp-chatbot.md b/docs/design/621-vpd-aso-persona-page-review/pages/16-mcp-chatbot.md new file mode 100644 index 0000000..fb7a090 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/16-mcp-chatbot.md @@ -0,0 +1,29 @@ +# 대화형 검색 페이지 리뷰 + +- URL: `/mcp-chatbot` +- 현재 목적: 자연어 질의로 MCP/Select AI/Vector 검색을 호출한다. +- VPD/ASO 관련성: 자연어 질의라도 정형 DB 호출에는 VPD/ASO context가 적용되어야 한다. + +## 페르소나 의견 + +- 일반 사용자: 질문 입력과 답변이 중심이어야 하고, 도구명은 보조 정보여야 한다. +- 운영 관리자: 어떤 토큰으로 어떤 도구가 호출됐는지 알 수 있어야 한다. +- 적용 담당자: Select AI가 잘못된 SQL을 만들면 테이블/컬럼 comment 보강으로 이어져야 한다. +- DB 관리자: DB 오류, 모델 지연, VPD 차단, ASO 마스킹을 구분해야 한다. + +## 가벼운 개선 + +1. 결과를 `답변`, `호출 도구`, `정형 데이터 결과`, `근거 문서`, `오류 원인`으로 분리한다. +2. Select AI 호출 시간이 길면 모델/profile/재시도 여부를 표시한다. +3. “권한 때문에 안 보임”과 “질의 생성 실패”를 다른 오류로 보여준다. + +## Advanced로 둘 내용 + +- MCP tool JSON +- Select AI showprompt/showsql +- LLM profile +- raw ORDS response + +## 우선순위 + +P2. 데모 품질에는 중요하지만, 먼저 권한 운영 화면을 정리한 뒤 다루는 것이 좋다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/17-mcp-reasoning.md b/docs/design/621-vpd-aso-persona-page-review/pages/17-mcp-reasoning.md new file mode 100644 index 0000000..e528100 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/17-mcp-reasoning.md @@ -0,0 +1,29 @@ +# 검색 해석 페이지 리뷰 + +- URL: `/mcp-reasoning` +- 현재 목적: MCP 도구 선택과 권한 결과 해석을 확인한다. +- VPD/ASO 관련성: 자연어 질의가 어떤 protected tool로 라우팅되고, 어떤 토큰으로 실행됐는지 설명한다. + +## 페르소나 의견 + +- 일반 사용자: “왜 이 도구가 선택됐는가”를 업무 문장으로 알고 싶다. +- 운영 관리자: 실패 시 정형 MCP, 벡터 검색, Select AI 중 어느 구간이 문제인지 봐야 한다. +- 적용 담당자: 라우팅 결과와 스키마 메타데이터 부족을 연결해야 한다. +- DB 관리자: 실제 DB 호출과 VPD/ASO 적용 여부를 증적으로 보고 싶다. + +## 가벼운 개선 + +1. 결과 타임라인을 `질문 → 도구 선택 → 토큰 context → DB/RAG 호출 → 응답` 순서로 표시한다. +2. 각 단계의 시간과 오류 원인을 표시한다. +3. Select AI prompt 또는 generated SQL은 Advanced에서 열람한다. + +## Advanced로 둘 내용 + +- routing payload +- tool allowlist +- generated SQL +- raw trace + +## 우선순위 + +P2. MCP 진단용 화면으로서 기본 사용자보다 적용 담당자 중심으로 정리한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/18-mcp-sse.md b/docs/design/621-vpd-aso-persona-page-review/pages/18-mcp-sse.md new file mode 100644 index 0000000..f68fd5d --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/18-mcp-sse.md @@ -0,0 +1,28 @@ +# MCP 서비스 페이지 리뷰 + +- URL: `/mcp-sse` +- 현재 목적: MCP endpoint와 JSON-RPC 호출 정보를 제공한다. +- VPD/ASO 관련성: MCP 외부 클라이언트가 VPD 적용 Select AI tool을 호출하는 진입점이다. + +## 페르소나 의견 + +- 일반 사용자: 프로토콜 설명보다 “어디에 등록하면 되는가”가 먼저 필요하다. +- 운영 관리자: endpoint, 인증 방식, 허용 tool, 상태를 한눈에 봐야 한다. +- 적용 담당자: Bearer token을 MCP 서버 인증과 업무 사용자 token으로 나누면 혼동된다. 가능하면 업무 토큰 하나로 설명해야 한다. +- DB 관리자: MCP 호출이 ORDS와 DB context 세팅을 거치는지 확인해야 한다. + +## 가벼운 개선 + +1. 상단에 복사 가능한 client 설정 명세를 제공한다. +2. 인증 헤더는 실제 설계 기준으로 하나만 설명한다. 이중 토큰이 필요하면 이유를 명확히 쓴다. +3. 허용 tool이 하나라면 tool allowlist를 단순하게 표시한다. + +## Advanced로 둘 내용 + +- JSON-RPC 예시 +- streaming/http transport 차이 +- timeout/retry 정책 + +## 우선순위 + +P2. 외부 연동 개발자에게 필요한 문서형 화면으로 정리한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/19-mcp-client-demo.md b/docs/design/621-vpd-aso-persona-page-review/pages/19-mcp-client-demo.md new file mode 100644 index 0000000..ab9fd4e --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/19-mcp-client-demo.md @@ -0,0 +1,29 @@ +# 연동 점검 페이지 리뷰 + +- URL: `/mcp-client-demo` +- 현재 목적: Java client로 MCP 호출을 점검한다. +- VPD/ASO 관련성: MCP를 통해 호출한 정형 DB 결과에도 VPD/ASO가 적용되는지 확인한다. + +## 페르소나 의견 + +- 일반 사용자: Java client 호출 정보는 과하다. +- 운영 관리자: 현재 endpoint가 호출 가능한지, 권한 오류인지, timeout인지 알고 싶다. +- 적용 담당자: client 설정과 서버 tool 명세가 일치하는지 확인해야 한다. +- DB 관리자: 호출이 어떤 DB profile과 ORDS endpoint를 쓰는지 추적하고 싶다. + +## 가벼운 개선 + +1. 기본은 `연결 가능`, `도구 호출 가능`, `VPD 적용 결과 수신` 3개 상태로 표시한다. +2. curl 예시와 MCP client 설정 예시를 복사 버튼으로 제공한다. +3. Java stack/detail은 Advanced로 보낸다. + +## Advanced로 둘 내용 + +- Java client raw request/response +- timeout 설정 +- retry 횟수 +- tool schema + +## 우선순위 + +P2. 연동 개발자용 진단 화면이다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/20-operation-status.md b/docs/design/621-vpd-aso-persona-page-review/pages/20-operation-status.md new file mode 100644 index 0000000..fce5bd2 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/20-operation-status.md @@ -0,0 +1,29 @@ +# 운영 현황 페이지 리뷰 + +- URL: `/operation-status` +- 현재 목적: 백오피스와 DB/ORDS/MCP 관련 운영 상태를 확인한다. +- VPD/ASO 관련성: 설정 상태와 DB 실제 적용 상태, 최근 동기화 결과를 구분해야 한다. + +## 페르소나 의견 + +- 일반 사용자: 정상/주의/장애만 먼저 보고 싶다. +- 운영 관리자: 장애가 어느 기능에 영향을 주는지 알아야 한다. +- 적용 담당자: ASO 동기화 실패, VPD 정책 누락, ORDS 오류를 구분해야 한다. +- DB 관리자: DB 조회 기반 상태와 애플리케이션 설정 기반 상태를 분리해서 봐야 한다. + +## 가벼운 개선 + +1. 상단에 전체 상태 배너를 둔다. +2. 상태 항목을 `앱`, `DB 연결`, `VPD 정책`, `ASO 정책`, `ORDS`, `MCP/Select AI`로 나눈다. +3. 각 항목에 최근 확인 시각, 영향 범위, 권장 조치를 표시한다. + +## Advanced로 둘 내용 + +- raw health response +- SQL check query +- systemd/log 위치 +- DB 오류 전문 + +## 우선순위 + +P0. 운영자가 “지금 정상인가”를 판단하는 중심 화면이다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/21-vpd-filter-runtime.md b/docs/design/621-vpd-aso-persona-page-review/pages/21-vpd-filter-runtime.md new file mode 100644 index 0000000..c7e61bc --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/21-vpd-filter-runtime.md @@ -0,0 +1,29 @@ +# VPD 필터 구조 페이지 리뷰 + +- URL: `/vpd-filter-runtime` +- 현재 목적: `CB_AGENT_DOC_VPD_FILTER`의 연결 상태와 배포된 함수 소스를 읽기 전용으로 보여준다. +- VPD/ASO 관련성: VPD 행 필터가 권한 설정을 실제 WHERE predicate로 바꾸는 핵심 구조를 설명한다. + +## 페르소나 의견 + +- 일반 사용자: 함수 소스는 기본적으로 너무 어렵다. +- 운영 관리자: 이 화면은 수정 화면이 아니라 근거 확인 화면이라는 점이 필요하다. +- 적용 담당자: 토큰이 context로 바뀌고, 필터가 context와 권한 테이블을 읽는 흐름을 이해해야 한다. +- DB 관리자: 실제 DB 함수 소스와 Git source가 일치하는지 확인하고 싶다. + +## 가벼운 개선 + +1. 상단 도움말에 “토큰 → context → 권한 테이블 → VPD predicate → 결과 행” 흐름을 유지한다. +2. 함수 소스는 기본 접힘으로 두고, 주요 블록별 설명을 먼저 보여준다. +3. ASO와의 차이 표를 유지하되 “VPD는 컬럼 값을 NULL 처리하지 않는다”를 명시한다. + +## Advanced로 둘 내용 + +- 전체 PL/SQL source +- safe column/static SQL 검증 +- ALLOW/DENY predicate 조립 +- policy binding metadata + +## 우선순위 + +P1. 적용 담당자와 DB 관리자의 신뢰 확보용 화면이다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/22-schema-metadata.md b/docs/design/621-vpd-aso-persona-page-review/pages/22-schema-metadata.md new file mode 100644 index 0000000..8ea01d4 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/22-schema-metadata.md @@ -0,0 +1,29 @@ +# DB 메타데이터 페이지 리뷰 + +- URL: `/schema-metadata` +- 현재 목적: 테이블/컬럼 comment와 annotation을 확인하고 수정한다. +- VPD/ASO 관련성: Select AI가 자연어 질의를 SQL로 바꿀 때 테이블·컬럼 의미를 이해하게 돕는 화면이다. + +## 페르소나 의견 + +- 일반 사용자: comment와 annotation의 차이를 알기 어렵다. +- 운영 관리자: 자연어 질의 실패 원인이 메타데이터 부족일 수 있음을 알아야 한다. +- 적용 담당자: 업무 용어, 조인 키, 권한 기준 컬럼을 명확히 입력해야 한다. +- DB 관리자: 실제 DB comment와 백오피스 annotation 저장소가 어떻게 동기화되는지 봐야 한다. + +## 가벼운 개선 + +1. 각 컬럼에 `업무 의미`, `권한 기준`, `Select AI 힌트`를 나눠 표시한다. +2. `CUST_ID`, `CONTRACT_NO`, `FC_ID`, `FC_CHANNEL` 같은 권한 기준 컬럼은 배지로 강조한다. +3. 자연어 질의 오류 리포트에서 이 화면으로 연결한다. + +## Advanced로 둘 내용 + +- DB comment DDL +- annotation storage schema +- Select AI showprompt +- generated SQL 비교 + +## 우선순위 + +P1. 자연어 기반 Select AI 품질 개선의 핵심 운영 화면이다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/23-security-sql-scripts.md b/docs/design/621-vpd-aso-persona-page-review/pages/23-security-sql-scripts.md new file mode 100644 index 0000000..9487652 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/23-security-sql-scripts.md @@ -0,0 +1,29 @@ +# 보안 SQL 스크립트 페이지 리뷰 + +- URL: `/security-sql-scripts` +- 현재 목적: Git에 저장된 VPD/ASO/ORDS/Select AI 관련 SQL 스크립트를 확인하고 LLM 설명을 생성한다. +- VPD/ASO 관련성: 운영자가 실제 DB 적용 스크립트를 이해하고 검토하는 증적 화면이다. + +## 페르소나 의견 + +- 일반 사용자: 전체 SQL보다 “이 스크립트가 뭘 적용하는가”가 먼저 필요하다. +- 운영 관리자: 실행 대상, 영향 범위, 되돌리기 가능 여부가 중요하다. +- 적용 담당자: 토큰/context/VPD/ASO 흐름을 스크립트 단위로 설명해야 한다. +- DB 관리자: 원본 SQL, 주석, LLM 설명, DB 적용 상태를 나란히 보고 싶다. + +## 가벼운 개선 + +1. 스크립트마다 `목적`, `적용 객체`, `변경되는 DB 정책`, `검증 방법`을 상단 카드로 요약한다. +2. LLM 설명은 “토큰이 어떻게 context가 되고, VPD/ASO가 무엇을 읽는가”를 반드시 포함하게 한다. +3. 원본 SQL은 그대로 보여주되, 비전문가 설명은 먼저 제공한다. + +## Advanced로 둘 내용 + +- 전체 SQL source +- script diff +- DB 배포 이력 +- LLM prompt 전문 + +## 우선순위 + +P1. 복잡한 스크립트를 이해하기 위한 설명 화면으로 방향이 맞다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/24-vpd-filter-policies.md b/docs/design/621-vpd-aso-persona-page-review/pages/24-vpd-filter-policies.md new file mode 100644 index 0000000..97c6645 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/24-vpd-filter-policies.md @@ -0,0 +1,29 @@ +# 고급 접근 조건 페이지 리뷰 + +- URL: `/vpd-filter-policies` +- 현재 목적: 기본 접근 규칙으로 처리하기 어려운 고급 필터 정책을 관리한다. +- VPD/ASO 관련성: VPD predicate 생성에 영향을 주는 예외적 고급 조건이다. + +## 페르소나 의견 + +- 일반 사용자: 이 화면은 기본 사용자가 접근할 이유가 없다. +- 운영 관리자: 대부분의 변경은 접근 규칙에서 끝난다는 안내가 현재 방향과 맞다. +- 적용 담당자: 고급 조건을 쓰기 전 업무 조건으로 해결 가능한지 판단해야 한다. +- DB 관리자: 임의 SQL 조건은 injection 방어와 검증 결과가 필요하다. + +## 가벼운 개선 + +1. 기본 화면은 “접근 규칙으로 처리 가능한가?” 체크리스트부터 보여준다. +2. 고급 조건 작성은 Advanced로 접고, 저장 전 검증 결과를 필수로 표시한다. +3. 실제 predicate 예시와 실패 시 fail-closed 동작을 설명한다. + +## Advanced로 둘 내용 + +- raw predicate +- validation rules +- 대상 컬럼 whitelist +- DBMS_ASSERT 처리 + +## 우선순위 + +P1. 잘못 쓰면 접근 범위를 넓힐 수 있으므로 일반 설정과 분리해야 한다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/25-settings.md b/docs/design/621-vpd-aso-persona-page-review/pages/25-settings.md new file mode 100644 index 0000000..0f259a8 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/25-settings.md @@ -0,0 +1,32 @@ +# 시스템 설정 페이지 리뷰 + +- URL: `/settings` +- 현재 목적: ORDS Base URL 등 시스템 연결 설정을 관리한다. +- VPD/ASO 관련성: 권한 검증과 MCP/ORDS 호출의 기반 연결 정보다. + +## 페르소나 의견 + +- 일반 사용자: 일반 사용자가 자주 볼 화면은 아니다. +- 운영 관리자: 연결 URL 변경이 어떤 기능에 영향을 주는지 알아야 한다. +- 적용 담당자: DB 준비 상태와 ORDS 연결 설정을 구분해야 한다. +- DB 관리자: 운영 설정 변경 이력과 검증 결과가 필요하다. + +## 가벼운 개선 + +1. 변경 가능한 설정과 읽기 전용 환경 설정을 분리한다. +2. 설정 변경 후 영향을 받는 기능을 표시한다. + - 접근 검증 + - MCP 호출 + - ORDS handler 조회 +3. 저장 후 자동 health check를 실행하고 결과를 보여준다. + +## Advanced로 둘 내용 + +- 환경 변수명 +- systemd env 파일 +- connection timeout +- proxy/TLS 설정 + +## 우선순위 + +P2. 기능 안정성에는 중요하지만 일반 권한 흐름보다는 후순위다. diff --git a/docs/design/621-vpd-aso-persona-page-review/pages/26-settings-database.md b/docs/design/621-vpd-aso-persona-page-review/pages/26-settings-database.md new file mode 100644 index 0000000..5f46116 --- /dev/null +++ b/docs/design/621-vpd-aso-persona-page-review/pages/26-settings-database.md @@ -0,0 +1,29 @@ +# DB 준비 상태 페이지 리뷰 + +- URL: `/settings/database` +- 현재 목적: 지원 테이블과 VPD runtime 준비 상태를 확인하고 초기 구성 SQL을 실행한다. +- VPD/ASO 관련성: 권한 운영에 필요한 메타 테이블, context package, policy function, ASO metadata의 준비 상태를 다룬다. + +## 페르소나 의견 + +- 일반 사용자: 거의 볼 필요가 없는 관리자 화면이다. +- 운영 관리자: 운영 중 재실행하면 위험한 작업과 안전한 확인 작업을 구분해야 한다. +- 적용 담당자: 어떤 단계가 누락되면 어떤 화면이 실패하는지 알고 싶다. +- DB 관리자: 실행 전 SQL, 실행 권한, 생성 객체, 재실행 안전성이 필요하다. + +## 가벼운 개선 + +1. 상태 확인과 변경 실행을 완전히 분리한다. +2. 변경 실행 전에는 생성/수정/건너뜀/위험 항목을 요약한다. +3. VPD와 ASO 준비 항목을 별도 섹션으로 표시한다. + +## Advanced로 둘 내용 + +- 전체 DDL +- package/function source +- DB 권한 grant +- 재실행 idempotency 설명 + +## 우선순위 + +P1. DB 준비 화면은 강력한 변경 화면이므로 안전 장치와 설명이 필요하다. diff --git a/docs/design/_FN_TEMPLATE.md b/docs/design/_FN_TEMPLATE.md new file mode 100644 index 0000000..fc2171b --- /dev/null +++ b/docs/design/_FN_TEMPLATE.md @@ -0,0 +1,51 @@ + + +# 함수 설계서: `` (#) + +> **부모 설계서**: ./README.md · **상태**: Draft +> **작성**: [AI] Architect · **구현**: · **테스트**: <경로 또는 TBD> + +## 1. 시그니처 +``` + () # 언어 확정 후 정확히 기재 +``` + +## 2. 책임 (단일 책임, 1줄) +이 함수가 하는 단 하나의 일. + +## 3. 입력 +| 파라미터 | 타입 | 제약/검증 | 설명 | +|----------|------|-----------|------| +| `

` | | | | + +## 4. 출력 +- **반환**: 타입 / 의미. +- **부수효과**: (있으면 — I/O·상태변경 명시) / 없으면 **순수 함수**. + +## 5. 동작 / 알고리즘 +1. ... +2. ... + +## 6. 에러 & 실패 모드 +| 조건 | 처리 | 반환/예외 | +|------|------|-----------| +| | | | + +## 7. 엣지케이스 +- 경계값(0, 음수, 빈값, 최대), 동시성, 부분 실패. + +## 8. 복잡도 / 성능 +- 시간/공간 복잡도. 호출 빈도(예: 시세 폴링 루프 내부인가?). + +## 9. 의존성 +- 호출하는 함수/모듈, 외부 API, 설정 키. + +## 10. 테스트 케이스 +- [ ] 정상: <입력 → 기대 출력> +- [ ] 경계: ... +- [ ] 실패: ... + +## 11. 추적성 +- 인수조건: # 의 "<항목>". +- 관련 ADR: . diff --git a/docs/design/_TEMPLATE.md b/docs/design/_TEMPLATE.md new file mode 100644 index 0000000..9601549 --- /dev/null +++ b/docs/design/_TEMPLATE.md @@ -0,0 +1,66 @@ + + +# 설계서: <기능명> (#) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: +> **추적성** — Redmine: # · 관련 ADR: +> · 구현 파일: <경로 또는 TBD> · 테스트: <경로 또는 TBD> + +## 1. 목적 (Why) +이 기능이 푸는 문제. Planner 의 목표 1줄 인용. + +## 2. 범위 (Scope) +- **포함**: ... +- **제외 (out of scope)**: ... + +## 3. 인수조건 (Acceptance Criteria) + +- [ ] ... +- [ ] ... + +## 4. 컨텍스트 & 제약 +- 의존성: 거래소 API / DB / 알림 / 외부 라이브러리. +- 제약: 성능, 레이트리밋, 리스크(돈), 보안. +- 가정: ... + +## 5. 아키텍처 개요 +- 모듈/파일 구조 (목록). +- 데이터 흐름 (텍스트 다이어그램). +- **I/O ↔ 순수 전략 로직 경계** 명시 (테스트 가능성). + +``` +<여기에 ASCII 흐름도> +``` + +## 6. 데이터 모델 +- 입력 / 출력 / 저장 구조, 타입, **경계 검증 규칙**. + +## 7. 함수 명세 (Function Specs) + + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `` | | | | | | 단순 / **복잡** | + +> 복잡 기준: 분기/상태기계, 외부 I/O, 리스크(주문·잔고) 경로, 비자명 알고리즘. +> → 해당 함수는 `fn-.md` 작성. 단순(게터·포매터 등)은 이 표로 충분. + +## 8. 흐름 / 알고리즘 +- 핵심 시나리오 단계별. 상태 전이. + +## 9. 엣지케이스 & 에러 처리 +- 경계값, 실패 모드, 재시도/백오프. +- **안전한 기본값**(API 실패 시 거래 중단 등). + +## 10. 테스트 계획 +- 단위/통합 케이스 목록 (각 인수조건에 매핑). +- 모킹/드라이런 전략 (거래소 API 등). + +## 11. 리스크 & 대안 검토 +- 선택한 접근 vs 대안, 트레이드오프. +- 되돌리기 어려운 결정 → **ADR 로 분리** (`adr/NNNN-*.md`). + +## 12. 미해결 질문 (Open Questions) +- ... diff --git a/docs/ords_vpd_dds.zip b/docs/ords_vpd_dds.zip new file mode 100644 index 0000000..cc75eba Binary files /dev/null and b/docs/ords_vpd_dds.zip differ diff --git a/docs/pipeline/QUEUE-PROTOCOL.md b/docs/pipeline/QUEUE-PROTOCOL.md new file mode 100644 index 0000000..ef3e4b3 --- /dev/null +++ b/docs/pipeline/QUEUE-PROTOCOL.md @@ -0,0 +1,42 @@ +# Queue Protocol — 모든 페르소나 공통 규약 + +작업 큐 = Redmine 이슈. 각 페르소나는 자기 단계 이슈를 처리하고 git/Redmine 에 남긴 뒤 다음으로 넘긴다. + +## 0. 환경 로드 +```bash +set -a; . ./.env; set +a +RK="$REDMINE_API_KEY"; RB="$REDMINE_URL"; PROJ="$REDMINE_PROJECT" +# 카테고리 id 는 이름으로 조회(프로젝트마다 id 다름): +catid(){ curl -s -H "X-Redmine-API-Key: $RK" "$RB/projects/$PROJ/issue_categories.json" \ + | python3 -c "import sys,json;[print(c['id']) for c in json.load(sys.stdin)['issue_categories'] if c['name']=='$1']"; } +``` + +## 1. 큐 매핑 +- 현재 단계 = 카테고리 `01-Planner`…`08-Documenter`,`09-Done`. +- 수명주기 = 상태 신규(대기)/진행/완료/거절. + +## 2. 내 작업 꺼내기 +```bash +DEV=$(catid 03-Developer) +curl -s -H "X-Redmine-API-Key: $RK" "$RB/issues.json?project_id=$PROJ&category_id=$DEV&status_id=1&sort=id:asc&limit=1" +# 시작 시 상태 진행(2): +curl -s -H "X-Redmine-API-Key: $RK" -H "Content-Type: application/json" -X PUT "$RB/issues/.json" -d '{"issue":{"status_id":2}}' +``` + +## 3~4. 결과 남기기 (필수 3가지) +- (a) git 커밋+push (`[] # ...`) +- (b) Redmine 저널 노트(역할 태그) +- (c) 다음 단계 전진: 카테고리=다음이름의 id, 상태 신규(1) +```bash +NEXT=$(catid 04-QA) +curl -s -H "X-Redmine-API-Key: $RK" -H "Content-Type: application/json" -X PUT "$RB/issues/.json" \ + -d "{\"issue\":{\"category_id\":$NEXT,\"status_id\":1,\"notes\":\"[] ...\"}}" +``` + +## 5. 게이트 반려 +- QA(04)/Reviewer(06) 실패 → `03-Developer`. Developer 설계서 누락 → `02-Architect`. 사유를 노트에. + +## 6. 종료 (Documenter) +- `09-Done` + 상태 완료(5) + done_ratio 100. + +원칙: 자기 역할 범위만, 모든 변경 git 추적, 비밀(.env) 노출 금지. diff --git a/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md b/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md new file mode 100644 index 0000000..f44bbe7 --- /dev/null +++ b/docs/reports/2026-07-10-existing-01-mcp-rag-diagnosis.md @@ -0,0 +1,108 @@ +# 기존-01 MCP/RAG 진단 리포트 + +## 대상 시나리오 + +- 질문 번호: 기존-01 +- 분류: 권한 + RAG 질문형 +- 이해관계자: 설계사 `FC00789` +- 질의: `C1001006 고객 자동차보험 갱신 상담 전에, 현재 KB 계약(41048)과 삼성화재 보유 자동차보험 약관을 비교해서 고객에게 설명할 차별 포인트를 정리해줘.` + +## 정형 MCP 확인 결과 + +정형 MCP 경로는 복구 확인됐다. + +- MCP endpoint: `https://kb.cloud-handson.com/mcp` +- tool: `ords.query.kb_select_ai_vpd` +- Select AI profile: `KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2` +- 검증 결과: + - `HTTP_STATUS=200` + - `MCP_HAS_ERROR=FALSE` + - `MCP_IS_ERROR=FALSE` + - `ITEM_COUNT=1` + +생성 SQL은 `41048`을 `CONTRACT_NO`가 아니라 `PRODUCT_CD`로 해석했다. + +```text +KC.CUST_ID = 'C1001006' +KC.PRODUCT_CD = '41048' +EH.EXT_INSURER = '삼성화재' +EH.EXT_PRODUCT_GRP = '자동차' +``` + +대표 반환값: + +```text +CUSTOMER_ID=C1001006 +KB_CONTRACT_NO=CT2699001 +KB_PRODUCT_CODE=41048 +KB_PRODUCT_NAME=41048_KB개인용자동차보험 +KB_PREMIUM=1350000 +EXTERNAL_INSURER=삼성화재 +EXTERNAL_PRODUCT_GROUP=자동차 +EXTERNAL_PRODUCT_TYPE=개인용 +EXTERNAL_CLAUSE_NAME=개인용애니카다이렉트자동차보험 +``` + +## 적용한 정형 MCP 보완 + +- `POC_2` KB 업무 테이블/컬럼 comment를 보강했다. +- Select AI `showprompt` 확인 결과, comment와 annotation이 실제 모델 프롬프트에 포함됨을 확인했다. +- SQL 정규화/검증 로직을 보완했다. + - 마크다운 fence와 선행 wrapper comment를 제거한다. + - 문자열 리터럴 내부의 세미콜론/금지어를 실행 문법으로 오판하지 않도록 검사한다. + - DDL/DML/PLSQL/system object 차단은 유지한다. + +변경 스크립트: + +- `sql/adb/65_kb_select_ai_vpd_query_api.sql` + +## RAG 근거 검색 확인 결과 + +RAG corpus는 `POC_2` 스키마에 존재한다. + +```text +POC_2.KB_OWN_TERMS_DOCUMENTS +POC_2.KB_OWN_TERMS_CHUNKS +POC_2.KB_COMPETITOR_TERMS_DOCUMENTS +POC_2.KB_COMPETITOR_TERMS_CHUNKS +POC_2.KB_TERMS_CHUNKS_ALL_V +``` + +건수: + +```text +TOTAL_CHUNKS=38772 +OWN_CHUNKS=28506 +COMP_CHUNKS=10266 +``` + +KB 41048 약관 근거는 존재한다. + +```text +DOC_41048|KB손해보험|OWN||KB개인용자동차보험|b5b680222361c4cde7a54855925333a3 +SAMPLE_41048|KB손해보험|OWN||KB개인용자동차보험|...|자동차26-41048-1-04 KB개인용자동차보험 ... +``` + +## RAG 측 원인 판단 + +KB 41048 약관 chunk는 있지만 `PRODUCT_CODE` 메타데이터가 비어 있다. + +```text +company_name=KB손해보험 +company_type=OWN +product_code= +product_name=KB개인용자동차보험 +chunk_text contains 자동차26-41048-1-04 +``` + +따라서 RAG 검색/필터가 `PRODUCT_CODE = '41048'` 또는 상품코드 기반 필터를 사용하면 KB 근거가 제외될 수 있다. 본문 텍스트에는 `41048`이 있으므로 순수 텍스트 검색으로는 찾을 수 있지만, 메타데이터 필터 기준 검색에서는 빠질 가능성이 높다. + +## 권고 + +RAG/약관 적재 담당 영역에서 아래 중 하나를 처리해야 한다. + +1. `KB_OWN_TERMS_DOCUMENTS` / `KB_OWN_TERMS_CHUNKS` 적재 시 `자동차26-41048-1-04`에서 업무 상품코드 `41048`을 추출해 `PRODUCT_CODE`에 저장한다. +2. 기존 적재분에 대해 `KB개인용자동차보험` 또는 `자동차26-41048-1-04` 문서를 대상으로 `PRODUCT_CODE='41048'` backfill을 수행한다. +3. 검색 필터가 상품코드만 보지 말고 `PRODUCT_NAME`, `CHUNK_TEXT`의 약관 승인번호 패턴도 fallback으로 보도록 보강한다. + +VPD 관리 인스턴스에서는 이 영역을 직접 수정하지 않고, 정형 데이터/Select AI 쪽 table comment와 annotation 관리 기능으로 메타데이터 품질을 운영 가능하게 한다. diff --git a/docs/reports/2026-07-10-select-ai-profile-performance-report.md b/docs/reports/2026-07-10-select-ai-profile-performance-report.md new file mode 100644 index 0000000..ee3beb3 --- /dev/null +++ b/docs/reports/2026-07-10-select-ai-profile-performance-report.md @@ -0,0 +1,319 @@ +# Select AI 프로파일 전환 및 호출 시간 리포트 + +## 대상 + +- 날짜: 2026-07-10 +- 대상 서비스: VPD 관리 인스턴스 MCP / ORDS Select AI 조회 +- MCP endpoint: `https://kb.cloud-handson.com/mcp` +- MCP tool: `ords.query.kb_select_ai_vpd` +- ORDS endpoint: `/ords/cb-ords/kb-select-ai-vpd/query` +- 테스트 질의: + +```text +C1001006 고객 자동차보험 갱신 상담 전에, 현재 KB 계약(41048)과 삼성화재 보유 자동차보험 약관을 비교해서 고객에게 설명할 차별 포인트를 정리해줘. +``` + +## 결론 + +기존 `openai.gpt-5.5` 기반 Select AI 프로파일은 SQL 생성 시간이 길고, 같은 질의에서도 SQL 생성 실패/거절 응답이 간헐적으로 발생했다. + +최종 적용 프로파일은 아래로 변경했다. + +```text +KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1 +``` + +이 프로파일은 `openai.gpt-5.4-mini`를 사용하고, `comments=true`를 켜서 테이블/컬럼 comment 기반 SQL 생성을 유지한다. 최종 ORDS 직접 호출은 약 4.1초, MCP 호출은 약 4.6~8.2초 범위로 확인됐다. + +## 기존 호출 시간 + +기존 운영 프로파일: + +```text +KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2 +model=openai.gpt-5.5 +provider=oci +region=us-chicago-1 +provider_endpoint=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com +comments=true +annotations=true +constraints=true +conversation=true +enforce_object_list=true +``` + +관찰된 기존 병목: + +| 측정 구간 | 시간 | 결과 | +|---|---:|---| +| VPD context 설정 | 282ms | 정상 | +| 확정 SQL + VPD 실행 | 50ms | 정상 | +| `showprompt` metadata/prompt 생성 | 43.817초 | prompt 18,806자 | +| `showsql` 1차 | 38.770초 | SQL 생성 성공 | +| `showsql` 2차 | 105.505초 | non-SQL refusal | +| SQLcl direct package 호출 | 61.123초 | SQL 생성 및 2건 반환 | +| MCP 경로 호출 | 54.3초 | `ORA-20813`, Select AI가 유효 SELECT 생성 실패 | +| GPT-5.5 재비교 호출 | 72초 | SQL 대신 refusal 메시지 반환 | + +판단: + +- DB/VPD/ORDS 자체가 느린 것이 아니다. +- 주 병목은 `DBMS_CLOUD_AI.GENERATE` 내부의 Select AI prompt 구성과 LLM SQL 생성 단계다. +- `comments`, `annotations`, `constraints`, `conversation`, `enforce_object_list`가 모두 켜진 GPT-5.5 프로파일은 prompt가 커지고 응답 편차가 컸다. + +## 후보 프로파일 테스트 결과 + +### 1. 단순 모델 교체 후보 + +| 프로파일 | 모델 | 설정 | 결과 | +|---|---|---|---| +| `KB_AIDP_SELECTAI_GPT5_MINI_PROFILE_V1` | `openai.gpt-5-mini` | 기존 GPT-5.5와 유사 | `ORA-20404 ... /actions/chat` | +| `KB_AIDP_SELECTAI_GPT41_MINI_PROFILE_V1` | `openai.gpt-4.1-mini` | 기존 GPT-5.5와 유사 | `ORA-20404 ... /actions/chat` | +| `KB_AIDP_SELECTAI_GROK43_PROFILE_V1` | `xai.grok-4.3` | 기존 GPT-5.5와 유사 | `ORA-20404 ... /actions/chat` | +| `KB_AIDP_SELECTAI_GPT54_MINI_PROFILE_V1` | `openai.gpt-5.4-mini` | 기존 GPT-5.5와 유사 | `ORA-20404 ... /actions/chat` | + +오류: + +```text +ORA-20404: Object not found - oci://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/chat +``` + +단순히 모델명만 바꾸는 방식은 안정적이지 않았다. + +### 2. 기존 성공 프로파일 방식 확인 + +기존에 성공한 프로파일: + +```text +POC_SELECT_AI_ALL +model=xai.grok-4.3 +``` + +확인된 차이: + +- `oci_compartment_id`가 있음 +- `comments`, `annotations`, `constraints`, `conversation`, `enforce_object_list` 플래그가 없음 + +비교 결과: + +| 프로파일 | 시간 | 결과 | +|---|---:|---| +| `KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2` | 72초 | refusal 메시지 반환 | +| `POC_SELECT_AI_ALL` | 16초 | SQL 생성 성공 | + +### 3. FAST 프로파일 테스트 + +`POC_SELECT_AI_ALL` 구조를 기준으로 `oci_compartment_id`를 포함하고, 무거운 메타데이터 플래그를 제거한 FAST 후보를 만들었다. + +| 프로파일 | 모델 | showprompt | prompt 크기 | showsql | 결과 | +|---|---|---:|---:|---:|---| +| `KB_AIDP_SELECTAI_GROK43_FAST_PROFILE_V1` | `xai.grok-4.3` | 3초 | 5,049자 | 7초 | 성공 | +| `KB_AIDP_SELECTAI_GPT54_MINI_FAST_PROFILE_V1` | `openai.gpt-5.4-mini` | 0초 | 5,049자 | 4초 | 성공 | + +FAST 방식은 빠르지만, 테이블/컬럼 comment 활용이 약해진다. 따라서 최종 적용용으로는 `comments=true`만 추가한 절충형을 별도 테스트했다. + +### 4. Full metadata 프로파일 테스트 + +`comments=true`에 더해 `annotations=true`, `constraints=true`까지 켠 후보를 추가 테스트했다. + +프로파일: + +```text +KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1 +model=openai.gpt-5.4-mini +comments=true +annotations=true +constraints=true +enforce_object_list=true +``` + +DBMS_CLOUD_AI 단독 측정: + +| 회차 | showprompt | prompt 크기 | showsql | 결과 | +|---:|---:|---:|---:|---| +| 1 | 32초 | 19,816자 | 15초 | 성공 | +| 2 | 3초 | 19,816자 | 6초 | 성공 | +| 3 | 4초 | 19,816자 | 13초 | 성공 | + +ORDS 실제 경로 측정: + +| 회차 | HTTP | 총 시간 | 오류 | 반환 건수 | +|---:|---:|---:|---|---:| +| 1 | 200 | 8.224초 | 없음 | 2 | +| 2 | 200 | 7.288초 | 없음 | 2 | +| 3 | 200 | 6.217초 | 없음 | 2 | + +평균: + +```text +7.243초 +``` + +비교: + +| 구성 | ORDS 평균 | 상대 | +|---|---:|---:| +| `comments=true` only | 4.156초 | 1.0x | +| `comments + annotations + constraints` | 7.243초 | 약 1.7x 느림 | + +판단: + +- Full metadata 구성은 동작한다. +- 단, prompt 크기가 `12,520자`에서 `19,816자`로 증가한다. +- ORDS 기준 평균 응답 시간이 `4.156초`에서 `7.243초`로 늘어난다. +- 데모 응답성 기준으로는 `comments=true` only 구성이 더 적합하다. + +## 최종 적용 프로파일 + +최종 적용: + +```text +KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1 +model=openai.gpt-5.4-mini +comments=true +enforce_object_list=true +oci_compartment_id= +``` + +테스트 결과: + +| 구간 | 시간 | 결과 | +|---|---:|---| +| `showprompt` | 6초 | 성공 | +| prompt 크기 | 12,520자 | comment 포함 | +| `showsql` | 4초 | 성공 | + +선택 이유: + +- GPT-5.5 대비 훨씬 빠르다. +- FAST 프로파일보다 prompt가 크지만, 테이블/컬럼 comment를 유지한다. +- VPD 관리 인스턴스에서 보강한 table/column comment 운영 효과가 Select AI에 반영된다. + +## 적용 내용 + +### DB 패키지 + +`POC_2.KB_SELECT_AI_VPD_QUERY_API`의 Select AI profile 상수를 변경했다. + +```sql +c_profile_name CONSTANT VARCHAR2(128) := 'KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1'; +``` + +대상 스크립트: + +```text +sql/adb/65_kb_select_ai_vpd_query_api.sql +``` + +### ORDS 응답 표시 + +ORDS JSON 응답의 profile 표시도 새 프로파일로 변경했다. + +```text +profile=KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1 +``` + +대상 스크립트: + +```text +sql/adb/66_kb_select_ai_vpd_query_ords.sql +``` + +### MCP 응답 표시 + +MCP tool 응답 payload의 profile 표시와 화면 설명을 새 프로파일 기준으로 변경했다. + +대상 소스: + +```text +src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +src/main/resources/templates/mcp-sse.html +``` + +## 최종 속도 측정 + +### ORDS 직접 호출 + +호출 대상: + +```text +https://g329127dfd380ad-kbaipoc.adb.ap-osaka-1.oraclecloudapps.com/ords/cb-ords/kb-select-ai-vpd/query +``` + +| 회차 | HTTP | 총 시간 | 응답 profile | 오류 | 반환 건수 | +|---:|---:|---:|---|---|---:| +| 1 | 200 | 4.058초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 2 | +| 2 | 200 | 4.216초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 2 | +| 3 | 200 | 4.195초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 1 | + +평균: + +```text +4.156초 +``` + +### MCP 호출 + +호출 대상: + +```text +https://kb.cloud-handson.com/mcp +``` + +| 회차 | HTTP | 총 시간 | MCP payload profile | ORDS profile | 오류 | 반환 건수 | +|---:|---:|---:|---|---|---|---:| +| 1 | 200 | 4.632초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 2 | +| 2 | 200 | 8.213초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 2 | +| 3 | 200 | 7.279초 | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | `KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1` | 없음 | 1 | + +평균: + +```text +6.708초 +``` + +참고: + +- MCP `time_starttransfer`는 약 0.008~0.012초였지만, 최종 응답 body 완료 기준은 `time_total`이다. +- MCP 경로는 백오피스 HTTP 처리와 ORDS 호출 wrapping이 추가되므로 ORDS 직접 호출보다 약간 느릴 수 있다. + +## 개선 효과 + +| 비교 항목 | 기존 GPT-5.5 | 최종 GPT-5.4-mini comments | +|---|---:|---:| +| SQLcl direct package | 61.123초 | 4초대 SQL 생성 | +| 기존 MCP 실패 사례 | 54.3초 후 실패 | 4.6~8.2초 성공 | +| GPT-5.5 재비교 | 72초 후 refusal | 4.1초대 ORDS 성공 | +| prompt 크기 | 18,806~21,776자 | 12,520자 | + +정리: + +- 최종 ORDS 기준으로 기존 61~72초 구간 대비 약 10~17배 빠르다. +- MCP 기준으로도 기존 54.3초 실패 사례 대비 성공 응답이 약 4.6~8.2초로 줄었다. +- DB/VPD 실행 병목이 아니라 Select AI 프로파일/모델/메타데이터 구성이 핵심 병목이었다. + +## 남은 주의점 + +1. 같은 자연어라도 Select AI 생성 SQL은 완전히 결정적이지 않다. + - 테스트에서도 반환 건수가 1~2건으로 흔들렸다. + - 이는 LLM SQL 생성 특성이다. +2. 비즈니스 품질을 더 안정화하려면 table/column comment와 annotation을 계속 보강해야 한다. +3. 특정 데모 질문은 deterministic SQL fallback 후보로 둘 수 있다. + - 예: `CUST_ID`, `PRODUCT_CD`, `EXT_INSURER`, `EXT_PRODUCT_GRP`가 명확한 비교 질문. +4. 장기적으로는 profile을 설정값으로 분리하는 것이 좋다. + - 현재는 DB package 상수로 고정되어 있다. + - 운영 전환 시 `CB_BACKOFFICE_SETTING` 또는 별도 Select AI 설정 테이블에서 profile을 읽도록 개선할 수 있다. + +## 현재 권고 + +데모/PoC 운영 기준으로는 현재 적용한 아래 프로파일을 유지한다. + +```text +KB_AIDP_SELECTAI_GPT54_MINI_COMMENTS_PROFILE_V1 +``` + +이유: + +- 응답 시간이 데모 가능한 수준이다. +- comment 기반 스키마 설명을 유지한다. +- ORDS/MCP 양쪽 모두 실제 호출 성공을 확인했다. diff --git a/docs/reports/2026-07-13-vpd-aso-holistic-ui-review.md b/docs/reports/2026-07-13-vpd-aso-holistic-ui-review.md new file mode 100644 index 0000000..6cc4273 --- /dev/null +++ b/docs/reports/2026-07-13-vpd-aso-holistic-ui-review.md @@ -0,0 +1,213 @@ +# 데이터 접근 제어 백오피스 전체 UX/기능 리뷰 + +작성일: 2026-07-13 +범위: 현재 Spring Boot 백오피스 화면, VPD/ASO/ORDS/MCP/Select AI 운영 흐름 +상태: Review only + +## 1. 결론 + +단순 문구 정리만으로는 충분하지 않다. 지금 화면은 기능은 많이 들어와 있지만, 사용자가 “업무 규칙을 넣으면 DB에서 어떻게 행/컬럼으로 적용되는가”를 끝까지 따라가기 어렵다. + +가장 큰 개선 축은 세 가지다. + +1. 전체 흐름을 작업 단위로 묶어야 한다. + - 사용자/역할 생성 + - 행 접근 규칙 등록 + - 보호 정책 연결 + - 컬럼 마스킹 설정 + - 토큰 발급 + - 실제 접근 검증 + +2. 화면별 기본/고급을 분리해야 한다. + - 기본 화면: 업무 의미, 현재 상태, 다음 버튼, 검증 결과 + - 고급 화면: VPD predicate, PL/SQL source, ORDS handler, raw JSON, SQL trace + +3. 모든 설정 화면에서 “설정값 → DB 적용 → 실제 검증”이 이어져야 한다. + - 지금은 각 화면이 기능 단위로는 존재하지만, 다음 단계 연결과 검증 루프가 약하다. + +## 2. 페르소나별 핵심 불만 + +| 페르소나 | 현재 불만 | 필요한 개선 | +|---|---|---| +| 일반 사용자 | VPD/ASO/ORDS/MCP가 섞여 무엇을 눌러야 할지 모른다 | “이 사용자는 무엇을 볼 수 있나?” 중심의 단순 경로 | +| 운영 관리자 | 변경 전후 영향과 실제 적용 여부를 한 번에 보기 어렵다 | 영향 사용자, 대상 객체, 검증 버튼, 최근 결과 | +| 적용 담당자 | 업무 조건이 WHERE predicate로 바뀌는 연결이 화면마다 끊긴다 | 업무 조건 → 저장 rule → VPD/ASO 해석 → 검증 결과 | +| DB 관리자 | 백오피스 설정과 DB 실제 정책이 일치하는지 증적이 부족하다 | DBMS_RLS/DBMS_REDACT/FGA/ORDS 상태를 한곳에서 확인 | +| 보안 담당자 | guest/read-only, 토큰 처리, 원문 표시 예외의 경계가 더 명확해야 한다 | 변경 가능 여부, 원문 표시 범위, 감사 증적 | +| 데모/영업 사용자 | KB 보험 시나리오가 화면 흐름으로 자연스럽게 보이지 않는다 | 설계사/지점장/관리자 시나리오 preset과 결과 비교 | + +## 3. 우선순위 개선안 + +### P0. 반드시 해야 할 개선 + +#### 3.1 대시보드를 “전체 그림 + 오늘 할 일” 중심으로 재구성 + +현재 대시보드는 구조 설명은 좋아졌지만, 사용자가 다음 행동을 결정하기에는 아직 기능 나열에 가깝다. + +개선: + +- 상단에 3개 핵심 카드: + - 행 접근: “누가 어떤 행을 보는가” + - 컬럼 마스킹: “허용된 행의 어떤 컬럼을 원문/마스킹으로 보는가” + - 접근 검증: “토큰으로 실제 DB 결과를 확인한다” +- “처음 설정” 체크리스트: + 1. 사용자/역할 준비 + 2. 행 접근 규칙 등록 + 3. 보호 상태 적용 + 4. 컬럼 마스킹 연결 + 5. 토큰 발급 + 6. 접근 검증 +- DB 상태 요약: + - VPD 정책 누락 수 + - ASO 정책 불일치 수 + - ORDS handler 누락 수 + - 최근 검증 실패 수 + +#### 3.2 접근 검증 결과 화면을 탭/단계형으로 재설계 + +접근 검증은 이 백오피스의 최종 판단 화면이다. 현재도 결과는 나오지만, “왜 그렇게 나왔는지”를 단계별로 보기에는 부족하다. + +개선: + +- 결과를 4개 섹션으로 분리: + 1. 토큰 해석 결과: 사용자, stakeholder, role, channel + 2. 행 접근 결과: 반환 행 수, 적용된 VPD predicate, ALLOW/DENY 근거 + 3. 컬럼 마스킹 결과: 마스킹 대상 컬럼, 원문 허용 여부, ASO policy 상태 + 4. DB 감사 증적: FGA SQL, RLS_INFO, request id +- 잘못된 토큰은 DB 오류처럼 보이지 않게: + - 토큰 없음 + - 등록되지 않은 토큰 + - 만료/회수 토큰 + - 권한 없음 + 을 분리한다. +- 결과 테이블에서 마스킹된 컬럼에 아이콘/툴팁 표시. + +#### 3.3 행 접근 규칙 화면에 “업무 조건 → predicate 변환”을 더 강하게 표시 + +현재도 wizard와 preview가 있으나, 적용 담당자가 원하는 것은 “내가 고른 업무 조건이 실제 어떤 WHERE 조각이 되는가”다. + +개선: + +- 조건 선택 옆에 즉시 preview: + - 본인 담당 계약 → `FC_ID = SYS_CONTEXT(...STAKEHOLDER_USER_ID...)` + - 채널 고객 → `EXISTS (...) FC_CHANNEL = SYS_CONTEXT(...STAKEHOLDER_CHANNEL...)` + - 정적 SQL 조건 → 검증된 현재 객체 컬럼 조건만 허용 +- 목록 기본값은 raw rule보다 업무 문장 우선. +- 상세에는 저장 rule, 변환 predicate, 결합 방식(AND/OR/DENY)을 같이 표시. +- 삭제/변경 시 영향 사용자 수와 최근 검증 결과 링크 표시. + +#### 3.4 컬럼 마스킹 화면에서 설정 단계와 DB 적용 상태를 더 선명하게 분리 + +지금 기능은 맞지만 사용자에게는 “대상 컬럼 추가”, “규칙 연결”, “사용자 원문 허용”, “DB 정책 동기화”가 섞여 보일 수 있다. + +개선: + +- 단계형 표시: + 1. 마스킹 후보 컬럼 등록 + 2. 기본 마스킹 규칙 연결 + 3. 원문 표시 허용 사용자 지정 + 4. DB ASO 정책 동기화 상태 확인 +- 컬럼별 “현재 실제 동작” preview: + - 일반 사용자: 마스킹 + - 원문 허용 사용자: 원문 + - 행 접근 권한 없는 사용자: 행 없음 +- DBMS_REDACT 정책 상태와 백오피스 설정 차이를 컬럼 단위로 표시. + +#### 3.5 운영 현황을 통합 health dashboard로 강화 + +현재 운영 현황은 표가 있지만, 장애 상황에서 “무엇이 문제인지”를 바로 알려주는 구조가 약하다. + +개선: + +- 상단 전체 상태: + - 정상 / 주의 / 장애 +- 영역별 health: + - App 로그인/세션 + - DB 연결 + - VPD 정책 + - ASO 정책 + - ORDS handler + - MCP/Select AI endpoint +- 각 상태에: + - 마지막 확인 시각 + - 영향받는 기능 + - 다음 조치 + - 관련 화면 이동 링크 + +## 4. 화면별 추가 리뷰 + +| 화면 | 현재 상태 | 남은 개선 | +|---|---|---| +| 로그인 | 제품명 정리됨 | 백오피스 계정과 업무 사용자 토큰이 다르다는 안내, guest/read-only 안내 보강 | +| 대시보드 | 전체 구조 설명 있음 | 실제 작업 체크리스트와 상태 요약 부족 | +| 사용자 | application user 설명 있음 | `KB_STAKEHOLDERS` 매핑 상태, 직접 역할/그룹 역할/토큰 발급 링크 부족 | +| 그룹 | 영향 사용자/역할 일부 표시 | 그룹이 지점/채널 조건이 아니라 역할 상속 단위라는 설명 보강 | +| 역할 | 삭제 영향 표시 있음 | 역할별 접근 규칙 수, 원문 허용 컬럼 수, 영향 사용자 요약 보강 | +| 행 접근 규칙 | wizard와 preview 있음 | 업무 조건별 predicate preview, 삭제 영향/검증 링크 보강 | +| 컬럼 원문 표시 허용 | VPD/ASO 경계 설명 있음 | 조건부 원문 허용이 아니라 사용자 단위 UNMASK라는 한계 명시 필요 | +| 사용자별 접근 확인 | 설정 기반 권한 확인 가능 | 실제 DB 결과가 아니라 예상 권한이라는 구분과 접근 검증 CTA 강화 | +| 보호 상태 | VPD 적용 상태 확인 가능 | `설정됨 / DB 적용됨 / 최근 검증 성공` 3단계 배지 필요 | +| 컬럼 마스킹 | ASO 동기화 기능 있음 | 컬럼별 실제 사용자 결과 preview와 정책 불일치 원인 설명 필요 | +| 검증 세션 | 토큰 발급/이력 있음 | “토큰은 권한을 담지 않고 사용자 식별만 한다”를 더 강하게 표시 | +| 접근 검증 | 핵심 검증 가능 | 결과를 토큰/context/행/컬럼/감사 증적으로 분리 필요 | +| 조회 대상 | ORDS 대상 등록 가능 | 신규 대상 등록 후 다음 단계 안내 부족 | +| 정형 데이터 조회 | 관리자 preview 가능 | 사용자별 접근 결과가 아님을 더 강하게 표시, 권한 기준 컬럼 배지 필요 | +| 조회 연동 | ORDS source 확인 가능 | 기본은 endpoint 상태, source는 Advanced로 더 숨기는 편이 좋음 | +| 지식 검색 | 등록/검색/정책이 한 화면 | 자료 등록, 접근 정책, 검색 검증을 탭으로 분리 필요 | +| 대화형 검색 | 자연어 질의 가능 | 결과를 답변/정형 결과/근거 문서/오류 원인으로 분리 필요 | +| 검색 해석 | 라우팅 결과 확인 가능 | 단계별 타임라인과 소요시간, showprompt/showsql Advanced 필요 | +| MCP 서비스 | endpoint/tool 설명 있음 | 복사 가능한 client 설정 명세와 curl 예시를 상단에 제공 | +| 연동 점검 | client 호출 가능 | 연결 가능/도구 호출 가능/권한 적용 결과 3단계 health 필요 | +| 운영 현황 | 정책/ORDS/ASO 상태 표 있음 | 통합 health, 최근 확인 시각, 영향 범위, 조치 링크 필요 | +| 행 접근 필터 구조 | 기술 흐름 있음 | 함수 소스보다 블록별 설명과 Git/DB source 차이 표시가 우선 | +| DB 메타데이터 | comment/annotation 수정 가능 | 권한 기준 컬럼 배지, Select AI 실패 리포트와 연결 필요 | +| 보안 SQL 스크립트 | 원문/LLM 설명 가능 | 스크립트별 목적/대상/변경 정책/검증 방법 summary card 필요 | +| 고급 접근 조건 | Advanced 성격 있음 | 기본 접근 규칙으로 해결 가능한지 체크리스트 선행 필요 | +| 시스템 설정 | ORDS Base URL 관리 | 저장 후 자동 health check와 영향 기능 표시 필요 | +| DB 준비 상태 | preflight와 DDL 있음 | 확인 작업과 변경 작업을 더 강하게 분리, 실행 전 영향 요약 필요 | + +## 5. 설계상 더 명확히 해야 할 원칙 + +### 5.1 토큰은 권한 묶음이 아니다 + +토큰은 사용자를 식별하고 context를 세팅하는 열쇠다. 실제 권한은 요청 시점에 사용자/그룹/역할/행 접근 규칙/마스킹 규칙을 조회해서 계산된다. + +화면 전반에 이 문장을 반복해야 한다. + +### 5.2 VPD와 ASO의 결합 방식 + +- VPD는 행을 줄인다. +- ASO는 남은 행의 컬럼 표시 방식을 바꾼다. +- 원문 표시 허용은 행 접근 권한을 늘리지 않는다. +- 행 접근 권한이 없으면 ASO 원문 허용도 의미가 없다. + +### 5.3 “집계만 허용”은 별도 설계가 필요하다 + +ASO로 마스킹된 컬럼에 대해 자연스럽게 집계가 된다고 가정하면 안 된다. 지점장에게 상세는 마스킹하고 집계만 허용하려면 trusted API, aggregate 전용 path, 또는 별도 검증 가능한 query boundary가 필요하다. + +### 5.4 Select AI 품질은 메타데이터 운영 문제다 + +자연어 질의 실패를 프롬프트로만 해결하면 재현성이 떨어진다. 테이블/컬럼 comment, annotation, constraint, 업무명, 조인 키를 운영자가 보강하는 흐름이 있어야 한다. + +## 6. 추천 구현 순서 + +### 1차: 운영 사고를 줄이는 P0 + +1. 접근 검증 결과 화면 재구성 +2. 운영 현황 health dashboard 강화 +3. 행 접근 규칙 predicate preview/영향도 강화 +4. 컬럼 마스킹 단계형 구성과 사용자별 preview + +### 2차: 온보딩과 이해도 개선 + +1. 대시보드 체크리스트와 상태 요약 +2. 사용자/역할/그룹 영향도 보강 +3. 보호 상태 3단계 배지 +4. 조회 대상 등록 후 다음 단계 안내 + +### 3차: MCP/Select AI 품질과 고급 운영 + +1. DB 메타데이터 권한 기준 컬럼 배지 +2. MCP/검색 결과 타임라인과 소요시간 표시 +3. 보안 SQL 스크립트 summary card +4. 고급 접근 조건 체크리스트와 검증 강화 diff --git a/docs/runbooks/547-vm-https-operations.md b/docs/runbooks/547-vm-https-operations.md index c7d9db5..74d2efa 100644 --- a/docs/runbooks/547-vm-https-operations.md +++ b/docs/runbooks/547-vm-https-operations.md @@ -2,12 +2,14 @@ ## 운영 구조 -- 공개 endpoint: `https://<소유 FQDN>` 또는 `https://<고정 public IPv4>` -- Caddy: VM의 80/443에서 TLS termination, HTTP redirect, HSTS, 인증서 자동 발급·갱신 -- Spring Boot: `127.0.0.1:8082`에서만 수신 -- 외부 `8082/tcp`: OCI NSG와 VM firewalld 모두 deny +- 공개 endpoint: `https://kb.cloud-handson.com` +- 공개 배포 VM: `opc@161.33.6.45` +- Nginx: VM의 80/443에서 TLS termination, HTTP redirect, HSTS +- Spring Boot: `127.0.0.1:8080`에서만 수신 +- 외부 애플리케이션 포트 직접 접근: OCI NSG와 VM firewalld 모두 deny +- 인증서: Let’s Encrypt / Certbot Nginx plugin -FQDN을 쓰면 A 레코드는 VM public IP를 가리켜야 한다. DNS가 없는 고정 public IPv4는 Let’s Encrypt `shortlived` profile 기반 약 6일 인증서를 사용하며 Caddy 자동 갱신이 정상인지 반드시 모니터링한다. 임시 공개 DNS 서비스와 Caddy 로컬 CA 인증서는 운영 endpoint로 사용하지 않는다. +FQDN의 A 레코드는 VM public IP `161.33.6.45`를 가리켜야 한다. 현재 운영 경로는 Nginx가 `127.0.0.1:8080`의 `vpd-backoffice.service`로 프록시하는 구조다. `hermes` 또는 `8082` 응답만 보고 운영 반영 완료로 판단하지 않는다. ## 최초 준비 @@ -16,15 +18,8 @@ FQDN을 쓰면 A 레코드는 VM public IP를 가리켜야 한다. DNS가 없는 1. 소유 FQDN의 A 레코드를 VM public IP로 설정하거나 고정 public IPv4 사용을 확정한다. 2. OCI NSG와 VM firewalld에서 80/443 ingress를 허용한다. 3. 기존 8082 ingress를 OCI NSG와 firewalld에서 제거한다. -4. Oracle Linux/RHEL 계열 VM에 공식 Caddy 패키지를 설치한다. - -```bash -sudo dnf install -y dnf-plugins-core -sudo dnf copr enable -y @caddy/caddy -sudo dnf install -y caddy -``` - -공식 패키지는 `caddy.service`와 `/etc/caddy/Caddyfile`을 제공한다. 설정 스크립트가 service enable/start를 처리한다. +4. Oracle Linux/RHEL 계열 VM에 Nginx와 Certbot Nginx plugin을 설치한다. +5. `vpd-backoffice.service`는 `127.0.0.1:8080`에만 바인딩한다. ## 적용 @@ -32,78 +27,58 @@ sudo dnf install -y caddy ```bash mvn test -scripts/test-backoffice-https-config.sh -scripts/deploy-backoffice-vm.sh --host hermes ``` -HTTPS 설정을 dry-run으로 확인한 다음 적용한다. +운영 배포는 `161.33.6.45` 대상에 수행한다. SSH alias를 쓴다면 해당 alias가 반드시 `opc@161.33.6.45`를 가리키는지 먼저 확인한다. ```bash -scripts/configure-backoffice-https-vm.sh \ - --host hermes \ - --public-host admin.example.com \ - --tls-email ops@example.com \ - --expected-address 130.162.134.59 \ - --dry-run - -scripts/configure-backoffice-https-vm.sh \ - --host hermes \ - --public-host admin.example.com \ - --tls-email ops@example.com \ - --expected-address 130.162.134.59 +ssh <운영-alias> 'hostname; hostname -I; systemctl status vpd-backoffice --no-pager' ``` -실제 endpoint, 이메일, 주소로 바꿔 실행한다. FQDN 대신 IP를 쓰는 현재 hermes 예시는 `--public-host 130.162.134.59 --expected-address 130.162.134.59`다. 스크립트는 DNS/IP 일치, Caddy/sudo, Caddyfile 문법, service 상태를 확인하고 기존 설정을 `~/apps/vpd-backoffice/caddy-backups`에 저장한다. +배포 후에는 systemd 서비스를 재시작하고 공개 URL로 확인한다. + +```bash +ssh <운영-alias> 'sudo systemctl restart vpd-backoffice' +curl -k -sS https://kb.cloud-handson.com/login +``` ## 반복 검증 설정을 바꾸지 않고 외부 검증만 다시 수행할 수 있다. -```bash -scripts/configure-backoffice-https-vm.sh \ - --host hermes \ - --public-host admin.example.com \ - --tls-email ops@example.com \ - --expected-address 130.162.134.59 \ - --verify-only -``` - 검증 항목: - HTTP `/login`이 동일 host의 HTTPS로 전환됨 - HTTPS 인증서가 공개 신뢰됨 - HSTS 1년 - `JSESSIONID`의 Secure/HttpOnly/SameSite=Lax -- 외부 `:8082` 직접 연결 실패 +- 외부 애플리케이션 포트 직접 연결 실패 +- `https://kb.cloud-handson.com/login`의 HTML이 현재 배포된 jar의 로그인 화면과 일치 ## 인증서 갱신과 모니터링 -Caddy는 공개 DNS 이름의 인증서를 자동 갱신하며, 공식 systemd service의 인증서 상태는 `/var/lib/caddy/.local/share/caddy`에 유지된다. 별도 cron이나 certbot hook을 추가하지 않는다. +Certbot timer가 Let’s Encrypt 인증서를 갱신한다. Nginx 설정과 인증서 갱신 상태를 함께 확인한다. ```bash -ssh hermes 'systemctl is-active caddy && systemctl is-enabled caddy' -ssh hermes 'sudo journalctl -u caddy --since "24 hours ago" --no-pager' -ssh hermes 'sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile' +ssh <운영-alias> 'systemctl is-active nginx && systemctl is-enabled nginx' +ssh <운영-alias> 'systemctl is-active certbot-renew.timer && systemctl is-enabled certbot-renew.timer' +ssh <운영-alias> 'sudo nginx -t' +ssh <운영-alias> 'sudo journalctl -u nginx --since "24 hours ago" --no-pager' ``` -ACME 오류, 인증서 만료 경고, 반복 reload 실패를 알림 대상으로 삼는다. VM 백업에서 Caddy data directory와 앱의 Caddyfile backup을 함께 보존한다. +ACME 오류, 인증서 만료 경고, 반복 reload 실패를 알림 대상으로 삼는다. ## 장애와 롤백 1. 앱이 살아 있는지 VM 내부에서 확인한다. ```bash -ssh hermes 'curl -sS -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: admin.example.com" -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8082/login' +ssh <운영-alias> 'curl -sS -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: kb.cloud-handson.com" -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/login' ``` -2. Caddy 로그와 설정을 확인한다. -3. 설정 변경 직후 장애라면 가장 최근 backup을 복원한다. +2. Nginx 로그와 설정을 확인한다. +3. 설정 변경 직후 장애라면 가장 최근 Nginx 설정 backup을 복원한다. -```bash -ssh hermes 'ls -1t ~/apps/vpd-backoffice/caddy-backups/Caddyfile.* | head -n 3' -ssh hermes 'sudo install -o root -g root -m 644 ~/apps/vpd-backoffice/caddy-backups/Caddyfile. /etc/caddy/Caddyfile && sudo systemctl reload caddy' -``` +4. 앱 jar 롤백이 필요하면 직전 승인된 artifact를 배포하고 앱과 Nginx를 모두 재검증한다. -4. 앱 jar 롤백이 필요하면 직전 승인된 artifact를 배포하고 앱과 Caddy를 모두 재검증한다. - -장애 우회를 위해 `8082`를 다시 공개하지 않는다. 서비스 중단이나 방화벽/NSG 롤백은 개별 승인을 받는다. +장애 우회를 위해 애플리케이션 포트를 직접 공개하지 않는다. 서비스 중단이나 방화벽/NSG 롤백은 개별 승인을 받는다. diff --git a/ops/sonarqube/99-sonarqube.conf b/ops/sonarqube/99-sonarqube.conf new file mode 100644 index 0000000..4b5dee1 --- /dev/null +++ b/ops/sonarqube/99-sonarqube.conf @@ -0,0 +1 @@ +vm.max_map_count = 524288 diff --git a/ops/sonarqube/sonarcube.cloud-handson.com.conf b/ops/sonarqube/sonarcube.cloud-handson.com.conf new file mode 100644 index 0000000..c4ed267 --- /dev/null +++ b/ops/sonarqube/sonarcube.cloud-handson.com.conf @@ -0,0 +1,15 @@ +server { + listen 80; + listen [::]:80; + server_name sonarcube.cloud-handson.com; + + location / { + proxy_pass http://127.0.0.1:9000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } +} diff --git a/ops/sonarqube/sonarqube-community.service b/ops/sonarqube/sonarqube-community.service new file mode 100644 index 0000000..259ae8b --- /dev/null +++ b/ops/sonarqube/sonarqube-community.service @@ -0,0 +1,20 @@ +[Unit] +Description=SonarQube Community Build (evaluation) +After=network.target + +[Service] +Type=simple +User=opc +Group=opc +WorkingDirectory=/home/opc/tools/sonarqube-26.6.0.123539 +Environment=SONAR_WEB_HOST=127.0.0.1 +Environment=SONAR_WEB_PORT=9000 +ExecStart=/bin/bash /home/opc/tools/sonarqube-26.6.0.123539/bin/linux-x86-64/sonar.sh console +Restart=on-failure +RestartSec=10 +TimeoutStartSec=180 +LimitNOFILE=131072 +LimitNPROC=8192 + +[Install] +WantedBy=multi-user.target diff --git a/pom.xml b/pom.xml index 361ae58..8fc9b81 100644 --- a/pom.xml +++ b/pom.xml @@ -19,10 +19,12 @@ - - 21 - 3.0.4 - 23.6.0.24.10 + + 21 + 3.0.4 + 23.6.0.24.10 + + 3.90.1 @@ -57,6 +59,21 @@ oraclepki ${oracle.jdbc.version} + + + com.oracle.oci.sdk + oci-java-sdk-generativeaiinference + ${oci.sdk.version} + + + + com.oracle.oci.sdk + oci-java-sdk-common-httpclient-jersey3 + ${oci.sdk.version} + org.springframework.boot @@ -71,6 +88,24 @@ + + + src/main/resources + + + + sql/adb + sql/adb + + 62_kb_aso_masking_backoffice_metadata.sql + 63_kb_aso_masking_rule_runtime.sql + 64_kb_aso_masking_default_column_rules.sql + 65_kb_select_ai_vpd_query_api.sql + 66_kb_select_ai_vpd_query_ords.sql + + + org.springframework.boot diff --git a/scripts/enqueue.sh b/scripts/enqueue.sh new file mode 100755 index 0000000..af3736e --- /dev/null +++ b/scripts/enqueue.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# 새 작업을 파이프라인 큐에 투입 = 01-Planner/신규 Redmine 이슈 생성. +# 사용법: ./scripts/enqueue.sh "제목" ["요구사항"] +set -euo pipefail +cd "$(dirname "$0")/.." +set -a; . ./.env; set +a +SUBJECT="${1:?사용법: enqueue.sh \"제목\" [\"요구\"]}"; BODY="${2:-}" +PLANNER=$(curl -s -H "X-Redmine-API-Key: $REDMINE_API_KEY" \ + "$REDMINE_URL/projects/$REDMINE_PROJECT/issue_categories.json" \ + | python3 -c "import sys,json;[print(c['id']) for c in json.load(sys.stdin)['issue_categories'] if c['name']=='01-Planner']") +DESC=$(printf '## [AI] Planner\n\n(요구사항)\n%s\n\n---\nWorking dir: %s' "$BODY" "$(pwd)") +python3 - "$REDMINE_URL" "$REDMINE_API_KEY" "$REDMINE_PROJECT" "$SUBJECT" "$DESC" "$PLANNER" <<'PY' +import sys,json,urllib.request +base,key,proj,subject,desc,cat=sys.argv[1:7] +p={"issue":{"project_id":proj,"tracker_id":2,"subject":subject,"description":desc, + "category_id":int(cat),"status_id":1}} +r=urllib.request.Request(base+"/issues.json",data=json.dumps(p).encode(), + headers={"X-Redmine-API-Key":key,"Content-Type":"application/json"},method="POST") +i=json.load(urllib.request.urlopen(r))["issue"] +print(f"enqueued #{i['id']}: {i['subject']} -> 01-Planner/신규") +PY diff --git a/scripts/provision-dds-token-search.sh b/scripts/provision-dds-token-search.sh new file mode 100644 index 0000000..74c9f29 --- /dev/null +++ b/scripts/provision-dds-token-search.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Provisions the single DDS technical END USER used by business-user searches. +# Required environment: DDS_ADMIN_DB_URL, DDS_ADMIN_USERNAME, +# DDS_ADMIN_PASSWORD, DDS_TOKEN_PASSWORD. +set -Eeuo pipefail + +: "${DDS_ADMIN_DB_URL:?set DDS_ADMIN_DB_URL}" +: "${DDS_ADMIN_USERNAME:?set DDS_ADMIN_USERNAME}" +: "${DDS_ADMIN_PASSWORD:?set DDS_ADMIN_PASSWORD}" +: "${DDS_TOKEN_PASSWORD:?set DDS_TOKEN_PASSWORD}" + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +connect="${DDS_ADMIN_USERNAME}/${DDS_ADMIN_PASSWORD}@${DDS_ADMIN_DB_URL}" + +sqlplus -s "$connect" <' +echo ' DDS_BACKOFFICE_TOKEN_USERNAME=dds_demo_token' +echo ' DDS_BACKOFFICE_TOKEN_PASSWORD=' diff --git a/scripts/run_agent_ords_security_adb_local.sh b/scripts/run_agent_ords_security_adb_local.sh new file mode 100755 index 0000000..825bcff --- /dev/null +++ b/scripts/run_agent_ords_security_adb_local.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# ============================================================ +# Agent ORDS security ADB local-only executable example. +# +# This script does not call RDS, DB Link, Postgres, or MySQL. +# It creates local ADB tables/views and verifies VPD + DDS. +# ============================================================ +set -Eeuo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ ! -f "$ROOT/.env" ]]; then + echo "[FAIL] .env not found. Copy .env.example to .env and fill ADB_* values." >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +. "$ROOT/.env" +set +a + +run_admin() { + local sql_file="$1" + echo + echo "[ADMIN] @$sql_file" + sqlplus -S -L "${ADB_USER}/${ADB_PASSWORD}@${ADB_TNS}" @"$sql_file" +} + +run_as() { + local user="$1" password="$2" sql_file="$3" + echo + echo "[$user] @$sql_file" + sqlplus -S -L "${user}/${password}@${ADB_TNS}" @"$sql_file" +} + +run_admin "$ROOT/sql/adb/16_agent_ords_security_local_cleanup.sql" +run_admin "$ROOT/sql/adb/17_agent_ords_security_local_vpd_setup.sql" +run_admin "$ROOT/sql/adb/19_agent_ords_security_local_dds_setup.sql" +run_admin "$ROOT/sql/adb/21_agent_ords_security_ords_enable_schema.sql" +run_as "cb_ords" "CbOrdS#2026Local1" "$ROOT/sql/adb/22_agent_ords_security_ords_handler_setup.sql" +run_admin "$ROOT/sql/adb/24_agent_ords_security_inventory.sql" + +run_as "cb_ords" "CbOrdS#2026Local1" "$ROOT/sql/adb/18_agent_ords_security_local_vpd_test.sql" +run_as "cb_ords" "CbOrdS#2026Local1" "$ROOT/sql/adb/23_agent_ords_security_ords_handler_test.sql" + +run_as '"cb_dds_hr"' "CbDds#Hr2026Local1" "$ROOT/sql/adb/20_agent_ords_security_local_dds_test.sql" +run_as '"cb_dds_fin"' "CbDds#Fin2026Local1" "$ROOT/sql/adb/20_agent_ords_security_local_dds_test.sql" +run_as '"cb_dds_all"' "CbDds#All2026Local1" "$ROOT/sql/adb/20_agent_ords_security_local_dds_test.sql" +run_as '"cb_dds_none"' "CbDds#None2026Local1" "$ROOT/sql/adb/20_agent_ords_security_local_dds_test.sql" + +echo +echo "[OK] Agent ORDS security ADB local-only VPD + DDS executable example complete" diff --git a/sql/adb/16_agent_ords_security_local_cleanup.sql b/sql/adb/16_agent_ords_security_local_cleanup.sql new file mode 100644 index 0000000..e6c4447 --- /dev/null +++ b/sql/adb/16_agent_ords_security_local_cleanup.sql @@ -0,0 +1,94 @@ +-- ============================================================ +-- 16_agent_ords_security_local_cleanup.sql +-- Agent ORDS security local-only cleanup. +-- +-- This removes only CB_* objects used by the local ADB example. +-- It does not touch the existing RDS DB Link POC objects. +-- ============================================================ +WHENEVER SQLERROR CONTINUE +SET ECHO OFF +SET FEEDBACK ON + +PROMPT === Cleaning VPD policy === +BEGIN + DBMS_REDACT.DROP_POLICY( + object_schema => USER, + object_name => 'CB_V_SEARCH_DOCUMENTS', + policy_name => 'CB_CONTENTS_REDACT' + ); +EXCEPTION + WHEN OTHERS THEN NULL; +END; +/ + +BEGIN + DBMS_RLS.DROP_POLICY( + object_schema => USER, + object_name => 'CB_V_SEARCH_DOCUMENTS', + policy_name => 'CB_AGENT_DOC_POLICY' + ); +EXCEPTION + WHEN OTHERS THEN NULL; +END; +/ + +PROMPT === Cleaning DDS data grants === +BEGIN EXECUTE IMMEDIATE 'DROP DATA GRANT admin.cb_dg_hr_docs'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP DATA GRANT admin.cb_dg_fin_docs'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP DATA GRANT admin.cb_dg_all_docs'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ + +PROMPT === Cleaning DDS end users and roles === +BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_hr"'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_fin"'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_all"'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_none"'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_hr_role'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_fin_role'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_all_role'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_connect_only_role'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP ROLE cb_dds_connect_role'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ + +PROMPT === Cleaning DB user and local objects === +BEGIN EXECUTE IMMEDIATE 'DROP USER cb_ords CASCADE'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP VIEW cb_dds_v_search_documents'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP VIEW cb_v_search_documents'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP FUNCTION cb_agent_doc_vpd_filter'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP CONTEXT cb_agent_ctx'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP PACKAGE cb_agent_ctx_pkg'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_agent_bearer_key PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_permission_rule PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_permission PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_user_role PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_app_role PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_app_user PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_dds_documents PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ +BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_search_documents PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ + +PROMPT === Agent ORDS security local cleanup complete === +EXIT; diff --git a/sql/adb/18_agent_ords_security_local_vpd_test.sql b/sql/adb/18_agent_ords_security_local_vpd_test.sql new file mode 100644 index 0000000..8fdc5e8 --- /dev/null +++ b/sql/adb/18_agent_ords_security_local_vpd_test.sql @@ -0,0 +1,115 @@ +-- ============================================================ +-- 18_agent_ords_security_local_vpd_test.sql +-- Run as CB_ORDS. +-- +-- Expected: +-- no context -> 0 rows +-- cb_hr_key -> HR rows only, 3 rows +-- cb_fin_key -> owner_emp_no = E2001 only, 1 row +-- cb_all_key -> all rows, 6 rows +-- contents column -> NULL unless CAN_READ_CONTENTS = Y +-- invalid key -> ORA-20002, then 0 rows +-- direct base table -> ORA-00942 +-- ============================================================ +WHENEVER SQLERROR CONTINUE +SET ECHO OFF +SET FEEDBACK ON +SET LINESIZE 220 +SET PAGESIZE 100 +COLUMN ctx_user_id FORMAT A12 +COLUMN ctx_emp_no FORMAT A12 +COLUMN ctx_dept_code FORMAT A12 +COLUMN ctx_can_read_contents FORMAT A22 +COLUMN title FORMAT A28 +COLUMN owner_emp_no FORMAT A12 +COLUMN dept_code FORMAT A10 +COLUMN contents FORMAT A35 + +PROMPT +PROMPT === VPD 1. No Bearer key / context: fail closed === +BEGIN + admin.cb_agent_ctx_pkg.clear_user; +END; +/ + +SELECT SYS_CONTEXT('CB_AGENT_CTX','USER_ID') AS ctx_user_id, + SYS_CONTEXT('CB_AGENT_CTX','EMP_NO') AS ctx_emp_no, + SYS_CONTEXT('CB_AGENT_CTX','DEPT_CODE') AS ctx_dept_code, + SYS_CONTEXT('CB_AGENT_CTX','CAN_READ_CONTENTS') AS ctx_can_read_contents +FROM dual; + +SELECT COUNT(*) AS rows_visible +FROM admin.cb_v_search_documents; + +PROMPT +PROMPT === VPD 2. Authorization: Bearer cb_hr_key -> HR department rows === +BEGIN + admin.cb_agent_ctx_pkg.set_user_by_bearer('cb_hr_key'); +END; +/ + +SELECT SYS_CONTEXT('CB_AGENT_CTX','USER_ID') AS ctx_user_id, + SYS_CONTEXT('CB_AGENT_CTX','EMP_NO') AS ctx_emp_no, + SYS_CONTEXT('CB_AGENT_CTX','DEPT_CODE') AS ctx_dept_code, + SYS_CONTEXT('CB_AGENT_CTX','CAN_READ_CONTENTS') AS ctx_can_read_contents +FROM dual; + +SELECT doc_id, title, owner_emp_no, dept_code, contents +FROM admin.cb_v_search_documents +ORDER BY doc_id; + +SELECT COUNT(*) AS rows_visible +FROM admin.cb_v_search_documents; + +PROMPT +PROMPT === VPD 3. Authorization: Bearer cb_fin_key -> self row only === +BEGIN + admin.cb_agent_ctx_pkg.set_user_by_bearer('cb_fin_key'); +END; +/ + +SELECT SYS_CONTEXT('CB_AGENT_CTX','USER_ID') AS ctx_user_id, + SYS_CONTEXT('CB_AGENT_CTX','EMP_NO') AS ctx_emp_no, + SYS_CONTEXT('CB_AGENT_CTX','DEPT_CODE') AS ctx_dept_code, + SYS_CONTEXT('CB_AGENT_CTX','CAN_READ_CONTENTS') AS ctx_can_read_contents +FROM dual; + +SELECT doc_id, title, owner_emp_no, dept_code, contents +FROM admin.cb_v_search_documents +ORDER BY doc_id; + +SELECT COUNT(*) AS rows_visible +FROM admin.cb_v_search_documents; + +SELECT doc_id, title, owner_emp_no, dept_code, contents +FROM admin.cb_v_search_documents +ORDER BY doc_id +FETCH FIRST 3 ROWS ONLY; + +PROMPT +PROMPT === VPD 4. Authorization: Bearer cb_all_key -> all rows === +BEGIN + admin.cb_agent_ctx_pkg.set_user_by_bearer('cb_all_key'); +END; +/ + +SELECT COUNT(*) AS rows_visible +FROM admin.cb_v_search_documents; + +PROMPT +PROMPT === VPD 5. Invalid Bearer key -> ORA-20002 and context cleared === +BEGIN + admin.cb_agent_ctx_pkg.set_user_by_bearer('wrong_key'); +END; +/ + +SELECT COUNT(*) AS rows_visible_after_invalid_key +FROM admin.cb_v_search_documents; + +PROMPT +PROMPT === VPD 6. Bypass attempts: direct base/permission tables are not granted === +SELECT COUNT(*) FROM admin.cb_search_documents; +SELECT COUNT(*) FROM admin.cb_app_user; +SELECT COUNT(*) FROM admin.cb_agent_bearer_key; + +EXIT; diff --git a/sql/adb/19_agent_ords_security_local_dds_setup.sql b/sql/adb/19_agent_ords_security_local_dds_setup.sql new file mode 100644 index 0000000..5831e54 --- /dev/null +++ b/sql/adb/19_agent_ords_security_local_dds_setup.sql @@ -0,0 +1,89 @@ +-- ============================================================ +-- 19_agent_ords_security_local_dds_setup.sql +-- Local-only executable DDS example for Agent ORDS security. +-- +-- Scenario: +-- * DDS uses END USER -> DATA ROLE -> DATA GRANT. +-- * No permission table lookup is used. +-- * Each DATA GRANT declares the protected object, row filter, +-- and column restriction. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO OFF +SET FEEDBACK ON +SET DEFINE OFF + +PROMPT === 1. Creating DDS-only local table and view === +CREATE TABLE cb_dds_documents ( + doc_id NUMBER PRIMARY KEY, + title VARCHAR2(100) NOT NULL, + owner_emp_no VARCHAR2(20) NOT NULL, + dept_code VARCHAR2(20) NOT NULL, + contents VARCHAR2(4000), + created_at DATE DEFAULT SYSDATE NOT NULL +); + +INSERT INTO cb_dds_documents VALUES (1, 'HR payroll guide', 'E1001', 'HR', 'Payroll policy and HR guide', SYSDATE); +INSERT INTO cb_dds_documents VALUES (2, 'HR recruiting plan', 'E1002', 'HR', 'Recruiting plan for HR team', SYSDATE); +INSERT INTO cb_dds_documents VALUES (3, 'Finance close checklist','E2001', 'FIN', 'Monthly close checklist', SYSDATE); +INSERT INTO cb_dds_documents VALUES (4, 'Finance audit memo', 'E2002', 'FIN', 'Audit memo for finance team', SYSDATE); +INSERT INTO cb_dds_documents VALUES (5, 'Sales forecast', 'E3001', 'SALES', 'Quarterly sales forecast', SYSDATE); +INSERT INTO cb_dds_documents VALUES (6, 'HR benefits notice', 'E1003', 'HR', 'Benefits notice for employees', SYSDATE); + +CREATE OR REPLACE VIEW cb_dds_v_search_documents AS +SELECT doc_id, + title, + owner_emp_no, + dept_code, + contents, + created_at +FROM cb_dds_documents; + +COMMIT; + +PROMPT === 2. Creating DDS end users === +CREATE END USER "cb_dds_hr" IDENTIFIED BY "CbDds#Hr2026Local1"; +CREATE END USER "cb_dds_fin" IDENTIFIED BY "CbDds#Fin2026Local1"; +CREATE END USER "cb_dds_all" IDENTIFIED BY "CbDds#All2026Local1"; +CREATE END USER "cb_dds_none" IDENTIFIED BY "CbDds#None2026Local1"; + +PROMPT === 3. Creating DATA ROLEs and connection carrier role === +CREATE ROLE cb_dds_connect_role; +GRANT CREATE SESSION TO cb_dds_connect_role; + +CREATE DATA ROLE cb_dds_hr_role; +CREATE DATA ROLE cb_dds_fin_role; +CREATE DATA ROLE cb_dds_all_role; +CREATE DATA ROLE cb_dds_connect_only_role; + +GRANT cb_dds_connect_role TO cb_dds_hr_role; +GRANT cb_dds_connect_role TO cb_dds_fin_role; +GRANT cb_dds_connect_role TO cb_dds_all_role; +GRANT cb_dds_connect_role TO cb_dds_connect_only_role; + +PROMPT === 4. Mapping END USERs to DATA ROLEs === +GRANT DATA ROLE cb_dds_hr_role TO "cb_dds_hr"; +GRANT DATA ROLE cb_dds_fin_role TO "cb_dds_fin"; +GRANT DATA ROLE cb_dds_all_role TO "cb_dds_all"; +GRANT DATA ROLE cb_dds_connect_only_role TO "cb_dds_none"; + +PROMPT === 5. Creating DATA GRANTs === +CREATE DATA GRANT admin.cb_dg_hr_docs + AS SELECT (ALL COLUMNS EXCEPT contents) + ON admin.cb_dds_v_search_documents + WHERE dept_code = 'HR' + TO cb_dds_hr_role; + +CREATE DATA GRANT admin.cb_dg_fin_docs + AS SELECT (ALL COLUMNS EXCEPT contents) + ON admin.cb_dds_v_search_documents + WHERE dept_code = 'FIN' + TO cb_dds_fin_role; + +CREATE DATA GRANT admin.cb_dg_all_docs + AS SELECT + ON admin.cb_dds_v_search_documents + TO cb_dds_all_role; + +PROMPT === DDS local setup complete === +EXIT; diff --git a/sql/adb/20_agent_ords_security_local_dds_test.sql b/sql/adb/20_agent_ords_security_local_dds_test.sql new file mode 100644 index 0000000..dd38ac4 --- /dev/null +++ b/sql/adb/20_agent_ords_security_local_dds_test.sql @@ -0,0 +1,46 @@ +-- ============================================================ +-- 20_agent_ords_security_local_dds_test.sql +-- Run as cb_dds_hr / cb_dds_fin / cb_dds_all / cb_dds_none. +-- +-- Expected: +-- cb_dds_hr -> 3 rows from admin.cb_dds_v_search_documents +-- cb_dds_fin -> 2 rows from admin.cb_dds_v_search_documents +-- cb_dds_all -> 6 rows from admin.cb_dds_v_search_documents +-- contents -> NULL for hr/fin grants, visible for all grant +-- cb_dds_none -> ORA-00942 on admin.cb_dds_v_search_documents +-- base table -> ORA-00942 for every DDS end user +-- ============================================================ +WHENEVER SQLERROR CONTINUE +SET ECHO OFF +SET FEEDBACK ON +SET LINESIZE 220 +SET PAGESIZE 100 +COLUMN end_user_name FORMAT A16 +COLUMN title FORMAT A28 +COLUMN owner_emp_no FORMAT A12 +COLUMN dept_code FORMAT A10 +COLUMN contents FORMAT A35 + +PROMPT +PROMPT === DDS 1. End user context === +SELECT ORA_END_USER_CONTEXT.username AS end_user_name +FROM dual; + +PROMPT +PROMPT === DDS 2. Protected view row count === +SELECT COUNT(*) AS rows_visible +FROM admin.cb_dds_v_search_documents; + +PROMPT +PROMPT === DDS 3. Protected view sample === +SELECT doc_id, title, owner_emp_no, dept_code, contents +FROM admin.cb_dds_v_search_documents +ORDER BY doc_id; + +PROMPT +PROMPT === DDS 4. Bypass attempts: no DATA GRANT on base/VPD objects === +SELECT COUNT(*) FROM admin.cb_dds_documents; +SELECT COUNT(*) FROM admin.cb_search_documents; +SELECT COUNT(*) FROM admin.cb_v_search_documents; + +EXIT; diff --git a/sql/adb/21_agent_ords_security_ords_enable_schema.sql b/sql/adb/21_agent_ords_security_ords_enable_schema.sql new file mode 100644 index 0000000..f9477b9 --- /dev/null +++ b/sql/adb/21_agent_ords_security_ords_enable_schema.sql @@ -0,0 +1,25 @@ +-- ============================================================ +-- 21_agent_ords_security_ords_enable_schema.sql +-- Enable the CB_ORDS schema for ORDS module/handler definitions. +-- +-- Run as ADMIN after CB_ORDS has been created. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO OFF +SET FEEDBACK ON + +PROMPT === Enabling CB_ORDS as an ORDS schema === +BEGIN + ORDS.ENABLE_SCHEMA( + p_enabled => TRUE, + p_schema => 'CB_ORDS', + p_url_mapping_type => 'BASE_PATH', + p_url_mapping_pattern => 'cb-ords', + p_auto_rest_auth => FALSE + ); + COMMIT; +END; +/ + +PROMPT === CB_ORDS ORDS schema enabled === +EXIT; diff --git a/sql/adb/23_agent_ords_security_ords_handler_test.sql b/sql/adb/23_agent_ords_security_ords_handler_test.sql new file mode 100644 index 0000000..919b60b --- /dev/null +++ b/sql/adb/23_agent_ords_security_ords_handler_test.sql @@ -0,0 +1,44 @@ +-- ============================================================ +-- 23_agent_ords_security_ords_handler_test.sql +-- Run as CB_ORDS. +-- +-- This directly executes the same package that the ORDS handlers +-- call. It proves the database-side behavior without requiring an +-- external ORDS URL. +-- ============================================================ +WHENEVER SQLERROR CONTINUE +SET ECHO OFF +SET FEEDBACK ON +SET LONG 100000 +SET LONGCHUNKSIZE 100000 +SET LINESIZE 220 +SET PAGESIZE 100 +SET SERVEROUTPUT ON +COLUMN response FORMAT A180 WORD_WRAPPED + +PROMPT +PROMPT === ORDS handler probe 1. VPD path with mandatory Bearer header === +SELECT cb_ords_handler_pkg.vpd_search_json('Bearer cb_hr_key') AS response +FROM dual; + +PROMPT +PROMPT === ORDS handler probe 2. VPD path without Bearer header === +BEGIN + DBMS_OUTPUT.PUT_LINE(cb_ords_handler_pkg.vpd_search_json(NULL)); +EXCEPTION + WHEN OTHERS THEN + DBMS_OUTPUT.PUT_LINE(SQLERRM); +END; +/ + +PROMPT +PROMPT === ORDS handler probe 3. DDS path with Bearer header only === +SELECT cb_ords_handler_pkg.dds_bearer_probe_json('Bearer cb_hr_key') AS response +FROM dual; + +PROMPT +PROMPT === ORDS handler probe 4. DDS path with all-access Bearer header only === +SELECT cb_ords_handler_pkg.dds_bearer_probe_json('Bearer cb_all_key') AS response +FROM dual; + +EXIT; diff --git a/sql/adb/24_agent_ords_security_inventory.sql b/sql/adb/24_agent_ords_security_inventory.sql new file mode 100644 index 0000000..d5da2b1 --- /dev/null +++ b/sql/adb/24_agent_ords_security_inventory.sql @@ -0,0 +1,99 @@ +-- ============================================================ +-- 24_agent_ords_security_inventory.sql +-- Central security inventory for Agent ORDS security. +-- +-- Run as ADMIN after VPD, DDS, and ORDS handler setup. +-- ============================================================ +WHENEVER SQLERROR CONTINUE +SET ECHO OFF +SET FEEDBACK ON +SET LINESIZE 260 +SET PAGESIZE 200 + +COLUMN object_owner FORMAT A12 +COLUMN object_name FORMAT A32 +COLUMN policy_name FORMAT A26 +COLUMN pf_owner FORMAT A12 +COLUMN function FORMAT A30 +COLUMN sel FORMAT A5 +COLUMN ins FORMAT A5 +COLUMN upd FORMAT A5 +COLUMN del FORMAT A5 +COLUMN enable FORMAT A8 +COLUMN expression FORMAT A70 WORD_WRAPPED +COLUMN grant_name FORMAT A24 +COLUMN grantee FORMAT A28 +COLUMN privilege FORMAT A10 +COLUMN allowed_columns FORMAT A76 WORD_WRAPPED +COLUMN excluded_columns FORMAT A22 +COLUMN predicate FORMAT A28 +COLUMN end_user_name FORMAT A26 +COLUMN data_role FORMAT A28 + +PROMPT +PROMPT === Inventory 1. VPD policies attached to protected objects === +SELECT object_owner, + object_name, + policy_name, + pf_owner, + function, + sel, + ins, + upd, + del, + enable +FROM dba_policies +WHERE object_owner = 'ADMIN' +AND object_name LIKE 'CB%' +ORDER BY object_name, policy_name; + +PROMPT +PROMPT === Inventory 2. Redaction policies attached to protected objects === +SELECT object_owner, + object_name, + policy_name, + enable, + expression +FROM redaction_policies +WHERE object_owner = 'ADMIN' +AND object_name LIKE 'CB%' +ORDER BY object_name, policy_name; + +PROMPT +PROMPT === Inventory 3. DDS grant summary, one row per DATA GRANT === +SELECT grant_name, + object_name, + grantee, + privilege, + COALESCE( + LISTAGG(column_name, ', ') WITHIN GROUP (ORDER BY column_name), + 'ALL COLUMNS' + ) AS allowed_columns, + COALESCE(MAX(granted_with_all_columns_except), '-') AS excluded_columns, + predicate +FROM dba_data_grants +WHERE owner = 'ADMIN' +AND object_name LIKE 'CB%' +GROUP BY grant_name, object_name, grantee, privilege, predicate +ORDER BY object_name, grant_name; + +PROMPT +PROMPT === Inventory 4. DDS end user -> data role -> data grant matrix === +SELECT rg.grantee AS end_user_name, + rg.data_role, + dg.grant_name, + dg.object_name, + dg.predicate, + COALESCE(MAX(dg.granted_with_all_columns_except), '-') AS excluded_columns +FROM dba_data_role_grants rg +LEFT JOIN dba_data_grants dg +ON dg.grantee = rg.data_role +WHERE rg.grantee LIKE 'cb\_dds\_%' ESCAPE '\' +GROUP BY rg.grantee, + rg.data_role, + dg.grant_name, + dg.object_name, + dg.predicate +ORDER BY rg.grantee, dg.object_name, dg.grant_name; + +EXIT; diff --git a/sql/adb/41_dds_row_column_fixture.sql b/sql/adb/41_dds_row_column_fixture.sql new file mode 100644 index 0000000..ac97382 --- /dev/null +++ b/sql/adb/41_dds_row_column_fixture.sql @@ -0,0 +1,24 @@ +-- DDS row/column verification fixtures. Run as ADMIN after 31 and 34. +CREATE TABLE admin.cb_dds_security_fixture ( + fixture_id NUMBER PRIMARY KEY, + dept_code VARCHAR2(30) NOT NULL, + owner_emp_no VARCHAR2(30) NOT NULL, + tech_tag VARCHAR2(100) NOT NULL, + title VARCHAR2(200) NOT NULL, + contents VARCHAR2(2000) NOT NULL, + public_summary VARCHAR2(500) NOT NULL +); + +INSERT INTO admin.cb_dds_security_fixture VALUES (1,'SALES','E2001','SALES','영업 파이프라인','영업 원문','영업 요약'); +INSERT INTO admin.cb_dds_security_fixture VALUES (2,'HR','E10234','HR','인사 운영','인사 원문','인사 요약'); +INSERT INTO admin.cb_dds_security_fixture VALUES (3,'FIN','E99999','FINANCE','재무 마감','재무 원문','재무 요약'); +COMMIT; + +CREATE OR REPLACE DATA GRANT admin.dds_demo_token_fixture_grant + AS SELECT (fixture_id, dept_code, owner_emp_no, tech_tag, title, public_summary) + ON admin.cb_dds_security_fixture + WHERE admin.cb_dds_vector_tag_allowed(tech_tag) = 1 + TO cb_dds_token_role; + +-- Expected: token mapped to SALES sees row 1; HR/FIN rows are denied. +-- CONTENTS is deliberately excluded: this is the column-level deny case. diff --git a/sql/adb/42_dds_fga_execution_evidence.sql b/sql/adb/42_dds_fga_execution_evidence.sql new file mode 100644 index 0000000..cc6242c --- /dev/null +++ b/sql/adb/42_dds_fga_execution_evidence.sql @@ -0,0 +1,32 @@ +-- DDS execution evidence: durable FGA audit for the protected vector view. +-- Run as ADMIN/AUDIT_ADMIN. In unified auditing the records are queried from +-- UNIFIED_AUDIT_TRAIL with AUDIT_TYPE = 'FineGrainedAudit'. +BEGIN + DBMS_FGA.DROP_POLICY( + object_schema => 'ADMIN', object_name => 'CB_DDS_VECTOR_SEARCH_DOCUMENTS', + policy_name => 'DDS_VECTOR_EXECUTION_EVIDENCE'); +EXCEPTION WHEN OTHERS THEN + IF SQLCODE != -28102 THEN RAISE; END IF; +END; +/ +BEGIN + DBMS_FGA.ADD_POLICY( + object_schema => 'ADMIN', + object_name => 'CB_DDS_VECTOR_SEARCH_DOCUMENTS', + policy_name => 'DDS_VECTOR_EXECUTION_EVIDENCE', + audit_condition => NULL, + audit_column => NULL, + statement_types => 'SELECT', + audit_trail => DBMS_FGA.DB_EXTENDED, + enable => TRUE); +END; +/ +-- Evidence lookup (grant AUDIT_VIEWER or expose a definer-rights reader view +-- to the backoffice; never grant this to DDS END USER accounts): +-- SELECT event_timestamp, client_identifier, dbusername, object_name, +-- return_code, sql_text, application_contexts +-- FROM unified_audit_trail +-- WHERE audit_type = 'FineGrainedAudit' +-- AND object_schema = 'ADMIN' +-- AND object_name = 'CB_DDS_VECTOR_SEARCH_DOCUMENTS' +-- ORDER BY event_timestamp DESC; diff --git a/sql/adb/46_kb_stakeholder_token_permission_example.sql b/sql/adb/46_kb_stakeholder_token_permission_example.sql index ceb949a..e91b8f2 100644 --- a/sql/adb/46_kb_stakeholder_token_permission_example.sql +++ b/sql/adb/46_kb_stakeholder_token_permission_example.sql @@ -432,88 +432,27 @@ BEGIN END; / -PROMPT === 5. Applying column NULL policies from permission display exceptions === --- ADB rejects application PL/SQL functions inside DBMS_REDACT expressions. --- SEC_RELEVANT_COL_OPT=ALL_ROWS is the VPD equivalent: it retains the row --- but returns NULL for the protected column while the column predicate is false. -CREATE OR REPLACE FUNCTION cb_kb_cust_nm_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2) -RETURN VARCHAR2 AUTHID DEFINER AS -BEGIN - RETURN CASE WHEN cb_agent_can_read_column('KB_CUSTOMERS', 'CUST_NM') = 'Y' THEN '1 = 1' ELSE '1 = 0' END; -END; -/ -CREATE OR REPLACE FUNCTION cb_kb_rrn_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2) -RETURN VARCHAR2 AUTHID DEFINER AS -BEGIN - RETURN CASE WHEN cb_agent_can_read_column('KB_CUSTOMERS', 'RRN_MASKED') = 'Y' THEN '1 = 1' ELSE '1 = 0' END; -END; -/ -CREATE OR REPLACE FUNCTION cb_kb_claim_amt_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2) -RETURN VARCHAR2 AUTHID DEFINER AS -BEGIN - RETURN CASE WHEN cb_agent_can_read_column('KB_CLAIMS', 'CLAIM_AMT') = 'Y' THEN '1 = 1' ELSE '1 = 0' END; -END; -/ -CREATE OR REPLACE FUNCTION cb_kb_paid_amt_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2) -RETURN VARCHAR2 AUTHID DEFINER AS -BEGIN - RETURN CASE WHEN cb_agent_can_read_column('KB_CLAIMS', 'PAID_AMT') = 'Y' THEN '1 = 1' ELSE '1 = 0' END; -END; -/ -CREATE OR REPLACE FUNCTION cb_kb_ext_insurer_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2) -RETURN VARCHAR2 AUTHID DEFINER AS -BEGIN - RETURN CASE WHEN cb_agent_can_read_column('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER') = 'Y' THEN '1 = 1' ELSE '1 = 0' END; -END; -/ -CREATE OR REPLACE FUNCTION cb_kb_ext_product_grp_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2) -RETURN VARCHAR2 AUTHID DEFINER AS -BEGIN - RETURN CASE WHEN cb_agent_can_read_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP') = 'Y' THEN '1 = 1' ELSE '1 = 0' END; -END; -/ -CREATE OR REPLACE FUNCTION cb_kb_ext_product_type_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2) -RETURN VARCHAR2 AUTHID DEFINER AS -BEGIN - RETURN CASE WHEN cb_agent_can_read_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE') = 'Y' THEN '1 = 1' ELSE '1 = 0' END; -END; -/ - +PROMPT === 5. Removing deprecated VPD column NULL policies === +-- Architecture rule: VPD applies row predicates only. ASO/Data Redaction +-- owns all column masking and its user-specific original-value exceptions. DECLARE - PROCEDURE replace_column_policy( - p_object_name IN VARCHAR2, - p_column_name IN VARCHAR2, - p_policy_name IN VARCHAR2, - p_function_name IN VARCHAR2 - ) IS + PROCEDURE drop_column_policy(p_object_name IN VARCHAR2, p_policy_name IN VARCHAR2) IS BEGIN - BEGIN - DBMS_RLS.DROP_POLICY( - object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name - ); - EXCEPTION WHEN OTHERS THEN NULL; - END; - DBMS_RLS.ADD_POLICY( - object_schema => 'POC_2', - object_name => p_object_name, - policy_name => p_policy_name, - function_schema => USER, - policy_function => p_function_name, - statement_types => 'SELECT', - policy_type => DBMS_RLS.DYNAMIC, - sec_relevant_cols => p_column_name, - sec_relevant_cols_opt => DBMS_RLS.ALL_ROWS, - enable => TRUE + DBMS_RLS.DROP_POLICY( + object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name ); + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -28102 THEN RAISE; END IF; END; BEGIN - replace_column_policy('KB_CUSTOMERS', 'CUST_NM', 'KB_CUST_NM_CLS_POLICY', 'CB_KB_CUST_NM_CLS'); - replace_column_policy('KB_CUSTOMERS', 'RRN_MASKED', 'KB_RRN_CLS_POLICY', 'CB_KB_RRN_CLS'); - replace_column_policy('KB_CLAIMS', 'CLAIM_AMT', 'KB_CLAIM_AMT_CLS_POLICY', 'CB_KB_CLAIM_AMT_CLS'); - replace_column_policy('KB_CLAIMS', 'PAID_AMT', 'KB_PAID_AMT_CLS_POLICY', 'CB_KB_PAID_AMT_CLS'); - replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER', 'KB_EXT_INSURER_CLS_POLICY', 'CB_KB_EXT_INSURER_CLS'); - replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP', 'KB_EXT_PRODUCT_GRP_CLS_POLICY', 'CB_KB_EXT_PRODUCT_GRP_CLS'); - replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY', 'CB_KB_EXT_PRODUCT_TYPE_CLS'); + drop_column_policy('KB_CUSTOMERS', 'KB_CUST_NM_CLS_POLICY'); + drop_column_policy('KB_CUSTOMERS', 'KB_RRN_CLS_POLICY'); + drop_column_policy('KB_CLAIMS', 'KB_CLAIM_AMT_CLS_POLICY'); + drop_column_policy('KB_CLAIMS', 'KB_PAID_AMT_CLS_POLICY'); + drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_INSURER_CLS_POLICY'); + drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_GRP_CLS_POLICY'); + drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY'); END; / diff --git a/sql/adb/55_reapply_kb_vpd_after_table_reload.sql b/sql/adb/55_reapply_kb_vpd_after_table_reload.sql index 8e8d982..3d043d2 100644 --- a/sql/adb/55_reapply_kb_vpd_after_table_reload.sql +++ b/sql/adb/55_reapply_kb_vpd_after_table_reload.sql @@ -65,13 +65,6 @@ BEGIN require_runtime_object('CB_AGENT_CTX_PKG', 'PACKAGE BODY'); require_runtime_object('CB_AGENT_DOC_VPD_FILTER', 'FUNCTION'); require_runtime_object('CB_AGENT_CAN_READ_COLUMN', 'FUNCTION'); - require_runtime_object('CB_KB_CUST_NM_CLS', 'FUNCTION'); - require_runtime_object('CB_KB_RRN_CLS', 'FUNCTION'); - require_runtime_object('CB_KB_CLAIM_AMT_CLS', 'FUNCTION'); - require_runtime_object('CB_KB_PAID_AMT_CLS', 'FUNCTION'); - require_runtime_object('CB_KB_EXT_INSURER_CLS', 'FUNCTION'); - require_runtime_object('CB_KB_EXT_PRODUCT_GRP_CLS', 'FUNCTION'); - require_runtime_object('CB_KB_EXT_PRODUCT_TYPE_CLS', 'FUNCTION'); END; / @@ -128,50 +121,28 @@ BEGIN END; / -PROMPT === 4. Reapplying column NULL policies === +PROMPT === 4. Removing deprecated VPD column NULL policies === +-- Architecture rule: VPD filters rows only. Column masking is handled solely +-- by ASO / DBMS_REDACT through 63_kb_aso_masking_rule_runtime.sql. DECLARE - PROCEDURE replace_column_policy( - p_object_name IN VARCHAR2, - p_column_name IN VARCHAR2, - p_policy_name IN VARCHAR2, - p_function_name IN VARCHAR2 - ) IS - v_count NUMBER; + PROCEDURE drop_column_policy(p_object_name IN VARCHAR2, p_policy_name IN VARCHAR2) IS BEGIN - SELECT COUNT(*) - INTO v_count - FROM all_policies - WHERE object_owner = 'POC_2' - AND object_name = p_object_name - AND policy_name = p_policy_name; - IF v_count > 0 THEN - DBMS_RLS.DROP_POLICY( - object_schema => 'POC_2', - object_name => p_object_name, - policy_name => p_policy_name - ); - END IF; - DBMS_RLS.ADD_POLICY( - object_schema => 'POC_2', - object_name => p_object_name, - policy_name => p_policy_name, - function_schema => 'ADMIN', - policy_function => p_function_name, - statement_types => 'SELECT', - policy_type => DBMS_RLS.DYNAMIC, - sec_relevant_cols => p_column_name, - sec_relevant_cols_opt => DBMS_RLS.ALL_ROWS, - enable => TRUE + DBMS_RLS.DROP_POLICY( + object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name ); + EXCEPTION + WHEN OTHERS THEN + -- A missing policy is already the desired state. Do not hide other errors. + IF SQLCODE != -28102 THEN RAISE; END IF; END; BEGIN - replace_column_policy('KB_CUSTOMERS', 'CUST_NM', 'KB_CUST_NM_CLS_POLICY', 'CB_KB_CUST_NM_CLS'); - replace_column_policy('KB_CUSTOMERS', 'RRN_MASKED', 'KB_RRN_CLS_POLICY', 'CB_KB_RRN_CLS'); - replace_column_policy('KB_CLAIMS', 'CLAIM_AMT', 'KB_CLAIM_AMT_CLS_POLICY', 'CB_KB_CLAIM_AMT_CLS'); - replace_column_policy('KB_CLAIMS', 'PAID_AMT', 'KB_PAID_AMT_CLS_POLICY', 'CB_KB_PAID_AMT_CLS'); - replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER', 'KB_EXT_INSURER_CLS_POLICY', 'CB_KB_EXT_INSURER_CLS'); - replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP', 'KB_EXT_PRODUCT_GRP_CLS_POLICY', 'CB_KB_EXT_PRODUCT_GRP_CLS'); - replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY', 'CB_KB_EXT_PRODUCT_TYPE_CLS'); + drop_column_policy('KB_CUSTOMERS', 'KB_CUST_NM_CLS_POLICY'); + drop_column_policy('KB_CUSTOMERS', 'KB_RRN_CLS_POLICY'); + drop_column_policy('KB_CLAIMS', 'KB_CLAIM_AMT_CLS_POLICY'); + drop_column_policy('KB_CLAIMS', 'KB_PAID_AMT_CLS_POLICY'); + drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_INSURER_CLS_POLICY'); + drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_GRP_CLS_POLICY'); + drop_column_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY'); END; / diff --git a/sql/adb/62_kb_aso_masking_backoffice_metadata.sql b/sql/adb/62_kb_aso_masking_backoffice_metadata.sql new file mode 100644 index 0000000..c72aa89 --- /dev/null +++ b/sql/adb/62_kb_aso_masking_backoffice_metadata.sql @@ -0,0 +1,100 @@ +-- ============================================================ +-- 62_kb_aso_masking_backoffice_metadata.sql +-- +-- Creates the metadata used by the masking-rule backoffice pages. +-- Run as ADMIN with SQLcl. This script does not create a DBMS_REDACT +-- policy; run 63_kb_aso_masking_rule_runtime.sql after rules are +-- connected to protected columns. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET DEFINE OFF +SET FEEDBACK ON + +PROMPT === Creating masking-rule metadata tables === +DECLARE + PROCEDURE ensure_table(p_name IN VARCHAR2, p_ddl IN CLOB) IS + BEGIN + EXECUTE IMMEDIATE p_ddl; + DBMS_OUTPUT.PUT_LINE('created ' || p_name); + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE = -955 THEN + DBMS_OUTPUT.PUT_LINE('exists ' || p_name); + ELSE + RAISE; + END IF; + END; +BEGIN + ensure_table('CB_MASKING_RULE', q'[ + CREATE TABLE cb_masking_rule ( + rule_id NUMBER PRIMARY KEY, + rule_code VARCHAR2(64) NOT NULL UNIQUE, + rule_name VARCHAR2(100) NOT NULL, + template_code VARCHAR2(30) NOT NULL, + description VARCHAR2(400), + enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL + )]'); + + ensure_table('CB_COLUMN_MASKING_RULE', q'[ + CREATE TABLE cb_column_masking_rule ( + column_id NUMBER PRIMARY KEY, + rule_id NUMBER NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL + )]'); + + ensure_table('CB_USER_MASKING_RULE', q'[ + CREATE TABLE cb_user_masking_rule ( + user_id NUMBER NOT NULL, + column_id NUMBER NOT NULL, + decision VARCHAR2(10) NOT NULL CHECK (decision IN ('MASK','UNMASK')), + active_yn CHAR(1) DEFAULT 'Y' CHECK (active_yn IN ('Y','N')) NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT cb_user_masking_rule_pk PRIMARY KEY (user_id, column_id) + )]'); +END; +/ + +PROMPT === Seeding curated masking templates === +DECLARE + v_next_rule_id NUMBER; + + PROCEDURE ensure_rule( + p_code IN VARCHAR2, + p_name IN VARCHAR2, + p_template IN VARCHAR2, + p_description IN VARCHAR2 + ) IS + BEGIN + MERGE INTO cb_masking_rule dst + USING ( + SELECT p_code rule_code, + p_name rule_name, + p_template template_code, + p_description description + FROM dual + ) src + ON (dst.rule_code = src.rule_code) + WHEN NOT MATCHED THEN + INSERT (rule_id, rule_code, rule_name, template_code, description, enabled_yn) + VALUES (v_next_rule_id, src.rule_code, src.rule_name, src.template_code, src.description, 'Y'); + v_next_rule_id := v_next_rule_id + 1; + END; +BEGIN + SELECT NVL(MAX(rule_id), 0) + 1 INTO v_next_rule_id FROM cb_masking_rule; + ensure_rule('MASK_NULLIFY', '값 숨김 (NULL)', 'NULLIFY', + '값을 NULL로 반환하는 기본 마스킹 방식'); + ensure_rule('MASK_FULL', '전체 마스킹', 'FULL', + '문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식'); + ensure_rule('MASK_TEXT_PARTIAL', '문자열 일부 마스킹', 'TEXT_PARTIAL', + '첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식'); + ensure_rule('MASK_RRN_PARTIAL', '주민등록번호 부분 마스킹', 'RRN_PARTIAL', + '앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식'); +END; +/ +COMMIT; + +PROMPT === Metadata ready === +SELECT rule_id, rule_code, rule_name, template_code, enabled_yn +FROM cb_masking_rule +ORDER BY rule_id; +EXIT diff --git a/sql/adb/63_kb_aso_masking_rule_runtime.sql b/sql/adb/63_kb_aso_masking_rule_runtime.sql new file mode 100644 index 0000000..6a9dd54 --- /dev/null +++ b/sql/adb/63_kb_aso_masking_rule_runtime.sql @@ -0,0 +1,679 @@ +-- ============================================================ +-- 63_kb_aso_masking_rule_runtime.sql +-- +-- Synchronizes CB_MASKING_RULE / CB_COLUMN_MASKING_RULE and +-- CB_USER_MASKING_RULE into Oracle Data Redaction policies. +-- +-- Important behaviour +-- * One selected masking rule is applied to each protected column. +-- * A direct user rule controls whether that user is masked or receives +-- an original-value exception (UNMASK). +-- * VPD still determines row visibility. Existing VPD NULL policies +-- remain in force for users without column permission. +-- +-- Run as ADMIN with SQLcl after 62 to initialize the runtime package, or as +-- a manual recovery/reconciliation action. The backoffice synchronizes +-- DBMS_REDACT on normal configuration changes; this script follows the same +-- source-of-truth rule for existing installations. +-- Requires EXECUTE on DBMS_REDACT and ADMINISTER REDACTION POLICY for POC_2 +-- objects. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET DEFINE OFF +SET FEEDBACK ON + +PROMPT === Validating masking-rule metadata === +DECLARE + PROCEDURE require_table(p_table_name IN VARCHAR2) IS + v_count NUMBER; + BEGIN + SELECT COUNT(*) + INTO v_count + FROM user_tables + WHERE table_name = p_table_name; + IF v_count = 0 THEN + RAISE_APPLICATION_ERROR(-20001, + 'Missing ' || p_table_name || '. Run 62_kb_aso_masking_backoffice_metadata.sql first.'); + END IF; + END; +BEGIN + require_table('CB_MASKING_RULE'); + require_table('CB_COLUMN_MASKING_RULE'); + require_table('CB_USER_MASKING_RULE'); +END; +/ + +PROMPT === Updating trusted bearer context with column exception flags === +CREATE OR REPLACE PACKAGE cb_agent_ctx_pkg AUTHID DEFINER AS + PROCEDURE clear_user; + PROCEDURE set_user(p_user_id IN NUMBER); + PROCEDURE set_user_values( + p_user_id IN NUMBER, + p_emp_no IN VARCHAR2, + p_dept_code IN VARCHAR2, + p_can_read_contents IN VARCHAR2 + ); + PROCEDURE set_user_by_bearer(p_bearer_key IN VARCHAR2); +END; +/ + +CREATE OR REPLACE PACKAGE BODY cb_agent_ctx_pkg AS + PROCEDURE clear_stakeholder_context AS + BEGIN + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID', NULL); + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_ROLE', NULL); + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL', NULL); + END; + + PROCEDURE clear_masking_rule_context AS + BEGIN + FOR r IN (SELECT column_id FROM cb_column_masking_rule) LOOP + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'MR_' || TO_CHAR(r.column_id), NULL); + END LOOP; + END; + + PROCEDURE set_masking_rule_context(p_user_id IN NUMBER) AS + BEGIN + clear_masking_rule_context; + FOR r IN ( + SELECT link.column_id, + CASE WHEN EXISTS ( + SELECT 1 + FROM cb_user_masking_rule user_rule + WHERE user_rule.user_id = p_user_id + AND user_rule.column_id = link.column_id + AND user_rule.active_yn = 'Y' + AND user_rule.decision = 'UNMASK' + ) THEN 'Y' ELSE 'N' END AS can_unmask + FROM cb_column_masking_rule link + JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id + WHERE rule.enabled_yn = 'Y' + ) LOOP + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'MR_' || TO_CHAR(r.column_id), r.can_unmask); + END LOOP; + END; + + PROCEDURE clear_user AS + BEGIN + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'USER_ID', NULL); + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'EMP_NO', NULL); + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE', NULL); + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'CAN_READ_CONTENTS', NULL); + clear_stakeholder_context; + clear_masking_rule_context; + END; + + PROCEDURE set_user_values( + p_user_id IN NUMBER, + p_emp_no IN VARCHAR2, + p_dept_code IN VARCHAR2, + p_can_read_contents IN VARCHAR2 + ) AS + BEGIN + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'USER_ID', TO_CHAR(p_user_id)); + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'EMP_NO', p_emp_no); + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE', p_dept_code); + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'CAN_READ_CONTENTS', p_can_read_contents); + clear_stakeholder_context; + clear_masking_rule_context; + END; + + PROCEDURE set_stakeholder_context( + p_stakeholder_user_id IN VARCHAR2, + p_role IN VARCHAR2, + p_channel IN VARCHAR2 + ) AS + v_channel VARCHAR2(50); + BEGIN + v_channel := CASE TRIM(p_channel) + WHEN '설계사채널' THEN '설계사' + WHEN 'GA채널' THEN 'GA' + ELSE TRIM(p_channel) + END; + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID', p_stakeholder_user_id); + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_ROLE', p_role); + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL', v_channel); + END; + + PROCEDURE set_user(p_user_id IN NUMBER) AS + v_emp_no cb_app_user.employee_no%TYPE; + v_dept_code cb_app_user.dept_code%TYPE; + v_can_read_contents cb_app_user.can_read_contents%TYPE; + BEGIN + SELECT employee_no, + dept_code, + CASE + WHEN EXISTS ( + SELECT 1 + FROM cb_user_role ur + JOIN cb_permission p ON p.role_id = ur.role_id + JOIN cb_permission_column pc ON pc.permission_id = p.perm_id + WHERE ur.user_id = p_user_id + AND p.target_name = 'CB_V_SEARCH_DOCUMENTS' + AND p.action_name = 'SELECT' + AND pc.column_name = 'CONTENTS' + ) THEN 'Y' + ELSE 'N' + END + INTO v_emp_no, v_dept_code, v_can_read_contents + FROM cb_app_user + WHERE user_id = p_user_id + AND active = 'Y'; + + set_user_values(p_user_id, v_emp_no, v_dept_code, v_can_read_contents); + set_masking_rule_context(p_user_id); + EXCEPTION + WHEN NO_DATA_FOUND THEN + clear_user; + RAISE_APPLICATION_ERROR(-20003, 'Mapped application user not found'); + END; + + PROCEDURE set_user_by_bearer(p_bearer_key IN VARCHAR2) AS + v_user_id cb_app_user.user_id%TYPE; + v_emp_no cb_app_user.employee_no%TYPE; + v_dept_code cb_app_user.dept_code%TYPE; + v_can_read_contents cb_app_user.can_read_contents%TYPE; + v_stakeholder_user_id VARCHAR2(30); + v_stakeholder_role VARCHAR2(50); + v_stakeholder_channel VARCHAR2(50); + BEGIN + IF p_bearer_key IS NULL THEN + clear_user; + RAISE_APPLICATION_ERROR(-20001, 'Authorization Bearer key is required'); + END IF; + + SELECT u.user_id, + u.employee_no, + u.dept_code, + CASE + WHEN EXISTS ( + SELECT 1 + FROM cb_user_role ur + JOIN cb_permission p ON p.role_id = ur.role_id + JOIN cb_permission_column pc ON pc.permission_id = p.perm_id + WHERE ur.user_id = u.user_id + AND p.target_name = 'CB_V_SEARCH_DOCUMENTS' + AND p.action_name = 'SELECT' + AND pc.column_name = 'CONTENTS' + ) THEN 'Y' + ELSE 'N' + END, + COALESCE(k.stakeholder_user_id, u.stakeholder_user_id) + INTO v_user_id, v_emp_no, v_dept_code, v_can_read_contents, v_stakeholder_user_id + FROM cb_agent_bearer_key k + JOIN cb_app_user u ON u.user_id = k.user_id + WHERE k.key_hash = STANDARD_HASH(p_bearer_key, 'SHA256') + AND k.active = 'Y' + AND k.revoked_at IS NULL + AND (k.expires_at IS NULL OR k.expires_at > SYSDATE) + AND u.active = 'Y'; + + set_user_values(v_user_id, v_emp_no, v_dept_code, v_can_read_contents); + + IF v_stakeholder_user_id IS NOT NULL THEN + DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID', v_stakeholder_user_id); + SELECT role, channel + INTO v_stakeholder_role, v_stakeholder_channel + FROM poc_2.kb_stakeholders + WHERE user_id = v_stakeholder_user_id; + set_stakeholder_context(v_stakeholder_user_id, v_stakeholder_role, v_stakeholder_channel); + END IF; + + set_masking_rule_context(v_user_id); + EXCEPTION + WHEN NO_DATA_FOUND THEN + clear_user; + RAISE_APPLICATION_ERROR(-20002, 'Invalid or expired Bearer key'); + END; +END; +/ +SHOW ERRORS PACKAGE BODY cb_agent_ctx_pkg + +GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords; + +PROMPT === Preserving the all-access admin raw-display contract === +MERGE INTO cb_user_masking_rule dst +USING ( + SELECT user_row.user_id, column_rule.column_id + FROM cb_app_user user_row + CROSS JOIN cb_column_masking_rule column_rule + JOIN cb_masking_rule rule ON rule.rule_id = column_rule.rule_id + WHERE user_row.stakeholder_user_id = 'KB_VPD_ADMIN' + AND user_row.active = 'Y' + AND rule.enabled_yn = 'Y' +) src +ON (dst.user_id = src.user_id AND dst.column_id = src.column_id) +WHEN NOT MATCHED THEN + INSERT (user_id, column_id, decision, active_yn, updated_at) + VALUES (src.user_id, src.column_id, 'UNMASK', 'Y', SYSTIMESTAMP); +COMMIT; + +PROMPT === Removing stale columns and disabling unconfigured KB policies === +-- A policy definition is deliberately retained when no column is configured. +-- DISABLE_POLICY makes the effective state match the empty backoffice setting +-- and lets a later column assignment re-enable the same named policy safely. +DECLARE + PROCEDURE reconcile_existing_policy( + p_object_name IN VARCHAR2, + p_policy_name IN VARCHAR2 + ) IS + v_policy_count NUMBER; + v_configured_count NUMBER; + BEGIN + SELECT COUNT(*) INTO v_policy_count + FROM redaction_policies + WHERE object_owner = 'POC_2' AND object_name = p_object_name AND policy_name = p_policy_name; + IF v_policy_count = 0 THEN + RETURN; + END IF; + + SELECT COUNT(*) INTO v_configured_count + FROM cb_column_masking_rule link + JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id + JOIN cb_protected_column column_row ON column_row.column_id = link.column_id + JOIN cb_protected_object object_row ON object_row.object_id = column_row.object_id + WHERE object_row.owner = 'POC_2' + AND object_row.object_name = p_object_name + AND rule.enabled_yn = 'Y'; + + IF v_configured_count = 0 THEN + DBMS_REDACT.DISABLE_POLICY('POC_2', p_object_name, p_policy_name); + RETURN; + END IF; + + FOR stale_column IN ( + SELECT policy_column.column_name + FROM redaction_columns policy_column + WHERE policy_column.object_owner = 'POC_2' + AND policy_column.object_name = p_object_name + AND NOT EXISTS ( + SELECT 1 + FROM cb_column_masking_rule link + JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id + JOIN cb_protected_column column_row ON column_row.column_id = link.column_id + JOIN cb_protected_object object_row ON object_row.object_id = column_row.object_id + WHERE object_row.owner = 'POC_2' + AND object_row.object_name = p_object_name + AND column_row.column_name = policy_column.column_name + AND rule.enabled_yn = 'Y' + ) + ) LOOP + DBMS_REDACT.ALTER_POLICY( + object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name, + action => DBMS_REDACT.DROP_COLUMN, column_name => stale_column.column_name + ); + END LOOP; + END; +BEGIN + reconcile_existing_policy('KB_CUSTOMERS', 'KB_CUSTOMER_PII_REDACT'); + reconcile_existing_policy('KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT'); + reconcile_existing_policy('KB_CONTRACTS', 'KB_CONTRACT_PREMIUM_REDACT'); + reconcile_existing_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT'); +END; +/ + +PROMPT === Integrating configured columns into the existing KB Data Redaction policies === +DECLARE + v_policy_name VARCHAR2(128); + v_expression_name VARCHAR2(128); + v_expression VARCHAR2(4000); + + PROCEDURE require_existing_policy( + p_object_name IN VARCHAR2, + p_policy_name IN VARCHAR2 + ) IS + v_count NUMBER; + BEGIN + SELECT COUNT(*) + INTO v_count + FROM redaction_policies + WHERE object_owner = 'POC_2' + AND object_name = p_object_name + AND policy_name = p_policy_name; + IF v_count = 0 THEN + RAISE_APPLICATION_ERROR(-20012, + 'Existing Data Redaction policy not found: POC_2.' || p_object_name || '.' || p_policy_name); + END IF; + END; + + PROCEDURE upsert_expression( + p_expression_name IN VARCHAR2, + p_expression IN VARCHAR2 + ) IS + v_count NUMBER; + BEGIN + SELECT COUNT(*) + INTO v_count + FROM redaction_expressions + WHERE policy_expression_name = p_expression_name; + IF v_count = 0 THEN + DBMS_REDACT.CREATE_POLICY_EXPRESSION( + policy_expression_name => p_expression_name, + expression => p_expression, + policy_expression_description => 'Mask unless trusted bearer context grants an original-value exception' + ); + ELSE + DBMS_REDACT.UPDATE_POLICY_EXPRESSION( + policy_expression_name => p_expression_name, + expression => p_expression, + policy_expression_description => 'Mask unless trusted bearer context grants an original-value exception' + ); + END IF; + END; + + PROCEDURE modify_column( + p_object_name IN VARCHAR2, + p_policy_name IN VARCHAR2, + p_column_name IN VARCHAR2, + p_template_code IN VARCHAR2 + ) IS + PROCEDURE apply( + p_function_type IN BINARY_INTEGER, + p_pattern IN VARCHAR2 DEFAULT NULL, + p_replace IN VARCHAR2 DEFAULT NULL + ) IS + BEGIN + DBMS_REDACT.ALTER_POLICY( + object_schema => 'POC_2', + object_name => p_object_name, + policy_name => p_policy_name, + action => DBMS_REDACT.MODIFY_COLUMN, + column_name => p_column_name, + function_type => p_function_type, + regexp_pattern => p_pattern, + regexp_replace_string => p_replace + ); + END; + BEGIN + CASE p_template_code + WHEN 'NULLIFY' THEN + apply(DBMS_REDACT.NULLIFY); + WHEN 'FULL' THEN + apply(DBMS_REDACT.FULL); + WHEN 'TEXT_PARTIAL' THEN + apply(DBMS_REDACT.REGEXP, '(^.).*$', '\1***'); + WHEN 'RRN_PARTIAL' THEN + apply(DBMS_REDACT.REGEXP, '(^[0-9]{6})-?[0-9]{7}$', '\1-*******'); + ELSE + RAISE_APPLICATION_ERROR(-20013, 'Unsupported masking template: ' || p_template_code); + END CASE; + END; + +BEGIN + FOR configured_column IN ( + SELECT protected_object.object_name, + protected_column.column_id, + protected_column.column_name, + masking_rule.template_code + FROM cb_column_masking_rule link + JOIN cb_masking_rule masking_rule ON masking_rule.rule_id = link.rule_id + JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id + JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id + WHERE protected_object.owner = 'POC_2' + AND masking_rule.enabled_yn = 'Y' + ORDER BY protected_object.object_name, protected_column.column_id + ) LOOP + v_policy_name := CASE configured_column.object_name + WHEN 'KB_CUSTOMERS' THEN 'KB_CUSTOMER_PII_REDACT' + WHEN 'KB_CLAIMS' THEN 'KB_CLAIM_AMOUNT_REDACT' + WHEN 'KB_CONTRACTS' THEN 'KB_CONTRACT_PREMIUM_REDACT' + WHEN 'KB_EXTERNAL_HOLDINGS' THEN 'KB_EXT_HOLDING_REDACT' + ELSE NULL + END; + IF v_policy_name IS NULL THEN + RAISE_APPLICATION_ERROR(-20014, + 'No existing KB Data Redaction policy mapping for ' || configured_column.object_name); + END IF; + + require_existing_policy(configured_column.object_name, v_policy_name); + DBMS_REDACT.ENABLE_POLICY('POC_2', configured_column.object_name, v_policy_name); + modify_column( + configured_column.object_name, + v_policy_name, + configured_column.column_name, + configured_column.template_code + ); + + v_expression_name := 'CBMR_' || TO_CHAR(configured_column.column_id); + v_expression := 'SYS_CONTEXT(''CB_AGENT_CTX'', ''MR_' || TO_CHAR(configured_column.column_id) + || ''') IS NULL OR SYS_CONTEXT(''CB_AGENT_CTX'', ''MR_' || TO_CHAR(configured_column.column_id) || ''') <> ''Y'''; + upsert_expression(v_expression_name, v_expression); + DBMS_REDACT.APPLY_POLICY_EXPR_TO_COL( + object_schema => 'POC_2', + object_name => configured_column.object_name, + column_name => configured_column.column_name, + policy_expression_name => v_expression_name + ); + END LOOP; +END; +/ +COMMIT; + +PROMPT === Verification === +SELECT object_name, policy_name, expression +FROM redaction_policies +WHERE object_owner = 'POC_2' + AND object_name IN ('KB_CUSTOMERS', 'KB_CLAIMS', 'KB_CONTRACTS', 'KB_EXTERNAL_HOLDINGS') +ORDER BY object_name, policy_name; + +SELECT object_name, column_name, function_type +FROM redaction_columns +WHERE object_owner = 'POC_2' + AND object_name IN ('KB_CUSTOMERS', 'KB_CLAIMS', 'KB_CONTRACTS', 'KB_EXTERNAL_HOLDINGS') +ORDER BY object_name, column_name; + +SELECT policy_expression_name, object_name, column_name, expression +FROM redaction_expressions +WHERE policy_expression_name LIKE 'CBMR_%' +ORDER BY policy_expression_name; + +SELECT user_row.user_name, + protected_object.object_name, + protected_column.column_name, + user_rule.decision +FROM cb_user_masking_rule user_rule +JOIN cb_app_user user_row ON user_row.user_id = user_rule.user_id +JOIN cb_protected_column protected_column ON protected_column.column_id = user_rule.column_id +JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id +ORDER BY user_row.user_name, protected_object.object_name, protected_column.column_name; +EXIT + +-- Legacy replacement implementation retained below for migration reference. +-- SQLcl exits above, so it never executes. +PROMPT === Rebuilding managed Oracle Data Redaction policies === +DECLARE + v_policy_name VARCHAR2(128); + v_expression_name VARCHAR2(128); + v_first BOOLEAN; + + PROCEDURE apply_column( + p_first IN BOOLEAN, + p_object_name IN VARCHAR2, + p_policy_name IN VARCHAR2, + p_column_name IN VARCHAR2, + p_template_code IN VARCHAR2 + ) IS + PROCEDURE first_policy( + p_function_type IN BINARY_INTEGER, + p_pattern IN VARCHAR2 DEFAULT NULL, + p_replace IN VARCHAR2 DEFAULT NULL + ) IS + BEGIN + DBMS_REDACT.ADD_POLICY( + object_schema => 'POC_2', + object_name => p_object_name, + policy_name => p_policy_name, + policy_description => 'Managed by CB masking rule backoffice', + column_name => p_column_name, + function_type => p_function_type, + expression => '1=1', + regexp_pattern => p_pattern, + regexp_replace_string => p_replace, + enable => TRUE + ); + END; + + PROCEDURE additional_column( + p_function_type IN BINARY_INTEGER, + p_pattern IN VARCHAR2 DEFAULT NULL, + p_replace IN VARCHAR2 DEFAULT NULL + ) IS + BEGIN + DBMS_REDACT.ALTER_POLICY( + object_schema => 'POC_2', + object_name => p_object_name, + policy_name => p_policy_name, + action => DBMS_REDACT.ADD_COLUMN, + column_name => p_column_name, + function_type => p_function_type, + regexp_pattern => p_pattern, + regexp_replace_string => p_replace + ); + END; + + PROCEDURE invoke( + p_function_type IN BINARY_INTEGER, + p_pattern IN VARCHAR2 DEFAULT NULL, + p_replace IN VARCHAR2 DEFAULT NULL + ) IS + BEGIN + IF p_first THEN + first_policy(p_function_type, p_pattern, p_replace); + ELSE + additional_column(p_function_type, p_pattern, p_replace); + END IF; + END; + BEGIN + CASE p_template_code + WHEN 'NULLIFY' THEN + invoke(DBMS_REDACT.NULLIFY); + WHEN 'FULL' THEN + invoke(DBMS_REDACT.FULL); + WHEN 'TEXT_PARTIAL' THEN + invoke(DBMS_REDACT.REGEXP, '(^.).*$', '\1***'); + WHEN 'RRN_PARTIAL' THEN + invoke(DBMS_REDACT.REGEXP, '(^[0-9]{6})-?[0-9]{7}$', '\1-*******'); + ELSE + RAISE_APPLICATION_ERROR(-20011, 'Unsupported template: ' || p_template_code); + END CASE; + END; + + PROCEDURE drop_managed_policy(p_object_name IN VARCHAR2, p_policy_name IN VARCHAR2) IS + BEGIN + DBMS_REDACT.DROP_POLICY( + object_schema => 'POC_2', + object_name => p_object_name, + policy_name => p_policy_name + ); + EXCEPTION + WHEN OTHERS THEN + NULL; + END; + + PROCEDURE drop_expression(p_expression_name IN VARCHAR2) IS + BEGIN + DBMS_REDACT.DROP_POLICY_EXPRESSION(p_expression_name); + EXCEPTION + WHEN OTHERS THEN + NULL; + END; + +BEGIN + -- These three policies are legacy test policies found on the KB objects. + -- Data Redaction permits only one policy per object, so replace them with + -- the managed policy rather than leaving their client-identifier tests live. + drop_managed_policy('KB_CUSTOMERS', 'KB_CUSTOMER_PII_REDACT'); + drop_managed_policy('KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT'); + drop_managed_policy('KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT'); + + FOR object_row IN ( + SELECT DISTINCT po.object_name + FROM cb_column_masking_rule link + JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id + JOIN cb_protected_column pc ON pc.column_id = link.column_id + JOIN cb_protected_object po ON po.object_id = pc.object_id + WHERE rule.enabled_yn = 'Y' + AND po.owner = 'POC_2' + ) LOOP + v_policy_name := 'CB_ASO_' || object_row.object_name || '_MASK'; + drop_managed_policy(object_row.object_name, v_policy_name); + END LOOP; + + FOR column_row IN ( + SELECT link.column_id + FROM cb_column_masking_rule link + ) LOOP + drop_expression('CBMR_' || TO_CHAR(column_row.column_id)); + END LOOP; + + FOR object_row IN ( + SELECT DISTINCT po.object_name + FROM cb_column_masking_rule link + JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id + JOIN cb_protected_column pc ON pc.column_id = link.column_id + JOIN cb_protected_object po ON po.object_id = pc.object_id + WHERE rule.enabled_yn = 'Y' + AND po.owner = 'POC_2' + ORDER BY po.object_name + ) LOOP + v_policy_name := 'CB_ASO_' || object_row.object_name || '_MASK'; + v_first := TRUE; + FOR column_row IN ( + SELECT pc.column_id, + pc.column_name, + rule.template_code, + rule.rule_name + FROM cb_column_masking_rule link + JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id + JOIN cb_protected_column pc ON pc.column_id = link.column_id + JOIN cb_protected_object po ON po.object_id = pc.object_id + WHERE po.owner = 'POC_2' + AND po.object_name = object_row.object_name + AND rule.enabled_yn = 'Y' + ORDER BY pc.column_id + ) LOOP + apply_column(v_first, object_row.object_name, v_policy_name, + column_row.column_name, column_row.template_code); + v_expression_name := 'CBMR_' || TO_CHAR(column_row.column_id); + DBMS_REDACT.CREATE_POLICY_EXPRESSION( + policy_expression_name => v_expression_name, + expression => 'SYS_CONTEXT(''CB_AGENT_CTX'', ''MR_' || TO_CHAR(column_row.column_id) + || ''') IS NULL OR SYS_CONTEXT(''CB_AGENT_CTX'', ''MR_' || TO_CHAR(column_row.column_id) || ''') <> ''Y''', + policy_expression_description => 'Mask unless trusted bearer context grants an original-value exception' + ); + DBMS_REDACT.APPLY_POLICY_EXPR_TO_COL( + object_schema => 'POC_2', + object_name => object_row.object_name, + column_name => column_row.column_name, + policy_expression_name => v_expression_name + ); + v_first := FALSE; + END LOOP; + END LOOP; +END; +/ +COMMIT; + +PROMPT === Verification === +SELECT object_name, policy_name, expression +FROM redaction_policies +WHERE object_owner = 'POC_2' + AND policy_name LIKE 'CB_ASO_%' +ORDER BY object_name, policy_name; + +SELECT object_name, column_name, function_type +FROM redaction_columns +WHERE object_owner = 'POC_2' + AND object_name IN ('KB_CUSTOMERS', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS') +ORDER BY object_name, column_name; + +SELECT user_row.user_name, + protected_object.object_name, + protected_column.column_name, + user_rule.decision +FROM cb_user_masking_rule user_rule +JOIN cb_app_user user_row ON user_row.user_id = user_rule.user_id +JOIN cb_protected_column protected_column ON protected_column.column_id = user_rule.column_id +JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id +ORDER BY user_row.user_name, protected_object.object_name, protected_column.column_name; +EXIT diff --git a/sql/adb/64_kb_aso_masking_default_column_rules.sql b/sql/adb/64_kb_aso_masking_default_column_rules.sql new file mode 100644 index 0000000..a609939 --- /dev/null +++ b/sql/adb/64_kb_aso_masking_default_column_rules.sql @@ -0,0 +1,54 @@ +-- ============================================================ +-- 64_kb_aso_masking_default_column_rules.sql +-- +-- Initial blacklist of KB columns subject to ASO masking. +-- Run as ADMIN after 62. Execute 63 afterwards to apply the +-- configured rules as DBMS_REDACT policies. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET DEFINE OFF +SET FEEDBACK ON + +PROMPT === Assigning the KB masking target blacklist === +MERGE INTO cb_column_masking_rule dst +USING ( + SELECT protected_column.column_id, + masking_rule.rule_id + FROM cb_protected_column protected_column + JOIN cb_protected_object protected_object + ON protected_object.object_id = protected_column.object_id + JOIN cb_masking_rule masking_rule + ON masking_rule.rule_code = CASE + WHEN protected_object.object_name = 'KB_CUSTOMERS' + AND protected_column.column_name = 'RRN_MASKED' + THEN 'MASK_RRN_PARTIAL' + ELSE 'MASK_NULLIFY' + END + WHERE (protected_object.object_name = 'KB_CUSTOMERS' + AND protected_column.column_name IN ('RRN_MASKED')) + OR (protected_object.object_name = 'KB_CLAIMS' + AND protected_column.column_name IN ('CLAIM_AMT', 'PAID_AMT')) + OR (protected_object.object_name = 'KB_EXTERNAL_HOLDINGS' + AND protected_column.column_name IN ('EXT_INSURER', 'EXT_PRODUCT_GRP', 'EXT_PRODUCT_TYPE')) +) src +ON (dst.column_id = src.column_id) +WHEN MATCHED THEN + UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP +WHEN NOT MATCHED THEN + INSERT (column_id, rule_id, updated_at) + VALUES (src.column_id, src.rule_id, SYSTIMESTAMP); +COMMIT; + +PROMPT === Configured masking target blacklist === +SELECT protected_object.owner, + protected_object.object_name, + protected_column.column_name, + masking_rule.rule_code, + masking_rule.rule_name, + masking_rule.template_code +FROM cb_column_masking_rule link +JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id +JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id +JOIN cb_masking_rule masking_rule ON masking_rule.rule_id = link.rule_id +ORDER BY protected_object.object_name, protected_column.column_name; +EXIT diff --git a/sql/adb/65_kb_select_ai_vpd_query_api.sql b/sql/adb/65_kb_select_ai_vpd_query_api.sql new file mode 100644 index 0000000..9d9a2f9 --- /dev/null +++ b/sql/adb/65_kb_select_ai_vpd_query_api.sql @@ -0,0 +1,221 @@ +-- ============================================================ +-- 65_kb_select_ai_vpd_query_api.sql +-- +-- Natural-language KB query API backing package. +-- Run as POC_2. The caller (CB_ORDS) establishes CB_AGENT_CTX before +-- invoking this package, so generated SQL is still subject to POC_2 VPD. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET DEFINE OFF +SET FEEDBACK ON + +PROMPT === Annotating KB schema for Select AI === +COMMENT ON TABLE poc_2.kb_customers IS '고객원장. CUST_ID는 C로 시작하는 고객 식별자 예: C1001006.'; +COMMENT ON COLUMN poc_2.kb_customers.cust_id IS '고객 식별자. C로 시작하는 값 예: C1001006.'; + +COMMENT ON TABLE poc_2.kb_products IS '상품원장 및 약관상품 마스터. PRODUCT_CD는 숫자형 상품/약관 코드 예: 41048.'; +COMMENT ON COLUMN poc_2.kb_products.product_cd IS '상품 또는 약관상품 코드. 숫자형 코드 예: 41048. KB_CONTRACTS.PRODUCT_CD와 조인한다.'; +COMMENT ON COLUMN poc_2.kb_products.clause_product_nm IS '약관상품명 또는 상품명. 고객 설명용 상품 비교에 사용한다.'; +COMMENT ON COLUMN poc_2.kb_products.insurance_type IS '보험 대분류. 예: 자동차, 장기, 일반.'; +COMMENT ON COLUMN poc_2.kb_products.product_type IS '상품 유형 또는 세부 상품군.'; +COMMENT ON COLUMN poc_2.kb_products.clause_version IS '약관 버전.'; +COMMENT ON COLUMN poc_2.kb_products.sale_status IS '판매 상태.'; +COMMENT ON COLUMN poc_2.kb_products.active_yn IS '상품 활성 여부.'; + +COMMENT ON TABLE poc_2.kb_contracts IS '계약원장. CONTRACT_NO는 CT로 시작하는 계약번호이고, PRODUCT_CD는 숫자형 상품/약관 코드다.'; +COMMENT ON COLUMN poc_2.kb_contracts.contract_no IS '계약번호. CT로 시작하는 값 예: CT2699001. 41048 같은 순수 숫자 코드는 계약번호가 아니라 PRODUCT_CD일 가능성이 높다.'; +COMMENT ON COLUMN poc_2.kb_contracts.cust_id IS '계약 고객 식별자. KB_CUSTOMERS.CUST_ID와 조인한다.'; +COMMENT ON COLUMN poc_2.kb_contracts.product_cd IS '계약의 상품/약관 코드. 숫자형 코드 예: 41048. KB_PRODUCTS.PRODUCT_CD와 조인한다.'; +COMMENT ON COLUMN poc_2.kb_contracts.fc_id IS '담당 설계사 또는 FC 사용자 ID. 예: FC00789.'; +COMMENT ON COLUMN poc_2.kb_contracts.fc_channel IS '담당 채널. 예: 설계사, GA, 다이렉트.'; +COMMENT ON COLUMN poc_2.kb_contracts.contract_status IS '계약 상태. 예: 정상, 실효.'; +COMMENT ON COLUMN poc_2.kb_contracts.premium IS '계약 보험료.'; +COMMENT ON COLUMN poc_2.kb_contracts.pay_cycle IS '보험료 납입 주기.'; + +COMMENT ON TABLE poc_2.kb_coverages IS '담보원장. 계약번호 CONTRACT_NO 기준으로 KB_CONTRACTS와 조인해 보장/특약을 설명한다.'; +COMMENT ON COLUMN poc_2.kb_coverages.contract_no IS '담보가 속한 계약번호. KB_CONTRACTS.CONTRACT_NO와 조인한다.'; +COMMENT ON COLUMN poc_2.kb_coverages.cust_id IS '담보 고객 식별자.'; +COMMENT ON COLUMN poc_2.kb_coverages.coverage_nm IS '담보명 또는 특약명.'; +COMMENT ON COLUMN poc_2.kb_coverages.coverage_type IS '담보 유형.'; +COMMENT ON COLUMN poc_2.kb_coverages.coverage_div IS '담보 구분.'; +COMMENT ON COLUMN poc_2.kb_coverages.insured_amt IS '가입금액 또는 보장금액.'; +COMMENT ON COLUMN poc_2.kb_coverages.renew_due_dt IS '갱신 예정일.'; + +COMMENT ON TABLE poc_2.kb_external_holdings IS '외부보유정보 원장. 타 보험사 보유계약과 약관상품을 고객 CUST_ID 기준으로 비교한다.'; +COMMENT ON COLUMN poc_2.kb_external_holdings.cust_id IS '외부보유정보 고객 식별자. KB_CUSTOMERS.CUST_ID와 조인한다.'; +COMMENT ON COLUMN poc_2.kb_external_holdings.ext_insurer IS '외부 보험사명. 예: 삼성화재.'; +COMMENT ON COLUMN poc_2.kb_external_holdings.ext_product_grp IS '외부 상품군. 예: 자동차.'; +COMMENT ON COLUMN poc_2.kb_external_holdings.ext_product_type IS '외부 상품 유형. 예: 개인용.'; +COMMENT ON COLUMN poc_2.kb_external_holdings.ext_clause_nm IS '외부 약관상품명.'; +COMMENT ON COLUMN poc_2.kb_external_holdings.ext_sale_status IS '외부 상품 판매 상태.'; +COMMENT ON COLUMN poc_2.kb_external_holdings.ext_renew_month IS '외부 보유계약 갱신월.'; + +PROMPT === Creating VPD-aware Select AI query API === +CREATE OR REPLACE PACKAGE poc_2.kb_select_ai_vpd_query_api AUTHID DEFINER AS + PROCEDURE open_query( + p_prompt IN CLOB, + p_limit IN PLS_INTEGER, + p_rows OUT SYS_REFCURSOR, + p_generated_sql OUT CLOB + ); +END kb_select_ai_vpd_query_api; +/ + +CREATE OR REPLACE PACKAGE BODY poc_2.kb_select_ai_vpd_query_api AS + c_profile_name CONSTANT VARCHAR2(128) := 'KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1'; + c_max_prompt_length CONSTANT PLS_INTEGER := 4000; + c_max_rows CONSTANT PLS_INTEGER := 100; + + FUNCTION normalized_sql(p_generated_sql IN CLOB) RETURN VARCHAR2 IS + v_sql VARCHAR2(32767); + v_comment_end PLS_INTEGER; + BEGIN + IF p_generated_sql IS NULL OR DBMS_LOB.GETLENGTH(p_generated_sql) = 0 THEN + RAISE_APPLICATION_ERROR(-20811, 'Select AI did not generate SQL.'); + END IF; + IF DBMS_LOB.GETLENGTH(p_generated_sql) > 32767 THEN + RAISE_APPLICATION_ERROR(-20812, 'Generated SQL is too long.'); + END IF; + + v_sql := TRIM(DBMS_LOB.SUBSTR(p_generated_sql, 32767, 1)); + + -- Select AI sometimes wraps an otherwise valid SQL statement in markdown + -- fences or adds a short leading explanation comment. Strip only those + -- wrapper artifacts before the strict safety validator runs. Comments that + -- remain inside the executable statement are still rejected below. + LOOP + v_sql := TRIM(v_sql); + v_sql := REGEXP_REPLACE(v_sql, '^[[:space:]]*```[[:alpha:]]*[[:space:]]*', ''); + v_sql := REGEXP_REPLACE(v_sql, '[[:space:]]*```[[:space:]]*$', ''); + v_sql := TRIM(v_sql); + + IF SUBSTR(v_sql, 1, 2) = '--' THEN + v_comment_end := INSTR(v_sql, CHR(10)); + IF v_comment_end = 0 THEN + RAISE_APPLICATION_ERROR(-20813, 'Select AI returned only a comment, not SQL.'); + END IF; + v_sql := SUBSTR(v_sql, v_comment_end + 1); + ELSIF SUBSTR(v_sql, 1, 2) = '/*' THEN + v_comment_end := INSTR(v_sql, '*/'); + IF v_comment_end = 0 THEN + RAISE_APPLICATION_ERROR(-20814, 'Unclosed generated SQL comment is not allowed.'); + END IF; + v_sql := SUBSTR(v_sql, v_comment_end + 2); + ELSE + EXIT; + END IF; + END LOOP; + + v_sql := RTRIM(TRIM(v_sql), ';'); + RETURN TRIM(v_sql); + END normalized_sql; + + PROCEDURE assert_safe_kb_select(p_sql IN VARCHAR2) IS + -- Select AI normally double-quotes Oracle identifiers. Validate a quote-free + -- copy so POC_2.KB_CLAIMS and "POC_2"."KB_CLAIMS" follow the same policy. + v_upper VARCHAR2(32767) := REPLACE(UPPER(p_sql), '"', ''); + -- Mask string literals before checking semicolons, keywords, packages, and + -- data dictionary names. A harmless CASE output string should not be treated + -- as executable SQL syntax. + v_lexical VARCHAR2(32767) := + REGEXP_REPLACE(REPLACE(UPPER(p_sql), '"', ''), '''(''''|[^''])*''', '''X'''); + BEGIN + IF NOT REGEXP_LIKE(v_lexical, '^(SELECT|WITH)[[:space:]]') THEN + RAISE_APPLICATION_ERROR( + -20813, + 'Only a single SELECT or WITH query is allowed. Generated prefix: ' + || SUBSTR(v_upper, 1, 160) + ); + END IF; + IF INSTR(v_lexical, ';') > 0 + OR INSTR(v_lexical, '--') > 0 + OR INSTR(v_lexical, '/*') > 0 + OR INSTR(v_lexical, '*/') > 0 THEN + RAISE_APPLICATION_ERROR( + -20814, + 'Comments or multiple SQL statements remain after normalization. Generated prefix: ' + || SUBSTR(v_upper, 1, 160) + ); + END IF; + IF REGEXP_LIKE( + v_lexical, + '(^|[^A-Z_])(ALTER|BEGIN|COMMIT|CREATE|DECLARE|DELETE|DROP|EXECUTE|GRANT|INSERT|MERGE|REVOKE|ROLLBACK|TRUNCATE|UPDATE)([^A-Z_]|$)' + ) THEN + RAISE_APPLICATION_ERROR(-20815, 'Only read-only SQL is allowed.'); + END IF; + IF INSTR(v_lexical, 'DBMS_') > 0 + OR INSTR(v_lexical, 'UTL_') > 0 + OR INSTR(v_lexical, 'SYS.') > 0 + OR INSTR(v_lexical, 'ADMIN.') > 0 + OR INSTR(v_lexical, 'CB_') > 0 + OR INSTR(v_lexical, 'ALL_') > 0 + OR INSTR(v_lexical, 'DBA_') > 0 + OR INSTR(v_lexical, 'USER_') > 0 + OR INSTR(v_lexical, 'FOR UPDATE') > 0 THEN + RAISE_APPLICATION_ERROR(-20816, 'System, backoffice, and locking objects are not allowed.'); + END IF; + IF NOT REGEXP_LIKE( + v_lexical, + '(FROM|JOIN)[[:space:]]+(POC_2[.])?KB_(CUSTOMERS|PRODUCTS|CONTRACTS|COVERAGES|CLAIMS|EXTERNAL_HOLDINGS|STAKEHOLDERS)([[:space:],)]|$)' + ) THEN + RAISE_APPLICATION_ERROR(-20817, 'The generated SQL must query a permitted KB business table.'); + END IF; + END assert_safe_kb_select; + + PROCEDURE open_query( + p_prompt IN CLOB, + p_limit IN PLS_INTEGER, + p_rows OUT SYS_REFCURSOR, + p_generated_sql OUT CLOB + ) IS + v_prompt VARCHAR2(4000); + v_generation_prompt CLOB; + v_generated_sql CLOB; + v_sql VARCHAR2(32767); + v_limit PLS_INTEGER; + BEGIN + IF p_prompt IS NULL OR DBMS_LOB.GETLENGTH(TRIM(p_prompt)) = 0 THEN + RAISE_APPLICATION_ERROR(-20801, 'prompt is required.'); + END IF; + IF DBMS_LOB.GETLENGTH(p_prompt) > c_max_prompt_length THEN + RAISE_APPLICATION_ERROR(-20802, 'prompt must be 4,000 characters or fewer.'); + END IF; + + v_prompt := TRIM(DBMS_LOB.SUBSTR(p_prompt, c_max_prompt_length, 1)); + IF REGEXP_LIKE(UPPER(v_prompt), 'SELECT[[:space:]]+AI') THEN + RAISE_APPLICATION_ERROR(-20803, 'SELECT AI action prefixes are not allowed in prompt.'); + END IF; + v_limit := LEAST(GREATEST(NVL(p_limit, 50), 1), c_max_rows); + + v_generation_prompt := + 'Return exactly one Oracle SQL SELECT or WITH statement and no markdown. ' + || 'Use only these POC_2 business tables: KB_CUSTOMERS, KB_PRODUCTS, KB_CONTRACTS, ' + || 'KB_COVERAGES, KB_CLAIMS, KB_EXTERNAL_HOLDINGS, KB_STAKEHOLDERS. ' + || 'Never use DDL, DML, PL/SQL, system views, backoffice tables, comments, or FOR UPDATE. ' + || 'The caller request is: ' || v_prompt; + + v_generated_sql := DBMS_CLOUD_AI.GENERATE( + prompt => v_generation_prompt, + profile_name => c_profile_name, + action => 'showsql' + ); + v_sql := normalized_sql(v_generated_sql); + assert_safe_kb_select(v_sql); + + -- CB_AGENT_CTX was set by CB_ORDS before this call. Parsing this statement + -- in the same database session makes Oracle evaluate POC_2 VPD policies. + OPEN p_rows FOR + 'SELECT * FROM (' || v_sql || ') WHERE ROWNUM <= :row_limit' + USING v_limit; + p_generated_sql := v_sql; + END open_query; +END kb_select_ai_vpd_query_api; +/ + +SHOW ERRORS + +PROMPT === Granting fixed query API to CB_ORDS === +GRANT EXECUTE ON poc_2.kb_select_ai_vpd_query_api TO cb_ords; + +PROMPT === POC_2 Select AI VPD query API ready === +EXIT diff --git a/sql/adb/66_kb_select_ai_vpd_query_ords.sql b/sql/adb/66_kb_select_ai_vpd_query_ords.sql new file mode 100644 index 0000000..1a5c776 --- /dev/null +++ b/sql/adb/66_kb_select_ai_vpd_query_ords.sql @@ -0,0 +1,149 @@ +-- ============================================================ +-- 66_kb_select_ai_vpd_query_ords.sql +-- +-- POST /ords/cb-ords/kb-select-ai-vpd/query +-- Authorization: Bearer +-- {"prompt":"채널별 청구 건수를 보여줘", "limit":50} +-- +-- Run as CB_ORDS after 65. The Bearer token is resolved before Select AI +-- generates and executes its single validated read-only SQL statement. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET DEFINE OFF +SET FEEDBACK ON + +PROMPT === Granting context setup to the ORDS gateway === +GRANT EXECUTE ON cb_ords_handler_pkg TO ords_public_user; + +PROMPT === Resetting only the VPD Select AI query module === +BEGIN + ORDS.DELETE_MODULE(p_module_name => 'kb.select.ai.vpd'); +EXCEPTION + WHEN OTHERS THEN NULL; +END; +/ + +PROMPT === Creating VPD-aware Select AI query endpoint === +BEGIN + ORDS.DEFINE_MODULE( + p_module_name => 'kb.select.ai.vpd', + p_base_path => 'kb-select-ai-vpd/', + p_items_per_page => 0, + p_status => 'PUBLISHED' + ); + + ORDS.DEFINE_TEMPLATE( + p_module_name => 'kb.select.ai.vpd', + p_pattern => 'query' + ); + + ORDS.DEFINE_HANDLER( + p_module_name => 'kb.select.ai.vpd', + p_pattern => 'query', + p_method => 'POST', + p_source_type => ORDS.source_type_plsql, + p_source => q'~ +DECLARE + v_body_text CLOB; + v_prompt VARCHAR2(4000); + v_limit PLS_INTEGER; + v_rows SYS_REFCURSOR; + v_generated_sql CLOB; + v_response CLOB; + v_error_code NUMBER; + v_error_message VARCHAR2(4000); +BEGIN + -- Resolves the Bearer token and sets the VPD/ASO application context in + -- this very ORDS database session before the generated SQL is parsed. + cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id); + + v_body_text := :body_text; + v_prompt := JSON_VALUE(v_body_text, '$.prompt' RETURNING VARCHAR2(4000) NULL ON ERROR); + v_limit := JSON_VALUE(v_body_text, '$.limit' RETURNING NUMBER DEFAULT 50 ON ERROR); + + IF v_prompt IS NULL OR TRIM(v_prompt) IS NULL THEN + RAISE_APPLICATION_ERROR(-20801, 'prompt is required'); + END IF; + IF v_limit IS NULL OR v_limit < 1 OR v_limit > 100 THEN + RAISE_APPLICATION_ERROR(-20802, 'limit must be between 1 and 100'); + END IF; + + -- Dynamic PL/SQL keeps ORDS gateway validation limited to the local handler + -- package while CB_ORDS invokes only the fixed POC_2 API granted to it. + EXECUTE IMMEDIATE + 'BEGIN poc_2.kb_select_ai_vpd_query_api.open_query(:prompt, :row_limit, :rows, :sql); END;' + USING IN v_prompt, IN v_limit, OUT v_rows, OUT v_generated_sql; + + :status_code := 200; + OWA_UTIL.MIME_HEADER('application/json', FALSE); + HTP.P('Cache-Control: no-store'); + OWA_UTIL.HTTP_HEADER_CLOSE; + + APEX_JSON.OPEN_OBJECT; + APEX_JSON.WRITE('profile', 'KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1'); + APEX_JSON.WRITE('generatedSql', v_generated_sql); + APEX_JSON.WRITE('items', v_rows); + APEX_JSON.CLOSE_OBJECT; + + cb_ords_handler_pkg.clear_vpd_context; +EXCEPTION + WHEN OTHERS THEN + v_error_code := SQLCODE; + v_error_message := SQLERRM; + cb_ords_handler_pkg.clear_vpd_context; + :status_code := CASE + WHEN v_error_code IN (-20001, -20002) THEN 403 + WHEN v_error_code BETWEEN -20899 AND -20800 THEN 400 + WHEN v_error_code BETWEEN -20199 AND -20100 THEN 403 + ELSE 500 + END; + SELECT JSON_OBJECT( + 'errorCode' VALUE v_error_code, + 'error' VALUE CASE + WHEN v_error_code IN (-20001, -20002) THEN + 'VPD token is missing, invalid, expired, or has no access permission.' + WHEN v_error_code BETWEEN -20899 AND -20800 THEN v_error_message + ELSE 'Select AI query execution failed.' + END + RETURNING CLOB + ) + INTO v_response + FROM dual; + OWA_UTIL.MIME_HEADER('application/json', FALSE); + HTP.P('Cache-Control: no-store'); + OWA_UTIL.HTTP_HEADER_CLOSE; + HTP.P(v_response); +END; +~', + p_items_per_page => 0 + ); + + ORDS.DEFINE_PARAMETER( + p_module_name => 'kb.select.ai.vpd', + p_pattern => 'query', + p_method => 'POST', + p_name => 'Authorization', + p_bind_variable_name => 'auth_header', + p_source_type => 'HEADER', + p_param_type => 'STRING', + p_access_method => 'IN' + ); + + ORDS.DEFINE_PARAMETER( + p_module_name => 'kb.select.ai.vpd', + p_pattern => 'query', + p_method => 'POST', + p_name => 'X-VPD-Probe-Id', + p_bind_variable_name => 'probe_id', + p_source_type => 'HEADER', + p_param_type => 'STRING', + p_access_method => 'IN' + ); + + COMMIT; +END; +/ + +PROMPT === VPD-aware Select AI query endpoint ready === +PROMPT Path: /ords/cb-ords/kb-select-ai-vpd/query +EXIT diff --git a/sql/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql b/sql/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql new file mode 100644 index 0000000..459f3c4 --- /dev/null +++ b/sql/adb/67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql @@ -0,0 +1,125 @@ +-- ============================================================ +-- 67_drop_legacy_kb_contracts_premium_vpd_cls_policy.sql +-- +-- 목적: +-- 과거 VPD Column-Level Security(CLS) PoC에서 생성된 +-- POC_2.KB_CONTRACTS.PREMIUM 대상 VPD 컬럼 정책을 제거한다. +-- +-- 배경: +-- 현재 운영 설계는 다음처럼 역할을 분리한다. +-- +-- * VPD/DBMS_RLS : 행 수준 필터링만 담당 +-- * ASO/Data Redaction : 컬럼 마스킹만 담당 +-- +-- 따라서 DBMS_RLS.ADD_POLICY의 sec_relevant_cols 기반 컬럼 제어 정책은 +-- 운영 정책에서 제거한다. 이 스크립트는 named policy 하나만 삭제하며, +-- ASO/Data Redaction 정책은 건드리지 않는다. +-- +-- 삭제 대상: +-- object_schema : POC_2 +-- object_name : KB_CONTRACTS +-- policy_name : KB_PREMIUM_CLS_POLICY +-- column : PREMIUM +-- +-- 실행 권한: +-- ADMIN 또는 DBMS_RLS.DROP_POLICY 수행 권한이 있는 계정. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET DEFINE OFF +SET FEEDBACK ON + +PROMPT === Before: legacy VPD CLS policy === +COLUMN object_owner FORMAT A18 +COLUMN object_name FORMAT A32 +COLUMN policy_name FORMAT A36 +COLUMN function FORMAT A40 +COLUMN enable FORMAT A10 +COLUMN policy_type FORMAT A20 + +SELECT object_owner, + object_name, + policy_name, + pf_owner, + package, + function, + enable, + policy_type, + sel +FROM dba_policies +WHERE object_owner = 'POC_2' +AND object_name = 'KB_CONTRACTS' +AND UPPER(policy_name) = 'KB_PREMIUM_CLS_POLICY'; + +PROMPT === Before: security relevant columns === +COLUMN sec_rel_column FORMAT A32 + +SELECT object_owner, + object_name, + policy_name, + sec_rel_column +FROM dba_sec_relevant_cols +WHERE object_owner = 'POC_2' +AND object_name = 'KB_CONTRACTS' +AND UPPER(policy_name) = 'KB_PREMIUM_CLS_POLICY'; + +PROMPT === Dropping only the legacy VPD CLS policy === +DECLARE + v_count NUMBER; +BEGIN + SELECT COUNT(*) + INTO v_count + FROM dba_policies + WHERE object_owner = 'POC_2' + AND object_name = 'KB_CONTRACTS' + AND UPPER(policy_name) = 'KB_PREMIUM_CLS_POLICY'; + + IF v_count > 0 THEN + DBMS_RLS.DROP_POLICY( + object_schema => 'POC_2', + object_name => 'KB_CONTRACTS', + policy_name => 'KB_PREMIUM_CLS_POLICY' + ); + END IF; +END; +/ + +PROMPT === After: legacy VPD CLS policy === +SELECT object_owner, + object_name, + policy_name, + pf_owner, + package, + function, + enable, + policy_type, + sel +FROM dba_policies +WHERE object_owner = 'POC_2' +AND object_name = 'KB_CONTRACTS' +AND UPPER(policy_name) = 'KB_PREMIUM_CLS_POLICY'; + +PROMPT === After: security relevant columns === +SELECT object_owner, + object_name, + policy_name, + sec_rel_column +FROM dba_sec_relevant_cols +WHERE object_owner = 'POC_2' +AND object_name = 'KB_CONTRACTS' +AND UPPER(policy_name) = 'KB_PREMIUM_CLS_POLICY'; + +PROMPT === ASO/Data Redaction on PREMIUM is intentionally not changed === +COLUMN column_name FORMAT A24 +COLUMN function_type FORMAT A28 + +SELECT object_owner, + object_name, + column_name, + function_type, + function_parameters +FROM redaction_columns +WHERE object_owner = 'POC_2' +AND object_name = 'KB_CONTRACTS' +AND UPPER(column_name) = 'PREMIUM'; + +PROMPT === Legacy KB_CONTRACTS premium VPD CLS cleanup complete === diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index fa23fdc..a959001 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -11,7 +11,41 @@ public record BackofficeProperties( Ai ai ) { - public record Security(String adminUser, String adminPassword, boolean requireHttps) { + public record Security( + String adminUser, + String adminPassword, + String adminPasswordHash, + boolean guestEnabled, + String guestUser, + String guestPassword, + String guestPasswordHash, + boolean requireHttps, + boolean rememberMeEnabled, + String rememberMeKey, + int rememberMeDays + ) { + + /** Remember-me is intentionally unavailable over HTTP or without a stable secret key. */ + public boolean rememberMeConfigured() { + return requireHttps + && rememberMeEnabled + && rememberMeKey != null + && !rememberMeKey.isBlank() + && rememberMeDays >= 1 + && rememberMeDays <= 90; + } + + public int rememberMeValiditySeconds() { + return rememberMeDays * 24 * 60 * 60; + } + + public boolean guestConfigured() { + return guestEnabled + && guestUser != null + && !guestUser.isBlank() + && ((guestPassword != null && !guestPassword.isBlank()) + || (guestPasswordHash != null && !guestPasswordHash.isBlank())); + } } public record Token(int maxDays) { @@ -22,15 +56,20 @@ public record BackofficeProperties( public record Ai( boolean enabled, + String provider, String baseUrl, String model, String apiKey, Duration timeout, - String embeddingModel + String embeddingModel, + String ociConfigFile, + String ociProfile, + String ociRegion, + String ociCompartmentId ) { public Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) { - this(enabled, baseUrl, model, apiKey, timeout, ""); + this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", ""); } } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpAccessTokenFilter.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpAccessTokenFilter.java deleted file mode 100644 index 27049ce..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpAccessTokenFilter.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.cloudhandson.vpdbackoffice.config; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.List; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -/** - * Authenticates only the streamable HTTP MCP endpoint with a service token. - * Business-data authorization remains the bearerToken tool argument, which is - * resolved to the VPD context by the ORDS handler. - */ -@Component -public class McpAccessTokenFilter extends OncePerRequestFilter { - - private final String accessToken; - - public McpAccessTokenFilter(@Value("${backoffice.mcp.access-token:}") String accessToken) { - this.accessToken = accessToken == null ? "" : accessToken.trim(); - } - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - return !"/mcp".equals(request.getRequestURI()); - } - - @Override - protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain - ) throws ServletException, IOException { - String bearerToken = bearerToken(request.getHeader("Authorization")); - if (!accessToken.isBlank() && bearerToken != null && constantTimeEquals(accessToken, bearerToken)) { - var authentication = new UsernamePasswordAuthenticationToken( - "mcp-client", - null, - List.of(new SimpleGrantedAuthority("ROLE_MCP")) - ); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - filterChain.doFilter(request, response); - } - - private String bearerToken(String authorization) { - if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) { - return null; - } - String value = authorization.substring(7).trim(); - return value.isEmpty() ? null : value; - } - - private boolean constantTimeEquals(String expected, String actual) { - return MessageDigest.isEqual( - expected.getBytes(StandardCharsets.UTF_8), - actual.getBytes(StandardCharsets.UTF_8) - ); - } -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java index 46c4c0f..1ba3c00 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java @@ -1,7 +1,6 @@ package com.cloudhandson.vpdbackoffice.config; import java.time.Duration; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -20,7 +19,6 @@ public class OrdsClientConfig { } @Bean - @Qualifier("ordsAgentRestTemplate") RestTemplate ordsAgentRestTemplate(BackofficeProperties properties) { Duration timeout = properties.ords().agentTimeout(); return new RestTemplateBuilder() diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java index c8e4045..a731c4b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java @@ -1,16 +1,18 @@ package com.cloudhandson.vpdbackoffice.config; +import java.util.ArrayList; +import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; -import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.http.HttpMethod; @Configuration public class SecurityConfig { @@ -19,23 +21,48 @@ public class SecurityConfig { SecurityFilterChain securityFilterChain( HttpSecurity http, BackofficeProperties properties, - McpAccessTokenFilter mcpAccessTokenFilter + UserDetailsService userDetailsService ) throws Exception { if (properties.security().requireHttps()) { http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); } + var security = properties.security(); + if (security.rememberMeConfigured()) { + http.rememberMe(rememberMe -> rememberMe + .key(security.rememberMeKey()) + .userDetailsService(userDetailsService) + .rememberMeParameter("remember-me") + .rememberMeCookieName("VPD_REMEMBER_ME") + .tokenValiditySeconds(security.rememberMeValiditySeconds()) + .useSecureCookie(true) + .alwaysRemember(false)); + } + return http - .addFilterBefore(mcpAccessTokenFilter, BasicAuthenticationFilter.class) .csrf(csrf -> csrf.ignoringRequestMatchers( - "/mcp", "/mcp/messages", "/mcp/*/messages", "/dds/mcp/messages")) + "/mcp", "/mcp/messages", "/mcp/*/messages")) .headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts .includeSubDomains(true) .maxAgeInSeconds(31_536_000))) .authorizeHttpRequests(auth -> auth - .requestMatchers("/css/**", "/js/**", "/webjars/**", "/dds/mcp/sse", "/dds/mcp/messages") + .requestMatchers( + "/css/**", "/js/**", "/webjars/**", + "/mcp", "/mcp/sse", "/mcp/*/sse", "/mcp/messages", "/mcp/*/messages") .permitAll() - .requestMatchers(HttpMethod.POST, "/mcp").hasAnyRole("ADMIN", "MCP") + .requestMatchers(HttpMethod.POST, "/login", "/logout").permitAll() + .requestMatchers(HttpMethod.POST, + "/probe", + "/vector-knowledge/search", + "/security-sql-scripts/explanation", + "/mcp-chatbot", + "/mcp-client-demo", + "/mcp-reasoning") + .authenticated() + .requestMatchers(HttpMethod.PUT, "/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.POST, "/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.DELETE, "/**").hasRole("ADMIN") .anyRequest().authenticated()) .httpBasic(basic -> { }) @@ -52,11 +79,26 @@ public class SecurityConfig { PasswordEncoder passwordEncoder ) { var security = properties.security(); - var user = User.withUsername(security.adminUser()) - .password(passwordEncoder.encode(security.adminPassword())) + String encodedPassword = security.adminPasswordHash() == null || security.adminPasswordHash().isBlank() + ? passwordEncoder.encode(security.adminPassword()) + : security.adminPasswordHash(); + List users = new ArrayList<>(); + users.add(User.withUsername(security.adminUser()) + // BCrypt encoding includes a random salt. Re-encoding the configured + // password on every startup would invalidate remember-me signatures. + .password(encodedPassword) .roles("ADMIN") - .build(); - return new InMemoryUserDetailsManager(user); + .build()); + if (security.guestConfigured()) { + String encodedGuestPassword = security.guestPasswordHash() == null || security.guestPasswordHash().isBlank() + ? passwordEncoder.encode(security.guestPassword()) + : security.guestPasswordHash(); + users.add(User.withUsername(security.guestUser()) + .password(encodedGuestPassword) + .roles("VIEWER") + .build()); + } + return new InMemoryUserDetailsManager(users); } @Bean diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java new file mode 100644 index 0000000..0bf978d --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ColumnMaskingRule.java @@ -0,0 +1,31 @@ +package com.cloudhandson.vpdbackoffice.domain.masking; + +public record ColumnMaskingRule( + long columnId, + long objectId, + String owner, + String objectName, + String columnName, + long ruleId, + String ruleCode, + String ruleName, + String templateCode, + String ruleEnabledYn +) { + + public String targetLabel() { + return owner + "." + objectName + "." + columnName; + } + + public MaskingTemplate template() { + return MaskingTemplate.from(templateCode); + } + + public String ruleLabel() { + return ruleName + " · " + template().label(); + } + + public boolean ruleEnabled() { + return "Y".equalsIgnoreCase(ruleEnabledYn); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java new file mode 100644 index 0000000..15ef856 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingPolicyStatus.java @@ -0,0 +1,77 @@ +package com.cloudhandson.vpdbackoffice.domain.masking; + +/** + * Read-only comparison between the configured masking metadata and the + * corresponding Oracle Data Redaction policy stored in the database. + */ +public record MaskingPolicyStatus( + String owner, + String objectName, + String policyName, + String enabled, + int configuredColumnCount, + int appliedColumnCount, + int mismatchedColumnCount, + int legacyVpdColumnPolicyCount +) { + + public String targetLabel() { + return owner + "." + objectName; + } + + public boolean policyEnabled() { + return "YES".equalsIgnoreCase(enabled); + } + + /** A policy with no configured active column is correct only while disabled. */ + public boolean inactiveAsExpected() { + return configuredColumnCount == 0 && !policyEnabled() && legacyVpdColumnPolicyCount == 0; + } + + public boolean applied() { + return configuredColumnCount > 0 + && policyEnabled() + && configuredColumnCount == appliedColumnCount + && mismatchedColumnCount == 0 + && legacyVpdColumnPolicyCount == 0; + } + + public String statusLabel() { + if (applied()) { + return "적용됨"; + } + if (inactiveAsExpected()) { + return "미적용"; + } + return "설정-DB 불일치"; + } + + public String badgeClass() { + if (applied()) { + return "text-bg-success"; + } + if (inactiveAsExpected()) { + return "text-bg-secondary"; + } + return "text-bg-danger"; + } + + public String detail() { + if (applied()) { + return "활성 규칙과 DB 정책 컬럼이 일치합니다."; + } + if (legacyVpdColumnPolicyCount > 0) { + return "기존 컬럼 NULL 정책(VPD CLS)이 활성입니다. ASO 설정과 별도로 값을 NULL 처리할 수 있습니다."; + } + if (inactiveAsExpected()) { + return "활성 컬럼 기본 규칙이 없어 DB 정책을 중지 상태로 보관합니다."; + } + if (configuredColumnCount > 0 && !policyEnabled()) { + return "활성 규칙이 있으나 DB 정책이 비활성입니다. 동기화가 필요합니다."; + } + if (configuredColumnCount == 0) { + return "활성 규칙이 없는데 DB 정책이 활성입니다. 동기화가 필요합니다."; + } + return "설정 컬럼과 DB Redaction 컬럼이 다릅니다. 동기화가 필요합니다."; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java new file mode 100644 index 0000000..5d30315 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRule.java @@ -0,0 +1,23 @@ +package com.cloudhandson.vpdbackoffice.domain.masking; + +public record MaskingRule( + long ruleId, + String ruleCode, + String ruleName, + String templateCode, + String description, + String enabledYn +) { + + public boolean enabled() { + return "Y".equalsIgnoreCase(enabledYn); + } + + public MaskingTemplate template() { + return MaskingTemplate.from(templateCode); + } + + public String templateLabel() { + return template().label(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java new file mode 100644 index 0000000..ae7701b --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingRuleCreateCommand.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.domain.masking; + +public record MaskingRuleCreateCommand( + String ruleCode, + String ruleName, + String templateCode, + String description +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java new file mode 100644 index 0000000..a46e5d1 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java @@ -0,0 +1,76 @@ +package com.cloudhandson.vpdbackoffice.domain.masking; + +import java.util.Arrays; + +/** + * Curated Data Redaction behaviours. A rule selects one template; operators + * never enter raw DBMS_REDACT expressions from the backoffice UI. + */ +public enum MaskingTemplate { + NULLIFY( + "NULLIFY", + "값 숨김 (NULL)", + "값을 NULL로 반환합니다. ASO/Data Redaction 컬럼 마스킹에 사용합니다.", + "DBMS_REDACT.NULLIFY", + "NULL"), + FULL( + "FULL", + "전체 마스킹", + "전체 값을 가립니다. Oracle 기본값은 문자형 공백, 숫자형 0입니다.", + "DBMS_REDACT.FULL", + "문자형은 공백 / 숫자형은 0"), + TEXT_PARTIAL( + "TEXT_PARTIAL", + "문자열 일부 마스킹", + "첫 글자만 남기고 나머지를 가리는 사전 정의 문자열 규칙입니다.", + "DBMS_REDACT.REGEXP", + "A******** (예시)"), + RRN_PARTIAL( + "RRN_PARTIAL", + "주민등록번호 부분 마스킹", + "앞 6자리만 표시하고 나머지는 가리는 사전 정의 식별번호 규칙입니다.", + "DBMS_REDACT.REGEXP", + "900101-******* (예시)"); + + private final String code; + private final String label; + private final String description; + private final String asoFunction; + private final String previewResult; + + MaskingTemplate(String code, String label, String description, String asoFunction, String previewResult) { + this.code = code; + this.label = label; + this.description = description; + this.asoFunction = asoFunction; + this.previewResult = previewResult; + } + + public String code() { + return code; + } + + public String label() { + return label; + } + + public String description() { + return description; + } + + public String asoFunction() { + return asoFunction; + } + + /** Human-readable result shown before an administrator assigns the rule. */ + public String previewResult() { + return previewResult; + } + + public static MaskingTemplate from(String code) { + return Arrays.stream(values()) + .filter(value -> value.code.equalsIgnoreCase(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 마스킹 템플릿입니다: " + code)); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java new file mode 100644 index 0000000..15906b2 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/UserMaskingRule.java @@ -0,0 +1,35 @@ +package com.cloudhandson.vpdbackoffice.domain.masking; + +public record UserMaskingRule( + long userId, + String username, + long columnId, + String owner, + String objectName, + String columnName, + String ruleName, + String templateCode, + String decision, + String activeYn +) { + + public boolean unmasked() { + return "UNMASK".equalsIgnoreCase(decision); + } + + public boolean active() { + return "Y".equalsIgnoreCase(activeYn); + } + + public String decisionLabel() { + return unmasked() ? "원문 표시 허용" : "기본값과 동일 · 마스킹"; + } + + public String targetLabel() { + return owner + "." + objectName + "." + columnName; + } + + public String ruleLabel() { + return ruleName + " · " + MaskingTemplate.from(templateCode).label(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java index 2ac6b8c..ab8bbf8 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/operation/OperationStatusRow.java @@ -47,10 +47,10 @@ public record OperationStatusRow( return "ORDS path와 handler schema/module/template 매핑을 확인하세요."; } if (policyNames == null || policyNames.isBlank()) { - return "VPD policy를 적용하세요."; + return "행 접근 정책(VPD)을 적용하세요."; } if (policyEnabled == null || !policyEnabled.toUpperCase().contains("YES")) { - return "VPD policy enable 상태를 확인하세요."; + return "행 접근 정책(VPD) enable 상태를 확인하세요."; } if (functionStatus != null && !"VALID".equalsIgnoreCase(functionStatus)) { return "Policy function 컴파일 오류를 확인하세요."; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java index 795b5f0..7c9532b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java @@ -13,4 +13,69 @@ public record PermissionView( String filterPreview, String nullPolicyPreview ) { + + public String businessRuleSummary() { + if (rules == null || rules.isBlank()) { + return "-"; + } + return java.util.Arrays.stream(rules.split(",")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .map(PermissionView::businessRuleLabel) + .collect(java.util.stream.Collectors.joining(System.lineSeparator() + "AND ")); + } + + public String storedRuleSummary() { + return rules == null || rules.isBlank() ? "-" : rules; + } + + public String vpdMappingSummary() { + return filterPreview == null || filterPreview.isBlank() ? "-" : filterPreview; + } + + public String columnControlSummary() { + if (visibleColumns == null || visibleColumns.isBlank()) { + return "컬럼 원문/마스킹은 컬럼 마스킹에서 관리"; + } + return "기존 권한별 표시 예외 값: " + visibleColumns + + " · 신규 컬럼 제어는 컬럼 마스킹에서 관리"; + } + + private static String businessRuleLabel(String rawRule) { + String upper = rawRule.toUpperCase(java.util.Locale.ROOT); + if ("ALL".equals(upper)) { + return "전체 행"; + } + if (upper.contains(" TOKEN_SUBJECT")) { + return "토큰으로 식별된 이해관계자 본인 행"; + } + if (upper.contains(" OWN_CONTRACT")) { + return "담당 설계사 본인 계약"; + } + if (upper.contains(" CHANNEL_CONTRACT")) { + return "토큰 사용자의 채널 계약"; + } + if (upper.contains(" OWN_CUSTOMER")) { + return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유"; + } + if (upper.contains(" CHANNEL_CUSTOMER")) { + return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유"; + } + if (upper.contains(" STATIC_SQL ")) { + return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", ""); + } + if (upper.startsWith("STATIC_SQL ")) { + return "정적 SQL 조건: " + rawRule.substring("STATIC_SQL ".length()); + } + if (upper.contains(" MY_DEPT")) { + return "내 부서 행"; + } + if (upper.contains(" SELF")) { + return "내 사번/소유자 행"; + } + if (upper.contains(" TAG ")) { + return "태그 조건: " + rawRule; + } + return rawRule; + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java index f34238a..b767c37 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java @@ -238,7 +238,7 @@ public record ProbeResult( case INVALID_TOKEN -> "입력한 토큰 정보가 일치하지 않습니다."; case OBJECT_DISABLED -> "검증 대상이 비활성 상태입니다."; case OBJECT_NOT_ACCESSIBLE -> "현재 권한으로 이 대상에 접근할 수 없습니다."; - case VPD_FILTER_ERROR -> "이 객체에 연결된 별도 VPD Filter가 실행되지 않습니다."; + case VPD_FILTER_ERROR -> "이 객체에 연결된 행 접근 Filter가 실행되지 않습니다."; case ORDS_PATH_NOT_FOUND -> "검증 대상의 ORDS 경로를 찾지 못했습니다."; case ORDS_NOT_CONFIGURED -> "ORDS 연결 주소가 아직 설정되지 않았습니다."; case ORDS_UNAVAILABLE -> "ORDS 서버에 연결할 수 없습니다."; @@ -250,9 +250,9 @@ public record ProbeResult( public String plainSummary() { return switch (status) { - case SUCCESS -> "토큰의 사용자와 역할을 기준으로 VPD가 적용되었고, 허용된 데이터 " + rowCount + case SUCCESS -> "토큰의 사용자와 역할을 기준으로 행 접근 정책이 적용되었고, 허용된 데이터 " + rowCount + "개가 반환되었습니다."; - case VPD_DENY_EMPTY_RESULT -> "호출은 정상 처리됐지만 VPD가 현재 사용자에게 허용한 행은 0개입니다. 권한 규칙과 실제 데이터가 맞지 않으면 정상 결과이며 오류가 아닐 수 있습니다."; + case VPD_DENY_EMPTY_RESULT -> "호출은 정상 처리됐지만 행 접근 정책이 현재 사용자에게 허용한 행은 0개입니다. 행 접근 규칙과 실제 데이터가 맞지 않으면 정상 결과이며 오류가 아닐 수 있습니다."; case TOKEN_NOT_FOUND -> "입력한 원문과 일치하는 등록 기록이 현재 DB에 없습니다. 예전에 발급한 값이거나 다른 환경의 토큰일 수 있습니다."; case TOKEN_INACTIVE -> "토큰은 DB에 있지만 만료되었거나 관리자가 회수해 더 이상 사용자 권한을 증명할 수 없습니다."; case INVALID_TOKEN -> "화면에서 선택한 정보와 입력한 토큰 원문이 서로 다릅니다."; @@ -270,17 +270,17 @@ public record ProbeResult( public String nextAction() { return switch (status) { - case SUCCESS -> "반환된 행과 마스킹 컬럼이 예상한 범위인지 확인하세요. 다르면 권한 화면의 행·열 규칙을 조정한 뒤 다시 검증하세요."; + case SUCCESS -> "반환된 행과 ASO 마스킹 컬럼이 예상한 범위인지 확인하세요. 행 범위가 다르면 행 접근 규칙을, 컬럼 표시가 다르면 컬럼 마스킹을 조정한 뒤 다시 검증하세요."; case VPD_DENY_EMPTY_RESULT -> "유효 권한 화면에서 사용자에게 직접 또는 그룹으로 상속된 역할과 행 규칙을 확인하세요."; case TOKEN_NOT_FOUND -> "토큰 화면에서 현재 환경의 사용자에게 새 토큰을 발급하고, 한 번만 표시되는 원문을 복사해 다시 검증하세요."; case TOKEN_INACTIVE -> "토큰 화면에서 활성 토큰을 새로 발급한 뒤 다시 검증하세요."; case INVALID_TOKEN -> "복사한 원문이 맞는지 확인하고, 원문을 잃었다면 새 토큰을 발급하세요."; case OBJECT_DISABLED -> "보호 객체를 활성화하고 권한을 등록한 뒤 다시 검증하세요."; case OBJECT_NOT_ACCESSIBLE -> "유효 권한과 ORDS handler의 대상 객체가 같은지 확인하세요."; - case VPD_FILTER_ERROR -> "토큰이나 권한을 바꾸지 말고 DB 보호 연결에서 이 객체의 Filter를 확인하세요. 일반 권한 객체라면 권한체계 자동 Filter로 복구하세요."; + case VPD_FILTER_ERROR -> "토큰이나 행 접근 규칙을 바꾸지 말고 DB 보호 연결에서 이 객체의 Filter를 확인하세요. 일반 권한 객체라면 표준 행 접근 Filter로 복구하세요."; case ORDS_PATH_NOT_FOUND -> "보호 객체의 ORDS 경로와 실제 module/template 경로를 맞춘 뒤 다시 실행하세요."; case ORDS_NOT_CONFIGURED -> "설정에서 ORDS 기준 주소를 등록한 뒤 백오피스를 재시작하세요."; - case ORDS_UNAVAILABLE, ORDS_TIMEOUT -> "권한 설정을 바꾸지 말고 먼저 ORDS 실행 상태, 주소와 네트워크를 확인하세요."; + case ORDS_UNAVAILABLE, ORDS_TIMEOUT -> "행 접근 규칙을 바꾸지 말고 먼저 ORDS 실행 상태, 주소와 네트워크를 확인하세요."; case INVALID_ORDS_RESPONSE -> "ORDS handler가 rows 또는 items 배열을 반환하는지 확인하세요."; case UNKNOWN_ERROR -> "아래 기술 상세의 오류 코드와 응답을 확인한 뒤 해당 단계부터 점검하세요."; }; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java new file mode 100644 index 0000000..2c2ea1b --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaAnnotation.java @@ -0,0 +1,4 @@ +package com.cloudhandson.vpdbackoffice.domain.schemametadata; + +public record SchemaAnnotation(String name, String value) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java new file mode 100644 index 0000000..c2469b7 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataColumn.java @@ -0,0 +1,12 @@ +package com.cloudhandson.vpdbackoffice.domain.schemametadata; + +import java.util.List; + +public record SchemaMetadataColumn( + String columnName, + String dataType, + boolean nullable, + String comment, + List annotations +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java new file mode 100644 index 0000000..752fdaa --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schemametadata/SchemaMetadataView.java @@ -0,0 +1,12 @@ +package com.cloudhandson.vpdbackoffice.domain.schemametadata; + +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; +import java.util.List; + +public record SchemaMetadataView( + StructuredDataTable table, + String tableComment, + List tableAnnotations, + List columns +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java new file mode 100644 index 0000000..794b339 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScript.java @@ -0,0 +1,12 @@ +package com.cloudhandson.vpdbackoffice.domain.securityscript; + +/** Curated, version-controlled database script displayed read-only in the backoffice. */ +public record SecuritySqlScript( + String scriptId, + String category, + String fileName, + String title, + String description, + String source +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java new file mode 100644 index 0000000..e26ea01 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptExplanation.java @@ -0,0 +1,11 @@ +package com.cloudhandson.vpdbackoffice.domain.securityscript; + +/** Explanation generated from one immutable, curated security SQL script. */ +public record SecuritySqlScriptExplanation( + String status, + String modelName, + String answer, + String prompt, + SecuritySqlScript script +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java new file mode 100644 index 0000000..b70bad9 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/securityscript/SecuritySqlScriptSummary.java @@ -0,0 +1,11 @@ +package com.cloudhandson.vpdbackoffice.domain.securityscript; + +/** Metadata for selecting a curated security SQL script without loading its source. */ +public record SecuritySqlScriptSummary( + String scriptId, + String category, + String fileName, + String title, + String description +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java new file mode 100644 index 0000000..75dbde7 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdDescriptionNote.java @@ -0,0 +1,5 @@ +package com.cloudhandson.vpdbackoffice.domain.vpd; + +/** A saved VPD policy or filter description, addressed by a stable composite key. */ +public record VpdDescriptionNote(String noteKey, String description) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java new file mode 100644 index 0000000..ca4edb8 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java @@ -0,0 +1,52 @@ +package com.cloudhandson.vpdbackoffice.mapper; + +import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; +import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule; +import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; +import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus; +import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface MaskingRuleMapper { + + List findAllRules(); + + List findEnabledRules(); + + MaskingRule findRuleById(@Param("ruleId") long ruleId); + + MaskingRule findRuleByCode(@Param("ruleCode") String ruleCode); + + long nextRuleId(); + + void insertRule(@Param("ruleId") long ruleId, @Param("command") MaskingRuleCreateCommand command); + + int updateRuleActive(@Param("ruleId") long ruleId, @Param("enabledYn") String enabledYn); + + List findColumnRules(); + + List findPolicyStatuses(); + + ColumnMaskingRule findColumnRule(@Param("columnId") long columnId); + + void upsertColumnRule(@Param("columnId") long columnId, @Param("ruleId") long ruleId); + + int deleteColumnRule(@Param("columnId") long columnId); + + int deleteUserRulesForColumn(@Param("columnId") long columnId); + + List findUserRules(); + + UserMaskingRule findUserRule(@Param("userId") long userId, @Param("columnId") long columnId); + + void upsertUserRule( + @Param("userId") long userId, + @Param("columnId") long columnId, + @Param("decision") String decision + ); + + int deleteUserRule(@Param("userId") long userId, @Param("columnId") long columnId); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java index d1c7dc8..3391eec 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java @@ -21,6 +21,13 @@ public interface ProtectedObjectMapper { List findColumns(@Param("objectId") long objectId); + List findColumnsByObjectIds(@Param("objectIds") List objectIds); + + ProtectedColumn findColumnById(@Param("columnId") long columnId); + + ProtectedColumn findColumnByObjectAndName(@Param("objectId") long objectId, + @Param("columnName") String columnName); + List findDatabaseObjects(); List findDatabaseColumns(@Param("owner") String owner, @Param("objectName") String objectName); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java index e357a2f..1ed219a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java @@ -1,6 +1,7 @@ package com.cloudhandson.vpdbackoffice.mapper; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption; +import com.cloudhandson.vpdbackoffice.domain.vpd.VpdDescriptionNote; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyTemplateOption; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView; @@ -66,6 +67,10 @@ public interface VpdPolicyMapper { @Param("functionName") String functionName ); + List findPolicyDescriptions(); + + List findFilterDescriptions(); + int upsertPolicyDescription( @Param("objectOwner") String objectOwner, @Param("objectName") String objectName, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index a0120c7..2712457 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -132,6 +132,33 @@ public class BackofficeSchemaService { CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name) ) """), + new TableDefinition("CB_MASKING_RULE", """ + CREATE TABLE cb_masking_rule ( + rule_id NUMBER PRIMARY KEY, + rule_code VARCHAR2(64) NOT NULL UNIQUE, + rule_name VARCHAR2(100) NOT NULL, + template_code VARCHAR2(30) NOT NULL, + description VARCHAR2(400), + enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL + ) + """), + new TableDefinition("CB_COLUMN_MASKING_RULE", """ + CREATE TABLE cb_column_masking_rule ( + column_id NUMBER PRIMARY KEY, + rule_id NUMBER NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL + ) + """), + new TableDefinition("CB_USER_MASKING_RULE", """ + CREATE TABLE cb_user_masking_rule ( + user_id NUMBER NOT NULL, + column_id NUMBER NOT NULL, + decision VARCHAR2(10) NOT NULL CHECK (decision IN ('MASK','UNMASK')), + active_yn CHAR(1) DEFAULT 'Y' CHECK (active_yn IN ('Y','N')) NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT cb_user_masking_rule_pk PRIMARY KEY (user_id, column_id) + ) + """), new TableDefinition("CB_ORDS_PROBE_AUDIT", """ CREATE TABLE cb_ords_probe_audit ( audit_id NUMBER PRIMARY KEY, @@ -224,6 +251,25 @@ public class BackofficeSchemaService { VALUES (src.setting_key, src.setting_value, SYSTIMESTAMP) """; + private static final List DEFAULT_MASKING_RULES = List.of( + new MaskingRuleSeed("MASK_NULLIFY", "값 숨김 (NULL)", "NULLIFY", + "값을 NULL로 반환하는 기본 마스킹 방식"), + new MaskingRuleSeed("MASK_FULL", "전체 마스킹", "FULL", + "문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"), + new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL", + "첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"), + new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL", + "앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식") + ); + + private static final String MASKING_RULE_SEED_SQL = """ + MERGE INTO cb_masking_rule dst + USING (SELECT ? rule_id, ? rule_code, ? rule_name, ? template_code, ? description FROM dual) src + ON (dst.rule_code = src.rule_code) + WHEN NOT MATCHED THEN INSERT (rule_id, rule_code, rule_name, template_code, description, enabled_yn) + VALUES (src.rule_id, src.rule_code, src.rule_name, src.template_code, src.description, 'Y') + """; + private final JdbcTemplate jdbcTemplate; private final BackofficeProperties properties; @@ -342,6 +388,7 @@ public class BackofficeSchemaService { } runDml(results, "CB_PROTECTED_COLUMN", "DATA", PROTECTED_COLUMN_MIGRATION_SQL, "민감 컬럼 legacy 값을 보강했습니다.", "UPDATED"); + seedDefaultMaskingRules(results); seedDefaultSettings(results); return results; } @@ -412,6 +459,28 @@ public class BackofficeSchemaService { } } + private void seedDefaultMaskingRules(List results) { + long nextRuleId; + try { + Long currentMax = jdbcTemplate.queryForObject("SELECT NVL(MAX(rule_id), 0) FROM cb_masking_rule", Long.class); + nextRuleId = currentMax == null ? 1L : currentMax + 1L; + } catch (RuntimeException exception) { + results.add(new SchemaActionResult("CB_MASKING_RULE", "MASKING_RULE", "FAILED", + safeMessage(exception), MASKING_RULE_SEED_SQL)); + return; + } + for (MaskingRuleSeed seed : DEFAULT_MASKING_RULES) { + try { + jdbcTemplate.update(MASKING_RULE_SEED_SQL, nextRuleId++, seed.code(), seed.name(), seed.templateCode(), seed.description()); + results.add(new SchemaActionResult(seed.code(), "MASKING_RULE", "MERGED", + "기본 마스킹 규칙을 확인했습니다.", MASKING_RULE_SEED_SQL)); + } catch (RuntimeException exception) { + results.add(new SchemaActionResult(seed.code(), "MASKING_RULE", "FAILED", + safeMessage(exception), MASKING_RULE_SEED_SQL)); + } + } + } + private String currentUser() { return jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class); } @@ -623,6 +692,7 @@ public class BackofficeSchemaService { appendSql(builder, column.ddl()); } appendSql(builder, PROTECTED_COLUMN_MIGRATION_SQL); + appendSql(builder, MASKING_RULE_SEED_SQL.replace("?", "''")); appendSql(builder, SETTINGS_MERGE_SQL.replace("?", "''")); return builder.toString(); } @@ -635,6 +705,7 @@ public class BackofficeSchemaService { @sql/adb/17_agent_ords_security_local_vpd_setup.sql @sql/adb/25_agent_ords_security_backoffice_support.sql @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql + @sql/adb/62_kb_aso_masking_backoffice_metadata.sql @sql/adb/21_agent_ords_security_ords_enable_schema.sql -- 2. ORDS parsing schema로 접속 @@ -644,8 +715,11 @@ public class BackofficeSchemaService { -- 3. 대표 권한 부여 SQL CONNECT %s/@ GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords; - GRANT EXECUTE ON cb_agent_can_read_column TO cb_ords; GRANT SELECT ON . TO cb_ords; + + -- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행 + @sql/adb/64_kb_aso_masking_default_column_rules.sql + @sql/adb/63_kb_aso_masking_rule_runtime.sql """.formatted(owner.toLowerCase()); } @@ -693,4 +767,7 @@ public class BackofficeSchemaService { private record ConstraintDefinition(String name, String table, String objectType) { } + + private record MaskingRuleSeed(String code, String name, String templateCode, String description) { + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/DdsAuthorizationChangeNotifier.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/DdsAuthorizationChangeNotifier.java deleted file mode 100644 index 462d71a..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/DdsAuthorizationChangeNotifier.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.stereotype.Service; - -/** - * Keeps the shared user/role/permission services independent from the DDS app. - * The normal VPD application has no synchronizer. The DDS application provides - * one and receives the change before the management request returns. - */ -@Service -public class DdsAuthorizationChangeNotifier { - - private static final Logger log = LoggerFactory.getLogger(DdsAuthorizationChangeNotifier.class); - private final ObjectProvider synchronizer; - - public DdsAuthorizationChangeNotifier(ObjectProvider synchronizer) { - this.synchronizer = synchronizer; - } - - private DdsAuthorizationChangeNotifier() { - this.synchronizer = null; - } - - public static DdsAuthorizationChangeNotifier noop() { - return new DdsAuthorizationChangeNotifier(); - } - - public void changed(String reason) { - if (synchronizer != null) { - log.info("DDS authorization change published: {}", reason); - synchronizer.ifAvailable(target -> target.synchronize(reason)); - } - } -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/DdsAuthorizationSynchronizer.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/DdsAuthorizationSynchronizer.java deleted file mode 100644 index 87ff18d..0000000 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/DdsAuthorizationSynchronizer.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.cloudhandson.vpdbackoffice.service; - -/** Optional bridge implemented only by the dedicated DDS application. */ -public interface DdsAuthorizationSynchronizer { - - void synchronize(String reason); -} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java new file mode 100644 index 0000000..6f433ee --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationChangeNotifier.java @@ -0,0 +1,41 @@ +package com.cloudhandson.vpdbackoffice.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; + +/** + * Keeps user/role/permission services independent from optional external + * authorization runtimes. The normal VPD application can run without a + * synchronizer; if one exists, it receives the change before the management + * request returns. + */ +@Service +public class ExternalAuthorizationChangeNotifier { + + private static final Logger log = LoggerFactory.getLogger(ExternalAuthorizationChangeNotifier.class); + private final ObjectProvider synchronizer; + + public ExternalAuthorizationChangeNotifier(ObjectProvider synchronizer) { + this.synchronizer = synchronizer; + } + + private ExternalAuthorizationChangeNotifier() { + this.synchronizer = null; + } + + public static ExternalAuthorizationChangeNotifier noop() { + return new ExternalAuthorizationChangeNotifier(); + } + + public void changed(String reason) { + if (synchronizer != null) { + ExternalAuthorizationSynchronizer target = synchronizer.getIfAvailable(); + if (target != null) { + log.info("External authorization change synchronized: {}", reason); + target.synchronize(reason); + } + } + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java new file mode 100644 index 0000000..4b2b777 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/ExternalAuthorizationSynchronizer.java @@ -0,0 +1,7 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Optional bridge implemented by an external authorization runtime. */ +public interface ExternalAuthorizationSynchronizer { + + void synchronize(String reason); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java index d008ba2..a34f87a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java @@ -16,21 +16,21 @@ public class GroupService { private final GroupMapper groupMapper; private final AuditService auditService; - private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier; + private final ExternalAuthorizationChangeNotifier authorizationChangeNotifier; @Autowired public GroupService( GroupMapper groupMapper, AuditService auditService, - DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier + ExternalAuthorizationChangeNotifier authorizationChangeNotifier ) { this.groupMapper = groupMapper; this.auditService = auditService; - this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier; + this.authorizationChangeNotifier = authorizationChangeNotifier; } public GroupService(GroupMapper groupMapper, AuditService auditService) { - this(groupMapper, auditService, DdsAuthorizationChangeNotifier.noop()); + this(groupMapper, auditService, ExternalAuthorizationChangeNotifier.noop()); } public List findAll() { @@ -50,7 +50,7 @@ public class GroupService { long groupId = groupMapper.nextGroupId(); groupMapper.insertGroup(groupId, command); auditService.record(new AuditEvent("GROUP_CREATED", null, null, "SUCCESS", null, null, command.groupCode())); - ddsAuthorizationChangeNotifier.changed("GROUP_CREATED"); + authorizationChangeNotifier.changed("GROUP_CREATED"); } @Transactional @@ -69,7 +69,7 @@ public class GroupService { } auditService.record(new AuditEvent("GROUP_ACTIVE_CHANGED", null, null, "SUCCESS", null, null, "groupId=" + groupId + ",active=" + active)); - ddsAuthorizationChangeNotifier.changed("GROUP_ACTIVE_CHANGED"); + authorizationChangeNotifier.changed("GROUP_ACTIVE_CHANGED"); } @Transactional @@ -77,7 +77,7 @@ public class GroupService { groupMapper.insertGroupUser(groupId, userId); auditService.record(new AuditEvent("GROUP_USER_ADDED", null, null, "SUCCESS", null, null, "groupId=" + groupId + ",userId=" + userId)); - ddsAuthorizationChangeNotifier.changed("GROUP_USER_ADDED"); + authorizationChangeNotifier.changed("GROUP_USER_ADDED"); } @Transactional @@ -96,7 +96,7 @@ public class GroupService { } auditService.record(new AuditEvent("GROUP_USER_REMOVED", null, null, "SUCCESS", null, null, "groupId=" + groupId + ",userId=" + userId)); - ddsAuthorizationChangeNotifier.changed("GROUP_USER_REMOVED"); + authorizationChangeNotifier.changed("GROUP_USER_REMOVED"); } @Transactional @@ -104,7 +104,7 @@ public class GroupService { groupMapper.insertGroupRole(groupId, roleId); auditService.record(new AuditEvent("GROUP_ROLE_ADDED", null, null, "SUCCESS", null, null, "groupId=" + groupId + ",roleId=" + roleId)); - ddsAuthorizationChangeNotifier.changed("GROUP_ROLE_ADDED"); + authorizationChangeNotifier.changed("GROUP_ROLE_ADDED"); } @Transactional @@ -123,6 +123,6 @@ public class GroupService { } auditService.record(new AuditEvent("GROUP_ROLE_REMOVED", null, null, "SUCCESS", null, null, "groupId=" + groupId + ",roleId=" + roleId)); - ddsAuthorizationChangeNotifier.changed("GROUP_ROLE_REMOVED"); + authorizationChangeNotifier.changed("GROUP_ROLE_REMOVED"); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java new file mode 100644 index 0000000..ce4b4ae --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java @@ -0,0 +1,359 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; +import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; +import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Reconciles the backoffice masking metadata with the managed Oracle + * Data Redaction policies. Metadata is the source of truth: a table with no + * active linked columns has its managed policy disabled, rather than silently + * retaining redaction after its UI configuration was removed. + */ +@Service +public class MaskingPolicySynchronizer { + + private static final String OWNER = "POC_2"; + private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); + private static final Map MANAGED_POLICIES = managedPolicyMap(); + + private final JdbcTemplate jdbcTemplate; + private final MaskingRuleMapper mapper; + + public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) { + this.jdbcTemplate = jdbcTemplate; + this.mapper = mapper; + } + + private static Map managedPolicyMap() { + Map policies = new LinkedHashMap<>(); + policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT"); + policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT"); + policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT"); + policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT"); + return Collections.unmodifiableMap(policies); + } + + public Set managedObjectNames() { + return MANAGED_POLICIES.keySet(); + } + + public boolean isManagedObject(String objectName) { + return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT)); + } + + static String managedPolicyName(String objectName) { + return MANAGED_POLICIES.get(objectName); + } + + /** + * Applies the current active column-rule metadata to managed DBMS_REDACT policies. + * + *

The backoffice metadata is the source of truth. In particular, when an object has no + * active linked column rule, its policy is disabled. This avoids an old redaction policy + * continuing to mask data after an operator removed every rule from the UI.

+ */ + public MaskingPolicySyncResult synchronize() { + Map> desiredByObject = new LinkedHashMap<>(); + for (ColumnMaskingRule rule : mapper.findColumnRules()) { + if (OWNER.equalsIgnoreCase(rule.owner()) + && rule.ruleEnabled() + && MANAGED_POLICIES.containsKey(rule.objectName())) { + desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule); + } + } + + int disabledPolicies = 0; + int enabledPolicies = 0; + int addedColumns = 0; + int modifiedColumns = 0; + int droppedColumns = 0; + for (Map.Entry policy : MANAGED_POLICIES.entrySet()) { + String objectName = policy.getKey(); + String policyName = policy.getValue(); + List desired = desiredByObject.getOrDefault(objectName, List.of()); + String enableStatus = policyEnableStatus(objectName, policyName); + if (desired.isEmpty()) { + if ("YES".equals(enableStatus)) { + disablePolicy(objectName, policyName); + disabledPolicies++; + } + continue; + } + + boolean exists = enableStatus != null; + if (exists) { + if (!"YES".equals(enableStatus)) { + enablePolicy(objectName, policyName); + enabledPolicies++; + } + } + Set desiredColumns = desired.stream() + .map(ColumnMaskingRule::columnName) + .map(this::requiredColumnName) + .collect(LinkedHashSet::new, Set::add, Set::addAll); + Set actualColumns = exists + ? new LinkedHashSet<>(redactionColumns(objectName)) + : new LinkedHashSet<>(); + + for (String actualColumn : actualColumns) { + if (!desiredColumns.contains(actualColumn)) { + dropColumn(objectName, policyName, actualColumn); + droppedColumns++; + } + } + + boolean firstColumn = !exists; + for (ColumnMaskingRule desiredColumn : desired) { + String columnName = requiredColumnName(desiredColumn.columnName()); + if (firstColumn) { + addPolicy(objectName, policyName, columnName, desiredColumn.template()); + firstColumn = false; + addedColumns++; + } else if (actualColumns.contains(columnName)) { + modifyColumn(objectName, policyName, columnName, desiredColumn.template()); + modifiedColumns++; + } else { + addColumn(objectName, policyName, columnName, desiredColumn.template()); + addedColumns++; + } + upsertColumnExpression(objectName, columnName, desiredColumn.columnId()); + } + } + return new MaskingPolicySyncResult( + disabledPolicies, enabledPolicies, addedColumns, modifiedColumns, droppedColumns + ); + } + + private String policyEnableStatus(String objectName, String policyName) { + List statuses = jdbcTemplate.queryForList(""" + SELECT enable + FROM redaction_policies + WHERE object_owner = ? AND object_name = ? AND policy_name = ? + """, String.class, OWNER, objectName, policyName); + return statuses.isEmpty() ? null : statuses.getFirst(); + } + + private List redactionColumns(String objectName) { + return jdbcTemplate.queryForList(""" + SELECT column_name + FROM redaction_columns + WHERE object_owner = ? AND object_name = ? + """, String.class, OWNER, objectName).stream() + .map(this::requiredColumnName) + .toList(); + } + + private void disablePolicy(String objectName, String policyName) { + jdbcTemplate.update(""" + BEGIN + DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); + END; + """, OWNER, objectName, policyName); + } + + private void enablePolicy(String objectName, String policyName) { + jdbcTemplate.update(""" + BEGIN + DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); + END; + """, OWNER, objectName, policyName); + } + + private void dropColumn(String objectName, String policyName, String columnName) { + jdbcTemplate.update(""" + BEGIN + DBMS_REDACT.ALTER_POLICY( + object_schema => ?, object_name => ?, policy_name => ?, + action => DBMS_REDACT.DROP_COLUMN, column_name => ? + ); + END; + """, OWNER, objectName, policyName, columnName); + } + + private void addPolicy( + String objectName, String policyName, String columnName, MaskingTemplate template + ) { + callTemplate("ADD_POLICY", objectName, policyName, columnName, template); + } + + private void addColumn( + String objectName, String policyName, String columnName, MaskingTemplate template + ) { + callTemplate("ADD_COLUMN", objectName, policyName, columnName, template); + } + + private void modifyColumn( + String objectName, String policyName, String columnName, MaskingTemplate template + ) { + callTemplate("MODIFY_COLUMN", objectName, policyName, columnName, template); + } + + /** + * Template choice is an enum, so DBMS_REDACT constants are rendered only from trusted source + * code. They cannot be supplied from a request parameter or backoffice table value. + */ + private void callTemplate( + String operation, String objectName, String policyName, String columnName, MaskingTemplate template + ) { + String functionConstant = switch (template) { + case NULLIFY -> "DBMS_REDACT.NULLIFY"; + case FULL -> "DBMS_REDACT.FULL"; + case TEXT_PARTIAL, RRN_PARTIAL -> "DBMS_REDACT.REGEXP"; + }; + String actionConstant = switch (operation) { + case "ADD_POLICY" -> null; + case "ADD_COLUMN" -> "DBMS_REDACT.ADD_COLUMN"; + case "MODIFY_COLUMN" -> "DBMS_REDACT.MODIFY_COLUMN"; + default -> throw new IllegalArgumentException("Unsupported redaction operation"); + }; + String regexPattern = switch (template) { + case TEXT_PARTIAL -> "(^.).*$"; + case RRN_PARTIAL -> "(^[0-9]{6})-?[0-9]{7}$"; + default -> null; + }; + String regexReplacement = switch (template) { + case TEXT_PARTIAL -> "\\1***"; + case RRN_PARTIAL -> "\\1-*******"; + default -> null; + }; + + if ("ADD_POLICY".equals(operation)) { + String sql = template == MaskingTemplate.TEXT_PARTIAL || template == MaskingTemplate.RRN_PARTIAL + ? """ + BEGIN + DBMS_REDACT.ADD_POLICY( + object_schema => ?, object_name => ?, policy_name => ?, + policy_description => 'Managed by VPD masking backoffice', + column_name => ?, function_type => %s, expression => '1=1', + regexp_pattern => ?, regexp_replace_string => ?, enable => TRUE + ); + END; + """.formatted(functionConstant) + : """ + BEGIN + DBMS_REDACT.ADD_POLICY( + object_schema => ?, object_name => ?, policy_name => ?, + policy_description => 'Managed by VPD masking backoffice', + column_name => ?, function_type => %s, expression => '1=1', enable => TRUE + ); + END; + """.formatted(functionConstant); + if (regexPattern == null) { + jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); + } else { + jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); + } + return; + } + + String sql = template == MaskingTemplate.TEXT_PARTIAL || template == MaskingTemplate.RRN_PARTIAL + ? """ + BEGIN + DBMS_REDACT.ALTER_POLICY( + object_schema => ?, object_name => ?, policy_name => ?, action => %s, + column_name => ?, function_type => %s, regexp_pattern => ?, regexp_replace_string => ? + ); + END; + """.formatted(actionConstant, functionConstant) + : """ + BEGIN + DBMS_REDACT.ALTER_POLICY( + object_schema => ?, object_name => ?, policy_name => ?, action => %s, + column_name => ?, function_type => %s + ); + END; + """.formatted(actionConstant, functionConstant); + if (regexPattern == null) { + jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); + } else { + jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); + } + } + + private void upsertColumnExpression(String objectName, String columnName, long columnId) { + String expressionName = "CBMR_" + columnId; + String expression = maskingExpression(columnId); + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM redaction_expressions WHERE policy_expression_name = ? + """, Integer.class, expressionName); + if (count != null && count > 0) { + jdbcTemplate.update(""" + BEGIN + DBMS_REDACT.UPDATE_POLICY_EXPRESSION( + policy_expression_name => ?, expression => ?, + policy_expression_description => 'Mask unless trusted context allows original value' + ); + END; + """, expressionName, expression); + } else { + jdbcTemplate.update(""" + BEGIN + DBMS_REDACT.CREATE_POLICY_EXPRESSION( + policy_expression_name => ?, expression => ?, + policy_expression_description => 'Mask unless trusted context allows original value' + ); + END; + """, expressionName, expression); + } + if (!expressionAppliedToColumn(expressionName, objectName, columnName)) { + jdbcTemplate.update(""" + BEGIN + DBMS_REDACT.APPLY_POLICY_EXPR_TO_COL( + object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ? + ); + END; + """, OWNER, objectName, columnName, expressionName); + } + } + + static String maskingExpression(long columnId) { + return "SYS_CONTEXT('CB_AGENT_CTX', 'MR_" + columnId + + "') IS NULL OR SYS_CONTEXT('CB_AGENT_CTX', 'MR_" + columnId + "') <> 'Y'"; + } + + private boolean expressionAppliedToColumn(String expressionName, String objectName, String columnName) { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM redaction_expressions + WHERE policy_expression_name = ? + AND object_name = ? + AND column_name = ? + """, Integer.class, expressionName, objectName, columnName); + return count != null && count > 0; + } + + private String requiredColumnName(String value) { + String normalized = value == null ? "" : value.trim().toUpperCase(); + if (!COLUMN_NAME.matcher(normalized).matches()) { + throw new AppException("동기화할 보호 컬럼명이 유효하지 않습니다."); + } + return normalized; + } + + public record MaskingPolicySyncResult( + int disabledPolicies, + int enabledPolicies, + int addedColumns, + int modifiedColumns, + int droppedColumns + ) { + + public String summary() { + return "DB ASO 정책 동기화 완료: 비활성 " + disabledPolicies + "건, 활성 " + enabledPolicies + + "건, 컬럼 추가 " + addedColumns + "건, 변경 " + modifiedColumns + "건, 해제 " + + droppedColumns + "건"; + } + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java new file mode 100644 index 0000000..05335de --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java @@ -0,0 +1,223 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent; +import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; +import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule; +import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; +import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus; +import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; +import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; +import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; +import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; +import com.cloudhandson.vpdbackoffice.mapper.UserMapper; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class MaskingRuleService { + + private static final Pattern RULE_CODE = Pattern.compile("[A-Z][A-Z0-9_]{2,63}"); + private static final Set DECISIONS = Set.of("MASK", "UNMASK"); + + private final MaskingRuleMapper mapper; + private final UserMapper userMapper; + private final ProtectedObjectService protectedObjectService; + private final AuditService auditService; + private final MaskingPolicySynchronizer maskingPolicySynchronizer; + + public MaskingRuleService( + MaskingRuleMapper mapper, + UserMapper userMapper, + ProtectedObjectService protectedObjectService, + AuditService auditService, + MaskingPolicySynchronizer maskingPolicySynchronizer + ) { + this.mapper = mapper; + this.userMapper = userMapper; + this.protectedObjectService = protectedObjectService; + this.auditService = auditService; + this.maskingPolicySynchronizer = maskingPolicySynchronizer; + } + + public List findAllRules() { + return mapper.findAllRules(); + } + + public List findEnabledRules() { + return mapper.findEnabledRules(); + } + + public List findColumnRules() { + return mapper.findColumnRules(); + } + + /** Reads the actual Oracle Data Redaction state for the three managed KB objects. */ + public List findPolicyStatuses() { + return mapper.findPolicyStatuses(); + } + + public Set managedObjectNames() { + return maskingPolicySynchronizer.managedObjectNames(); + } + + public List findUserRules() { + return mapper.findUserRules(); + } + + @Transactional + public void createRule(MaskingRuleCreateCommand command) { + String code = normalizeCode(command.ruleCode()); + String name = normalizeRequired(command.ruleName(), 100, "규칙명"); + String templateCode = normalizeTemplate(command.templateCode()); + String description = normalizeOptional(command.description(), 400, "설명"); + if (mapper.findRuleByCode(code) != null) { + throw new AppException("이미 등록된 컬럼 마스킹 규칙 코드입니다: " + code); + } + long ruleId = mapper.nextRuleId(); + mapper.insertRule(ruleId, new MaskingRuleCreateCommand(code, name, templateCode, description)); + auditService.record(new AuditEvent("MASKING_RULE_CREATED", null, null, "SUCCESS", null, null, + "ruleId=" + ruleId + ",code=" + code + ",template=" + templateCode)); + } + + @Transactional + public MaskingPolicySynchronizer.MaskingPolicySyncResult setRuleActive(long ruleId, boolean active) { + if (mapper.updateRuleActive(ruleId, active ? "Y" : "N") == 0) { + throw new AppException("컬럼 마스킹 규칙을 찾을 수 없습니다."); + } + auditService.record(new AuditEvent("MASKING_RULE_ACTIVE_CHANGED", null, null, "SUCCESS", null, null, + "ruleId=" + ruleId + ",active=" + active)); + return synchronizeDatabasePolicies(); + } + + @Transactional + public MaskingPolicySynchronizer.MaskingPolicySyncResult assignRuleToColumn(long columnId, long ruleId) { + var column = protectedObjectService.findColumn(columnId); + if (column == null) { + throw new AppException("보호 컬럼을 찾을 수 없습니다."); + } + if (!column.sensitive()) { + throw new AppException("민감 표시 보호 컬럼에만 컬럼 마스킹 규칙을 연결할 수 있습니다."); + } + MaskingRule rule = requireEnabledRule(ruleId); + mapper.upsertColumnRule(columnId, ruleId); + auditService.record(new AuditEvent("COLUMN_MASKING_RULE_ASSIGNED", null, column.objectId(), "SUCCESS", null, null, + "columnId=" + columnId + ",rule=" + rule.ruleCode())); + return synchronizeDatabasePolicies(); + } + + @Transactional + public MaskingPolicySynchronizer.MaskingPolicySyncResult addTargetColumn(long objectId, String columnName) { + ProtectedObject object = protectedObjectService.assertEnabled(objectId); + if (!maskingPolicySynchronizer.isManagedObject(object.objectName())) { + throw new AppException("ASO 마스킹 정책 관리 대상 객체가 아닙니다: " + object.objectName()); + } + var column = protectedObjectService.addSensitiveColumnTarget(objectId, columnName); + auditService.record(new AuditEvent("MASKING_TARGET_COLUMN_REGISTERED", null, objectId, "SUCCESS", null, null, + object.objectName() + "." + column.columnName())); + return synchronizeDatabasePolicies(); + } + + @Transactional + public MaskingPolicySynchronizer.MaskingPolicySyncResult removeRuleFromColumn(long columnId) { + mapper.deleteUserRulesForColumn(columnId); + if (mapper.deleteColumnRule(columnId) == 0) { + throw new AppException("해제할 컬럼 마스킹 규칙을 찾을 수 없습니다."); + } + auditService.record(new AuditEvent("COLUMN_MASKING_RULE_REMOVED", null, null, "SUCCESS", null, null, + "columnId=" + columnId)); + return synchronizeDatabasePolicies(); + } + + /** + * Reconciles the current metadata with Oracle Data Redaction. This is exposed for the one-time + * repair of settings saved before automatic synchronization was introduced. + */ + @Transactional + public MaskingPolicySynchronizer.MaskingPolicySyncResult synchronizeDatabasePolicies() { + MaskingPolicySynchronizer.MaskingPolicySyncResult result = maskingPolicySynchronizer.synchronize(); + auditService.record(new AuditEvent("MASKING_POLICY_SYNCHRONIZED", null, null, "SUCCESS", null, null, + "disabled=" + result.disabledPolicies() + ",enabled=" + result.enabledPolicies() + + ",added=" + result.addedColumns() + ",modified=" + result.modifiedColumns() + + ",dropped=" + result.droppedColumns())); + return result; + } + + @Transactional + public void assignUserRule(long userId, long columnId, String decision) { + if (userMapper.findById(userId) == null) { + throw new AppException("사용자를 찾을 수 없습니다."); + } + ColumnMaskingRule columnRule = mapper.findColumnRule(columnId); + if (columnRule == null || !columnRule.ruleEnabled()) { + throw new AppException("먼저 활성 컬럼 마스킹 규칙을 민감 컬럼에 연결하세요."); + } + String normalizedDecision = normalizeDecision(decision); + mapper.upsertUserRule(userId, columnId, normalizedDecision); + auditService.record(new AuditEvent("USER_MASKING_RULE_ASSIGNED", userId, columnRule.objectId(), "SUCCESS", null, null, + "columnId=" + columnId + ",decision=" + normalizedDecision)); + } + + @Transactional + public void removeUserRule(long userId, long columnId) { + if (mapper.deleteUserRule(userId, columnId) == 0) { + throw new AppException("해제할 사용자별 컬럼 마스킹 규칙을 찾을 수 없습니다."); + } + auditService.record(new AuditEvent("USER_MASKING_RULE_REMOVED", userId, null, "SUCCESS", null, null, + "columnId=" + columnId)); + } + + private MaskingRule requireEnabledRule(long ruleId) { + MaskingRule rule = mapper.findRuleById(ruleId); + if (rule == null || !rule.enabled()) { + throw new AppException("활성 컬럼 마스킹 규칙을 선택하세요."); + } + return rule; + } + + private String normalizeCode(String value) { + String normalized = normalizeRequired(value, 64, "규칙 코드").toUpperCase(Locale.ROOT); + if (!RULE_CODE.matcher(normalized).matches()) { + throw new AppException("규칙 코드는 영문 대문자·숫자·밑줄로 3~64자여야 합니다."); + } + return normalized; + } + + private String normalizeTemplate(String value) { + try { + return MaskingTemplate.from(normalizeRequired(value, 30, "마스킹 템플릿")).code(); + } catch (IllegalArgumentException exception) { + throw new AppException(exception.getMessage()); + } + } + + private String normalizeDecision(String value) { + String normalized = normalizeRequired(value, 10, "사용자별 적용 방식").toUpperCase(Locale.ROOT); + if (!DECISIONS.contains(normalized)) { + throw new AppException("사용자별 적용 방식은 MASK 또는 UNMASK만 가능합니다."); + } + return normalized; + } + + private String normalizeRequired(String value, int maxLength, String label) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isEmpty()) { + throw new AppException(label + "은(는) 필수입니다."); + } + if (normalized.length() > maxLength) { + throw new AppException(label + "은(는) " + maxLength + "자 이내여야 합니다."); + } + return normalized; + } + + private String normalizeOptional(String value, int maxLength, String label) { + String normalized = value == null ? "" : value.trim(); + if (normalized.length() > maxLength) { + throw new AppException(label + "은(는) " + maxLength + "자 이내여야 합니다."); + } + return normalized.isEmpty() ? null : normalized; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java index 962b613..2bb1cf1 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java @@ -130,7 +130,7 @@ public class McpChatbotService { 선택한 tool: %s 라우팅 근거: %s - Bearer Token이 없어 ORDS tools/call은 실행하지 않았습니다. 토큰을 입력하면 실제 VPD/ORDS 결과까지 조회합니다. + Bearer Token이 없어 ORDS tools/call은 실행하지 않았습니다. 토큰을 입력하면 실제 ORDS 행 접근 결과까지 조회합니다. """.formatted(tool.name(), routingReason); } @@ -141,13 +141,13 @@ public class McpChatbotService { int rowCount = payload.path("rowCount").asInt(0); JsonNode maskedColumns = payload.path("maskedColumns"); return """ - 질문을 MCP tool로 라우팅해 ORDS/VPD 결과를 조회했습니다. + 질문을 MCP tool로 라우팅해 ORDS 행 접근 결과를 조회했습니다. 선택한 tool: %s 라우팅 근거: %s ORDS 상태: %s 반환 행 수: %d - NULL 처리 컬럼: %s + ASO 마스킹 확인 컬럼: %s 질문: %s """.formatted(tool.name(), routingReason, status, rowCount, maskedColumns.toString(), question); @@ -155,7 +155,7 @@ public class McpChatbotService { private String systemPrompt() { return """ - 당신은 Oracle ORDS/VPD MCP 라우팅 결과를 설명하는 운영 보조자입니다. + 당신은 Oracle ORDS 행 접근 MCP 라우팅 결과를 설명하는 운영 보조자입니다. 제공된 MCP tool 결과 JSON만 근거로 한국어로 간결하게 답변하세요. Bearer Token 원문은 절대 출력하지 마세요. """; @@ -180,7 +180,7 @@ public class McpChatbotService { 답변 형식: 1. 한 문장 요약 2. 선택한 tool과 근거 - 3. 행 필터/컬럼 NULL 처리/오류 여부 + 3. 행 접근 필터(VPD)/ASO 컬럼 마스킹/오류 여부 4. 운영자가 다음에 확인할 것 """.formatted(question, tool.name(), tool.displayName(), tool.ordsPath(), routingReason, clientResult.toolsCallResponse()); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java index 866b115..9bf2bf2 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java @@ -134,7 +134,7 @@ public class McpReasoningService { private String buildPrompt(String question, McpToolView tool, String evidenceJson) { String normalizedQuestion = question == null || question.isBlank() - ? "요약부터 작성해줘. 이 ORDS/VPD 검증 결과에서 조회 행 수, 주요 식별자, NULL 처리 여부, 권한 범위, 다음 확인 조치를 정리해줘." + ? "요약부터 작성해줘. 이 ORDS 행 접근 검증 결과에서 조회 행 수, 주요 식별자, ASO 마스킹 여부, 권한 범위, 다음 확인 조치를 정리해줘." : question.trim(); return """ 질문: @@ -155,13 +155,13 @@ public class McpReasoningService { - 그 다음 "## 판단 근거" 섹션에 표를 사용해 rowCount, maskedColumns, status, errorCode를 정리한다. - 그 다음 "## 상세" 섹션에서 반환 행과 권한 범위를 설명한다. - 마지막 "## 다음 조치" 섹션은 운영자가 확인할 항목만 짧게 쓴다. - - VPD 행 필터, 컬럼 NULL 처리, ORDS 오류 여부를 구분한다. + - 행 접근 필터(VPD), ASO 컬럼 마스킹, ORDS 오류 여부를 구분한다. """.formatted(normalizedQuestion, tool.name(), tool.displayName(), tool.ordsPath(), evidenceJson); } private String systemPrompt() { return """ - 당신은 Oracle ADB VPD/Redaction/ORDS 권한 검증 보조자입니다. + 당신은 Oracle ADB 행 접근(VPD)/Redaction/ORDS 권한 검증 보조자입니다. 백오피스가 제공한 도구 실행 증거만 근거로 판단하고, 토큰 원문이나 비밀 값을 재출력하지 마세요. """; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 1c852aa..1765adf 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -1,8 +1,6 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView; -import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand; -import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -10,30 +8,41 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.List; import org.springframework.stereotype.Service; +/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */ @Service public class McpSseService { - private static final String SELECT_AI_ROUTER_TOOL = "ords.agent.kb_select_ai_router"; - private static final String SELECT_AI_ROUTER_PATH = "cb-ords/kb-select-ai-agent/run"; + private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd"; + private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query"; + private static final String SELECT_AI_VPD_QUERY_PROFILE = + "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1"; + private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView( + SELECT_AI_VPD_QUERY_TOOL, + "GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.", + -1L, + "KB Select AI 행 접근 자연어 조회", + SELECT_AI_VPD_QUERY_PATH + ); - private final McpToolRegistry toolRegistry; - private final OrdsProbeService ordsProbeService; private final SelectAiAgentOrdsService selectAiAgentOrdsService; private final ObjectMapper objectMapper; public McpSseService( - McpToolRegistry toolRegistry, - OrdsProbeService ordsProbeService, SelectAiAgentOrdsService selectAiAgentOrdsService, ObjectMapper objectMapper ) { - this.toolRegistry = toolRegistry; - this.ordsProbeService = ordsProbeService; this.selectAiAgentOrdsService = selectAiAgentOrdsService; this.objectMapper = objectMapper; } public ObjectNode handle(String contextPath, JsonNode request) { + return handle(contextPath, request, ""); + } + + /** + * The HTTP bearer token is the business-user subject token; no separate MCP token is used. + */ + public ObjectNode handle(String contextPath, JsonNode request, String vpdBearerToken) { ObjectNode response = objectMapper.createObjectNode(); response.put("jsonrpc", "2.0"); if (request != null && request.has("id")) { @@ -41,12 +50,13 @@ public class McpSseService { } String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText(); + JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params"); try { response.set("result", switch (method) { case "initialize" -> initializeResult(contextPath); case "notifications/initialized" -> objectMapper.createObjectNode(); case "tools/list" -> toolsListResult(); - case "tools/call" -> toolsCallResult(request.path("params")); + case "tools/call" -> toolsCallResult(parameters, vpdBearerToken); default -> throw new AppException("지원하지 않는 MCP method입니다: " + method); }); } catch (Exception e) { @@ -59,6 +69,11 @@ public class McpSseService { return response; } + /** The only tool registered by this MCP server. */ + public List registeredTools() { + return List.of(SELECT_AI_VPD_QUERY_VIEW); + } + private ObjectNode initializeResult(String contextPath) { ObjectNode result = objectMapper.createObjectNode(); result.put("protocolVersion", "2024-11-05"); @@ -75,83 +90,35 @@ public class McpSseService { private ObjectNode toolsListResult() { ObjectNode result = objectMapper.createObjectNode(); ArrayNode tools = objectMapper.createArrayNode(); - for (McpToolView tool : toolRegistry.listTools()) { - ObjectNode item = objectMapper.createObjectNode(); - item.put("name", tool.name()); - item.put("description", tool.description()); - item.set("inputSchema", inputSchema(tool)); - tools.add(item); - } - tools.add(selectAiRouterTool()); + tools.add(selectAiVpdQueryTool()); result.set("tools", tools); return result; } - private ObjectNode inputSchema(McpToolView tool) { + private ObjectNode selectAiVpdQueryTool() { + ObjectNode item = objectMapper.createObjectNode(); + item.put("name", SELECT_AI_VPD_QUERY_TOOL); + item.put("description", SELECT_AI_VPD_QUERY_VIEW.description()); + ObjectNode schema = objectMapper.createObjectNode(); schema.put("type", "object"); ObjectNode properties = objectMapper.createObjectNode(); - ObjectNode bearerToken = objectMapper.createObjectNode(); - bearerToken.put("type", "string"); - bearerToken.put("description", "ORDS 호출에 사용할 Bearer Token 원문"); - properties.set("bearerToken", bearerToken); + ObjectNode prompt = objectMapper.createObjectNode(); + prompt.put("type", "string"); + prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다."); + prompt.put("maxLength", 4000); + properties.set("prompt", prompt); ObjectNode limit = objectMapper.createObjectNode(); limit.put("type", "integer"); - limit.put("description", "조회 row 제한. 1부터 500까지 허용"); + limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다."); limit.put("minimum", 1); - limit.put("maximum", 500); + limit.put("maximum", 100); properties.set("limit", limit); schema.set("properties", properties); ArrayNode required = objectMapper.createArrayNode(); - required.add("bearerToken"); - if (isVectorTool(tool)) { - ObjectNode embedding = objectMapper.createObjectNode(); - embedding.put("type", "array"); - embedding.put("description", "외부 임베딩 모델이 만든 검색 벡터. 개발 환경에서는 4차원 벡터를 사용합니다."); - ObjectNode items = objectMapper.createObjectNode(); - items.put("type", "number"); - embedding.set("items", items); - embedding.put("minItems", 1); - properties.set("embedding", embedding); - required.add("embedding"); - } - schema.set("required", required); - schema.put("additionalProperties", false); - return schema; - } - - private ObjectNode selectAiRouterTool() { - ObjectNode item = objectMapper.createObjectNode(); - item.put("name", SELECT_AI_ROUTER_TOOL); - item.put("description", "Bearer Token으로 ORDS Select AI Team을 호출해 KB 원장 질의용 SQL을 생성합니다. Team의 VPD 컨텍스트가 적용됩니다."); - - ObjectNode schema = objectMapper.createObjectNode(); - schema.put("type", "object"); - ObjectNode properties = objectMapper.createObjectNode(); - - ObjectNode bearerToken = objectMapper.createObjectNode(); - bearerToken.put("type", "string"); - bearerToken.put("description", "ORDS 호출에 사용할 Bearer Token 원문"); - properties.set("bearerToken", bearerToken); - - ObjectNode prompt = objectMapper.createObjectNode(); - prompt.put("type", "string"); - prompt.put("description", "KB 원장에 대해 생성할 SQL을 자연어로 요청합니다. 이 Team은 읽기 전용 SHOWSQL 생성만 허용합니다."); - prompt.put("maxLength", 8000); - properties.set("prompt", prompt); - - ObjectNode conversationId = objectMapper.createObjectNode(); - conversationId.put("type", "string"); - conversationId.put("description", "선택값. 동일 대화 흐름을 이어갈 때 사용하는 안전한 식별자"); - conversationId.put("pattern", "^[A-Za-z0-9._:-]{1,128}$"); - properties.set("conversationId", conversationId); - - schema.set("properties", properties); - ArrayNode required = objectMapper.createArrayNode(); - required.add("bearerToken"); required.add("prompt"); schema.set("required", required); schema.put("additionalProperties", false); @@ -159,67 +126,32 @@ public class McpSseService { return item; } - private ObjectNode toolsCallResult(JsonNode params) { + private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) { String toolName = params.path("name").asText(""); + if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) { + throw new AppException("등록되지 않은 MCP tool입니다: " + toolName); + } + JsonNode arguments = params.path("arguments"); - if (SELECT_AI_ROUTER_TOOL.equals(toolName)) { - return selectAiRouterCallResult(arguments); + String token = vpdBearerToken == null ? "" : vpdBearerToken.trim(); + if (token.isBlank()) { + return tokenAccessDeniedResult(); } - McpToolView tool = findTool(toolName); - String bearerToken = arguments.path("bearerToken").asText(""); - int limit = normalizeLimit(arguments.path("limit").asInt(50)); - String requestBody = null; - if (isVectorTool(tool)) { - JsonNode embedding = arguments.get("embedding"); - if (embedding == null || !embedding.isArray() || embedding.isEmpty()) { - throw new AppException("벡터 검색 tool에는 embedding 배열이 필요합니다."); - } - ObjectNode body = objectMapper.createObjectNode(); - body.set("embedding", embedding); - requestBody = body.toString(); + JsonNode response; + try { + response = selectAiAgentOrdsService.run( + token, + arguments.path("prompt").asText(""), + normalizeLimit(arguments.path("limit").asInt(50)) + ); + } catch (VpdTokenAccessDeniedException ignored) { + return tokenAccessDeniedResult(); } - ProbeResult probeResult = ordsProbeService.runProbe( - new ProbeCommand(null, tool.objectId(), bearerToken, limit, requestBody)); ObjectNode payload = objectMapper.createObjectNode(); - payload.put("toolName", tool.name()); - payload.put("objectId", tool.objectId()); - payload.put("object", tool.displayName()); - payload.put("ordsPath", tool.ordsPath()); - payload.put("status", probeResult.status().name()); - payload.put("rowCount", probeResult.rowCount()); - payload.set("columns", objectMapper.valueToTree(probeResult.columns())); - payload.set("maskedColumns", objectMapper.valueToTree(probeResult.maskedColumns())); - payload.set("rows", objectMapper.valueToTree(probeResult.rows())); - payload.put("errorCode", probeResult.errorCode()); - payload.put("errorMessage", probeResult.errorMessage()); - payload.put("requestHeaders", probeResult.requestHeaders()); - payload.put("requestPayload", probeResult.requestPayload()); - payload.put("responseHeaders", probeResult.responseHeaders()); - payload.put("responseBody", probeResult.responseBody()); - - ObjectNode result = objectMapper.createObjectNode(); - ArrayNode content = objectMapper.createArrayNode(); - ObjectNode text = objectMapper.createObjectNode(); - text.put("type", "text"); - text.put("text", pretty(payload)); - content.add(text); - result.set("content", content); - result.put("isError", probeResult.errorCode() != null); - return result; - } - - private ObjectNode selectAiRouterCallResult(JsonNode arguments) { - JsonNode response = selectAiAgentOrdsService.run( - arguments.path("bearerToken").asText(""), - arguments.path("prompt").asText(""), - arguments.path("conversationId").asText("") - ); - - ObjectNode payload = objectMapper.createObjectNode(); - payload.put("toolName", SELECT_AI_ROUTER_TOOL); - payload.put("team", "KB_SELECT_AI_ROUTER_TEAM"); - payload.put("ordsPath", SELECT_AI_ROUTER_PATH); + payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL); + payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE); + payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH); payload.set("response", response); ObjectNode result = objectMapper.createObjectNode(); @@ -233,23 +165,27 @@ public class McpSseService { return result; } - private McpToolView findTool(String toolName) { - List tools = toolRegistry.listTools(); - return tools.stream() - .filter(tool -> tool.name().equals(toolName)) - .findFirst() - .orElseThrow(() -> new AppException("MCP tool을 찾을 수 없습니다: " + toolName)); - } + private ObjectNode tokenAccessDeniedResult() { + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("status", "VPD_TOKEN_DENIED"); + payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다."); - private boolean isVectorTool(McpToolView tool) { - return tool != null && tool.displayName().toUpperCase().endsWith("CB_VECTOR_SEARCH_DOCUMENTS"); + ObjectNode result = objectMapper.createObjectNode(); + ArrayNode content = objectMapper.createArrayNode(); + ObjectNode text = objectMapper.createObjectNode(); + text.put("type", "text"); + text.put("text", pretty(payload)); + content.add(text); + result.set("content", content); + result.put("isError", true); + return result; } private int normalizeLimit(int limit) { if (limit < 1) { return 50; } - return Math.min(limit, 500); + return Math.min(limit, 100); } private String pretty(Object value) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java index 3608eb7..8cfa90c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java @@ -33,7 +33,7 @@ public class McpToolRegistry { return new McpToolView( name, object.descriptionOrDefault() + " · " - + object.displayName() + "을 Bearer Token으로 ORDS 호출해 VPD/표시 보호 결과를 조회합니다." + vectorHint, + + object.displayName() + "을 Bearer Token으로 ORDS 호출해 행 접근/컬럼 표시 결과를 조회합니다." + vectorHint, object.objectId(), object.displayName(), object.ordsPath() diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java new file mode 100644 index 0000000..682fbe5 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OciGenerativeAiChatClient.java @@ -0,0 +1,231 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider; +import com.oracle.bmc.model.BmcException; +import com.oracle.bmc.generativeaiinference.GenerativeAiInferenceClient; +import com.oracle.bmc.generativeaiinference.model.ChatDetails; +import com.oracle.bmc.generativeaiinference.model.ChatChoice; +import com.oracle.bmc.generativeaiinference.model.BaseChatResponse; +import com.oracle.bmc.generativeaiinference.model.GenericChatRequest; +import com.oracle.bmc.generativeaiinference.model.GenericChatResponse; +import com.oracle.bmc.generativeaiinference.model.JsonSchemaResponseFormat; +import com.oracle.bmc.generativeaiinference.model.OnDemandServingMode; +import com.oracle.bmc.generativeaiinference.model.ResponseJsonSchema; +import com.oracle.bmc.generativeaiinference.model.SystemMessage; +import com.oracle.bmc.generativeaiinference.model.TextContent; +import com.oracle.bmc.generativeaiinference.model.UserMessage; +import com.oracle.bmc.generativeaiinference.requests.ChatRequest; +import com.oracle.bmc.generativeaiinference.responses.ChatResponse; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.springframework.stereotype.Service; +import org.springframework.core.env.Environment; + +/** + * Calls OCI Generative AI through the operator-managed OCI configuration profile. + * + *

The application neither reads nor stores a private key itself. OCI's SDK + * reads the path/profile supplied through the service environment and signs the + * request. This client is intentionally limited to non-streaming text chat. + * It does not execute SQL or hand the model a database connection.

+ */ +@Service +public class OciGenerativeAiChatClient { + + private final Environment environment; + private final ObjectMapper objectMapper; + + public OciGenerativeAiChatClient(Environment environment, ObjectMapper objectMapper) { + this.environment = environment; + this.objectMapper = objectMapper; + } + + public boolean configured() { + return missingConfigurationNames().isEmpty(); + } + + /** Returns setting names only; no configured value or credential is ever exposed. */ + public String configurationHint() { + List missing = missingConfigurationNames(); + return missing.isEmpty() ? "" : String.join(", ", missing); + } + + private List missingConfigurationNames() { + OciSettings settings = settings(); + return Stream.of( + setting(settings.enabled(), "BACKOFFICE_AI_ENABLED=true"), + setting("oci".equalsIgnoreCase(settings.provider()), "BACKOFFICE_AI_PROVIDER=oci"), + setting(hasText(settings.ociConfigFile()), "BACKOFFICE_AI_OCI_CONFIG_FILE"), + setting(hasText(settings.ociProfile()), "BACKOFFICE_AI_OCI_PROFILE"), + setting(hasText(settings.ociRegion()), "BACKOFFICE_AI_OCI_REGION"), + setting(hasText(settings.ociCompartmentId()), "BACKOFFICE_AI_OCI_COMPARTMENT_ID"), + setting(hasText(settings.baseUrl()), "BACKOFFICE_AI_BASE_URL"), + setting(hasText(settings.model()), "BACKOFFICE_AI_MODEL")) + .filter(value -> value != null) + .toList(); + } + + public String modelName() { + return settings().model(); + } + + /** Sends a bounded, source-only request and returns the first textual choice. */ + public String chat(String systemPrompt, String userPrompt) { + if (!configured()) { + throw new AppException("OCI AI 호출 설정이 없습니다."); + } + + OciSettings ai = settings(); + try (GenerativeAiInferenceClient client = GenerativeAiInferenceClient.builder() + .region(ai.ociRegion()) + .build(new ConfigFileAuthenticationDetailsProvider(ai.ociConfigFile(), ai.ociProfile()))) { + client.setEndpoint(ai.baseUrl()); + ChatResponse response = client.chat(ChatRequest.builder() + .chatDetails(ChatDetails.builder() + .compartmentId(ai.ociCompartmentId()) + .servingMode(OnDemandServingMode.builder().modelId(ai.model()).build()) + .chatRequest(GenericChatRequest.builder() + .messages(List.of( + SystemMessage.builder().content(List.of(text(systemPrompt))).build(), + UserMessage.builder().content(List.of(text(userPrompt))).build())) + // SQL source plus block-level commentary can exceed 1,200 + // completion tokens. GPT-5.5 can consume reasoning tokens + // before producing text, so leave a bounded 4K response budget. + .maxCompletionTokens(4_096) + // GPT-5.5 rejects temperature. The verified PoC route sends + // no temperature and explicitly requests one non-streaming response. + .isStream(false) + // Mirror the working PoC route: force a named JSON field so + // GPT-5.5 returns final text rather than only reasoning output. + .responseFormat(explanationResponseFormat()) + .build()) + .build()) + .build()); + return extractText(response); + } catch (AppException exception) { + throw exception; + } catch (BmcException exception) { + // Keep the operational hint useful without returning OCI's raw body, + // request IDs, request content, or any authentication detail to a browser. + throw new AppException("OCI Generative AI 설명 호출에 실패했습니다 (HTTP " + + exception.getStatusCode() + ")."); + } catch (Exception exception) { + throw new AppException("OCI Generative AI 설명 호출에 실패했습니다 (" + + exception.getClass().getSimpleName() + ")."); + } + } + + private TextContent text(String value) { + return TextContent.builder().text(value).build(); + } + + private String extractText(ChatResponse response) { + if (response == null || response.getChatResult() == null) { + throw new AppException("OCI Generative AI 응답 본문이 없습니다."); + } + BaseChatResponse baseResponse = response.getChatResult().getChatResponse(); + if (!(baseResponse instanceof GenericChatResponse generic)) { + throw new AppException("OCI Generative AI 응답 형식이 예상과 다릅니다 (" + + safeType(baseResponse) + ")."); + } + if (generic.getChoices() == null || generic.getChoices().isEmpty()) { + throw new AppException("OCI Generative AI 응답에 선택 결과가 없습니다."); + } + ChatChoice choice = generic.getChoices().getFirst(); + if (choice.getMessage() == null || choice.getMessage().getContent() == null) { + throw new AppException("OCI Generative AI 응답에 메시지 콘텐츠가 없습니다."); + } + List contents = choice.getMessage().getContent(); + String result = contents.stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::getText) + .filter(this::hasText) + .reduce("", String::concat); + if (result.isBlank()) { + throw new AppException("OCI Generative AI 응답에 텍스트가 없습니다 (콘텐츠: " + + contents.stream().map(this::safeType).distinct().reduce((left, right) -> left + ", " + right) + .orElse("없음") + ", 종료: " + safeFinishReason(choice.getFinishReason()) + ")."); + } + return explanationFromJson(result); + } + + private JsonSchemaResponseFormat explanationResponseFormat() { + Map schema = Map.of( + "type", "object", + "properties", Map.of("explanation", Map.of("type", "string")), + "required", List.of("explanation"), + "additionalProperties", false + ); + return JsonSchemaResponseFormat.builder() + .jsonSchema(ResponseJsonSchema.builder() + .name("security_sql_explanation") + .description("Markdown explanation for an approved read-only security SQL script") + .schema(schema) + .isStrict(true) + .build()) + .build(); + } + + private String explanationFromJson(String json) { + try { + JsonNode explanation = objectMapper.readTree(json).path("explanation"); + if (explanation.isTextual() && !explanation.asText().isBlank()) { + return explanation.asText(); + } + } catch (Exception ignored) { + // Fall through to the safe, actionable message below. The model output + // is untrusted and is never echoed into an exception message. + } + throw new AppException("OCI Generative AI 응답에 explanation JSON 필드가 없습니다."); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private String setting(boolean configured, String name) { + return configured ? null : name; + } + + private String safeType(Object value) { + return value == null ? "없음" : value.getClass().getSimpleName(); + } + + private String safeFinishReason(String value) { + return value == null || value.isBlank() ? "없음" : value.replaceAll("[^A-Za-z0-9_-]", ""); + } + + /** + * Reads the service environment directly. This is deliberate: OCI SDK API-key + * configuration is supplied by the operator's .env/EnvironmentFile, not by + * database data or a browser request. Values are never rendered or logged. + */ + private OciSettings settings() { + return new OciSettings( + Boolean.parseBoolean(environment.getProperty("BACKOFFICE_AI_ENABLED", "false")), + environment.getProperty("BACKOFFICE_AI_PROVIDER", ""), + environment.getProperty("BACKOFFICE_AI_BASE_URL", ""), + environment.getProperty("BACKOFFICE_AI_MODEL", ""), + environment.getProperty("BACKOFFICE_AI_OCI_CONFIG_FILE", ""), + environment.getProperty("BACKOFFICE_AI_OCI_PROFILE", ""), + environment.getProperty("BACKOFFICE_AI_OCI_REGION", ""), + environment.getProperty("BACKOFFICE_AI_OCI_COMPARTMENT_ID", "") + ); + } + + private record OciSettings( + boolean enabled, + String provider, + String baseUrl, + String model, + String ociConfigFile, + String ociProfile, + String ociRegion, + String ociCompartmentId + ) { + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java index 07fdd7f..3117b3d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -46,19 +46,19 @@ public class PermissionService { private final PermissionMapper permissionMapper; private final ProtectedObjectService protectedObjectService; private final AuditService auditService; - private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier; + private final ExternalAuthorizationChangeNotifier authorizationChangeNotifier; @Autowired public PermissionService( PermissionMapper permissionMapper, ProtectedObjectService protectedObjectService, AuditService auditService, - DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier + ExternalAuthorizationChangeNotifier authorizationChangeNotifier ) { this.permissionMapper = permissionMapper; this.protectedObjectService = protectedObjectService; this.auditService = auditService; - this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier; + this.authorizationChangeNotifier = authorizationChangeNotifier; } public PermissionService( @@ -66,7 +66,7 @@ public class PermissionService { ProtectedObjectService protectedObjectService, AuditService auditService ) { - this(permissionMapper, protectedObjectService, auditService, DdsAuthorizationChangeNotifier.noop()); + this(permissionMapper, protectedObjectService, auditService, ExternalAuthorizationChangeNotifier.noop()); } public List findRoles() { @@ -90,7 +90,7 @@ public class PermissionService { long roleId = permissionMapper.nextRoleId(); permissionMapper.insertRole(roleId, roleName.trim(), description, normalizeSensitivityLevel(maxSensitivityLevel)); auditService.record(new AuditEvent("ROLE_CREATED", null, null, "SUCCESS", null, null, roleName)); - ddsAuthorizationChangeNotifier.changed("ROLE_CREATED"); + authorizationChangeNotifier.changed("ROLE_CREATED"); } @Transactional @@ -102,7 +102,7 @@ public class PermissionService { } auditService.record(new AuditEvent("ROLE_MAX_SENSITIVITY_UPDATED", null, null, "SUCCESS", null, null, "roleId=" + roleId + ", max=" + normalized)); - ddsAuthorizationChangeNotifier.changed("ROLE_MAX_SENSITIVITY_UPDATED"); + authorizationChangeNotifier.changed("ROLE_MAX_SENSITIVITY_UPDATED"); } @Transactional @@ -128,7 +128,7 @@ public class PermissionService { throw new AppException("삭제할 역할을 찾을 수 없습니다."); } auditService.record(new AuditEvent("ROLE_DELETED", null, null, "SUCCESS", null, null, "roleId=" + roleId)); - ddsAuthorizationChangeNotifier.changed("ROLE_DELETED"); + authorizationChangeNotifier.changed("ROLE_DELETED"); } @Transactional @@ -143,7 +143,6 @@ public class PermissionService { } protectedObjectService.assertEnabled(command.objectId()); validateRules(command.objectId(), command.rules()); - validateVisibleColumns(command.objectId(), command.visibleColumns()); Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId()); long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId; @@ -167,17 +166,12 @@ public class PermissionService { } permissionMapper.deleteVisibleColumns(permissionId); - if (command.visibleColumns() != null) { - for (String columnName : command.visibleColumns()) { - permissionMapper.insertVisibleColumn(permissionId, columnName.trim().toUpperCase(Locale.ROOT)); - } - } auditService.record(new AuditEvent( "PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null, "roleId=" + command.roleId() )); - ddsAuthorizationChangeNotifier.changed("PERMISSION_SAVED"); + authorizationChangeNotifier.changed("PERMISSION_SAVED"); return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect, List.of(), List.of()); } @@ -203,7 +197,7 @@ public class PermissionService { } auditService.record(new AuditEvent("PERMISSION_DELETED", null, null, "SUCCESS", null, null, "permissionId=" + permissionId)); - ddsAuthorizationChangeNotifier.changed("PERMISSION_DELETED"); + authorizationChangeNotifier.changed("PERMISSION_DELETED"); } public int countPermissionsByObjectId(long objectId) { @@ -305,21 +299,6 @@ public class PermissionService { } } - private void validateVisibleColumns(long objectId, List visibleColumns) { - if (visibleColumns == null || visibleColumns.isEmpty()) { - return; - } - Set allowed = new HashSet<>(); - for (ProtectedColumn column : protectedObjectService.findColumns(objectId)) { - allowed.add(column.columnName().toUpperCase(Locale.ROOT)); - } - for (String columnName : visibleColumns) { - if (!allowed.contains(columnName.trim().toUpperCase(Locale.ROOT))) { - throw new AppException("등록되지 않은 컬럼입니다: " + columnName); - } - } - } - private String normalize(String value) { return clean(value).toUpperCase(Locale.ROOT); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java index cbe851a..7ca3bea 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java @@ -7,12 +7,17 @@ import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObjectCreateCommand; import com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper; import java.util.Arrays; +import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,13 +26,17 @@ public class ProtectedObjectService { private final ProtectedObjectMapper mapper; private final AuditService auditService; - private volatile CacheEntry> databaseObjectsCache; + private final AtomicReference>> databaseObjectsCache = + new AtomicReference<>(); private final Map>> databaseColumnsCache = new ConcurrentHashMap<>(); private final Map>> protectedColumnsCache = new ConcurrentHashMap<>(); - private static final long CATALOG_CACHE_MILLIS = 60_000L; + // Object and column metadata changes only through this service, which clears + // the affected cache entries. Keep dictionary metadata warm between screens. + private static final long CATALOG_CACHE_MILLIS = 15 * 60_000L; private static final Set SENSITIVITY_LEVELS = Set.of( "PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED"); private static final Set REDACTION_METHODS = Set.of("NONE", "NULLIFY", "PARTIAL", "FULL"); + private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); public ProtectedObjectService(ProtectedObjectMapper mapper, AuditService auditService) { this.mapper = mapper; @@ -43,12 +52,12 @@ public class ProtectedObjectService { } public List findDatabaseObjects() { - CacheEntry> cached = databaseObjectsCache; + CacheEntry> cached = databaseObjectsCache.get(); if (cached != null && !cached.expired()) { return cached.value(); } List objects = List.copyOf(mapper.findDatabaseObjects()); - databaseObjectsCache = new CacheEntry<>(objects, System.currentTimeMillis() + CATALOG_CACHE_MILLIS); + databaseObjectsCache.set(new CacheEntry<>(objects, System.currentTimeMillis() + CATALOG_CACHE_MILLIS)); return objects; } @@ -70,7 +79,7 @@ public class ProtectedObjectService { } if (isLegacyAutoPath(object.ordsPath(), object.owner(), object.objectName())) { mapper.updateOrdsPath(object.objectId(), defaultOrdsPath(object.owner(), object.objectName())); - databaseObjectsCache = null; + databaseObjectsCache.set(null); return mapper.findById(object.objectId()); } return object; @@ -86,6 +95,83 @@ public class ProtectedObjectService { return columns; } + /** + * Loads protected-object columns in one round trip. This is used by the + * permissions page, where requesting each object's columns independently + * turns a single page render into an ADB N+1 query pattern. + */ + public Map> findColumnsByObjectIds(Collection objectIds) { + List requestedIds = objectIds.stream() + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + if (requestedIds.isEmpty()) { + return Map.of(); + } + + List missingIds = new ArrayList<>(); + Map> result = new LinkedHashMap<>(); + for (Long objectId : requestedIds) { + CacheEntry> cached = protectedColumnsCache.get(objectId); + if (cached != null && !cached.expired()) { + result.put(objectId, cached.value()); + } else { + missingIds.add(objectId); + } + } + + if (!missingIds.isEmpty()) { + Map> loaded = new LinkedHashMap<>(); + for (ProtectedColumn column : mapper.findColumnsByObjectIds(missingIds)) { + loaded.computeIfAbsent(column.objectId(), ignored -> new ArrayList<>()).add(column); + } + long expiresAt = System.currentTimeMillis() + CATALOG_CACHE_MILLIS; + for (Long objectId : missingIds) { + List columns = List.copyOf(loaded.getOrDefault(objectId, List.of())); + protectedColumnsCache.put(objectId, new CacheEntry<>(columns, expiresAt)); + result.put(objectId, columns); + } + } + return Map.copyOf(result); + } + + public ProtectedColumn findColumn(long columnId) { + return mapper.findColumnById(columnId); + } + + @Transactional + public ProtectedColumn addSensitiveColumnTarget(long objectId, String columnName) { + ProtectedObject object = assertEnabled(objectId); + String normalizedColumnName = normalizeColumnName(columnName); + Set databaseColumns = new HashSet<>(); + for (String databaseColumn : findDatabaseColumns(object.owner(), object.objectName())) { + databaseColumns.add(databaseColumn.toUpperCase(Locale.ROOT)); + } + if (!databaseColumns.contains(normalizedColumnName)) { + throw new AppException("DB 객체에 존재하지 않는 컬럼입니다: " + + object.owner() + "." + object.objectName() + "." + normalizedColumnName); + } + + ProtectedColumn existing = mapper.findColumnByObjectAndName(objectId, normalizedColumnName); + if (existing != null) { + if (existing.sensitive()) { + return existing; + } + mapper.updateColumnPolicy(existing.columnId(), "CONFIDENTIAL", "FULL"); + protectedColumnsCache.remove(objectId); + auditService.record(new AuditEvent("PROTECTED_COLUMN_MASKING_TARGET_ENABLED", null, objectId, "SUCCESS", null, + null, normalizedColumnName)); + return mapper.findColumnById(existing.columnId()); + } + + long columnId = mapper.nextColumnId(); + mapper.insertColumn(columnId, objectId, normalizedColumnName, "Y", "CONFIDENTIAL", "FULL"); + protectedColumnsCache.remove(objectId); + auditService.record(new AuditEvent("PROTECTED_COLUMN_MASKING_TARGET_ADDED", null, objectId, "SUCCESS", null, null, + normalizedColumnName)); + return mapper.findColumnById(columnId); + } + @Transactional public void createObject(ProtectedObjectCreateCommand command) { ProtectedObjectCreateCommand normalized = normalizeCreateCommand(command); @@ -97,7 +183,7 @@ public class ProtectedObjectService { mapper.insertColumn(mapper.nextColumnId(), objectId, column, sensitiveYn, defaultSensitivityLevel(sensitiveYn), defaultRedactionMethod(sensitiveYn)); } - databaseObjectsCache = null; + databaseObjectsCache.set(null); protectedColumnsCache.remove(objectId); auditService.record(new AuditEvent("PROTECTED_OBJECT_CREATED", null, objectId, "SUCCESS", null, null, normalized.objectName())); @@ -114,7 +200,7 @@ public class ProtectedObjectService { if (isLegacyAutoPath(existing.ordsPath(), normalizedOwner, normalizedObjectName)) { mapper.updateOrdsPath(existing.objectId(), defaultOrdsPath(normalizedOwner, normalizedObjectName)); } - databaseObjectsCache = null; + databaseObjectsCache.set(null); protectedColumnsCache.remove(existing.objectId()); auditService.record(new AuditEvent("PROTECTED_OBJECT_RE_ENABLED", null, existing.objectId(), "SUCCESS", null, null, existing.displayName())); @@ -122,7 +208,7 @@ public class ProtectedObjectService { } if (isLegacyAutoPath(existing.ordsPath(), normalizedOwner, normalizedObjectName)) { mapper.updateOrdsPath(existing.objectId(), defaultOrdsPath(normalizedOwner, normalizedObjectName)); - databaseObjectsCache = null; + databaseObjectsCache.set(null); return mapper.findById(existing.objectId()); } return existing; @@ -145,7 +231,7 @@ public class ProtectedObjectService { for (String column : columns) { mapper.insertColumn(mapper.nextColumnId(), objectId, column, "N", "PUBLIC", "NONE"); } - databaseObjectsCache = null; + databaseObjectsCache.set(null); protectedColumnsCache.remove(objectId); auditService.record(new AuditEvent("PROTECTED_OBJECT_AUTO_CREATED", null, objectId, "SUCCESS", null, null, command.objectName())); @@ -210,7 +296,7 @@ public class ProtectedObjectService { if (updated == 0) { throw new AppException("설명을 수정할 조회 대상을 찾을 수 없습니다."); } - databaseObjectsCache = null; + databaseObjectsCache.set(null); auditService.record(new AuditEvent("PROTECTED_OBJECT_DESCRIPTION_UPDATED", null, objectId, "SUCCESS", null, null, normalized)); } @@ -237,7 +323,7 @@ public class ProtectedObjectService { if (updated == 0) { throw new AppException("보호 객체를 찾을 수 없습니다."); } - databaseObjectsCache = null; + databaseObjectsCache.set(null); protectedColumnsCache.remove(objectId); auditService.record(new AuditEvent("PROTECTED_OBJECT_DISABLED", null, objectId, "SUCCESS", null, null, null)); } @@ -273,6 +359,14 @@ public class ProtectedObjectService { return normalized; } + private String normalizeColumnName(String value) { + String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!COLUMN_NAME.matcher(normalized).matches()) { + throw new AppException("컬럼명은 영문 대문자·숫자·밑줄로 된 DB 컬럼명이어야 합니다."); + } + return normalized; + } + private record CacheEntry(T value, long expiresAt) { boolean expired() { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java new file mode 100644 index 0000000..695beff --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java @@ -0,0 +1,268 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation; +import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn; +import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SchemaMetadataService { + + private static final String OWNER = "POC_2"; + private static final int MAX_COMMENT_LENGTH = 4000; + private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000; + private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); + + private final JdbcTemplate jdbcTemplate; + private final StructuredDataService structuredDataService; + + public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) { + this.jdbcTemplate = jdbcTemplate; + this.structuredDataService = structuredDataService; + } + + public List tables() { + return structuredDataService.tables(); + } + + public String defaultKey() { + return structuredDataService.defaultKey(); + } + + public SchemaMetadataView find(String tableKey) { + StructuredDataTable table = structuredDataService.requireTable(tableKey); + String tableName = table.tableName(); + String tableComment = tableComment(tableName); + Map> annotations = annotationsByTarget(tableName); + List columns = columns(tableName, annotations); + return new SchemaMetadataView( + table, + nullToEmpty(tableComment), + annotations.getOrDefault(tableTargetKey(), List.of()), + columns + ); + } + + @Transactional + public void updateTableComment(String tableKey, String comment) { + StructuredDataTable table = structuredDataService.requireTable(tableKey); + String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment"); + jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName()) + + " IS " + quoteLiteral(normalizedComment)); + } + + @Transactional + public void updateColumnComment(String tableKey, String columnName, String comment) { + StructuredDataTable table = structuredDataService.requireTable(tableKey); + String column = requireColumn(table.tableName(), columnName); + String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment"); + jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "." + + quoteName(column) + " IS " + quoteLiteral(normalizedComment)); + } + + @Transactional + public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) { + StructuredDataTable table = structuredDataService.requireTable(tableKey); + updateAnnotation(table.tableName(), null, annotationName, annotationValue); + } + + @Transactional + public void updateColumnAnnotation( + String tableKey, + String columnName, + String annotationName, + String annotationValue + ) { + StructuredDataTable table = structuredDataService.requireTable(tableKey); + String column = requireColumn(table.tableName(), columnName); + updateAnnotation(table.tableName(), column, annotationName, annotationValue); + } + + private void updateAnnotation( + String tableName, + String columnName, + String annotationName, + String annotationValue + ) { + String key = requireSimpleName(annotationName, "annotation name"); + String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value"); + if (annotationExists(tableName, columnName, key)) { + jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key))); + } + if (!value.isBlank()) { + jdbcTemplate.execute(annotationSql(tableName, columnName, + "ADD " + quoteName(key) + " " + quoteLiteral(value))); + } + } + + private String tableComment(String tableName) { + List values = jdbcTemplate.query(""" + SELECT comments + FROM all_tab_comments + WHERE owner = ? + AND table_name = ? + """, (rs, rowNum) -> rs.getString(1), OWNER, tableName); + return values.isEmpty() ? "" : values.getFirst(); + } + + private List columns( + String tableName, + Map> annotations + ) { + return jdbcTemplate.query(""" + SELECT c.column_name, + CASE + WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR') + THEN c.data_type || '(' || c.char_length || ')' + WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL + THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')' + WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL + THEN c.data_type || '(' || c.data_precision || ')' + ELSE c.data_type + END AS display_type, + c.nullable, + cc.comments + FROM all_tab_columns c + LEFT JOIN all_col_comments cc + ON cc.owner = c.owner + AND cc.table_name = c.table_name + AND cc.column_name = c.column_name + WHERE c.owner = ? + AND c.table_name = ? + ORDER BY c.column_id + """, (rs, rowNum) -> new SchemaMetadataColumn( + rs.getString("column_name"), + rs.getString("display_type"), + "Y".equalsIgnoreCase(rs.getString("nullable")), + nullToEmpty(rs.getString("comments")), + annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of()) + ), OWNER, tableName); + } + + private Map> annotationsByTarget(String tableName) { + Map>> grouped = new LinkedHashMap<>(); + jdbcTemplate.query(""" + SELECT column_name, annotation_name, annotation_value + FROM all_annotations_usage + WHERE object_name = ? + AND object_type = 'TABLE' + ORDER BY column_name NULLS FIRST, annotation_name, annotation_value + """, rs -> { + String target = rs.getString("column_name") == null + ? tableTargetKey() + : columnTargetKey(rs.getString("column_name")); + grouped + .computeIfAbsent(target, ignored -> new LinkedHashMap<>()) + .computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>()) + .add(nullToEmpty(rs.getString("annotation_value"))); + }, tableName); + + Map> result = new LinkedHashMap<>(); + grouped.forEach((target, valuesByName) -> { + List annotations = new ArrayList<>(); + valuesByName.forEach((name, values) -> annotations.add(new SchemaAnnotation( + name, + String.join("\n--- duplicate annotation value ---\n", values) + ))); + result.put(target, annotations); + }); + return result; + } + + private boolean annotationExists(String tableName, String columnName, String annotationName) { + Integer count = columnName == null + ? jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM all_annotations_usage + WHERE object_name = ? + AND object_type = 'TABLE' + AND annotation_name = ? + AND column_name IS NULL + """, Integer.class, tableName, annotationName) + : jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM all_annotations_usage + WHERE object_name = ? + AND object_type = 'TABLE' + AND annotation_name = ? + AND column_name = ? + """, Integer.class, tableName, annotationName, columnName); + return count != null && count > 0; + } + + private String annotationSql(String tableName, String columnName, String operation) { + if (columnName == null) { + return "ALTER TABLE " + qualifiedTable(tableName) + " ANNOTATIONS (" + operation + ")"; + } + return "ALTER TABLE " + qualifiedTable(tableName) + " MODIFY " + quoteName(columnName) + + " ANNOTATIONS (" + operation + ")"; + } + + private String requireColumn(String tableName, String columnName) { + String column = requireSimpleName(columnName, "column name"); + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM all_tab_columns + WHERE owner = ? + AND table_name = ? + AND column_name = ? + """, Integer.class, OWNER, tableName, column); + if (count == null || count == 0) { + throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다."); + } + return column; + } + + private String requireSimpleName(String value, String label) { + if (value == null || value.isBlank()) { + throw new AppException(label + "은(는) 필수입니다."); + } + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!ORACLE_SIMPLE_NAME.matcher(normalized).matches()) { + throw new AppException(label + " 형식이 올바르지 않습니다. 영문 대문자, 숫자, _, $, #만 사용할 수 있습니다."); + } + return normalized; + } + + private String normalizeText(String value, int maxLength, String label) { + String normalized = value == null ? "" : value.trim(); + if (normalized.length() > maxLength) { + throw new AppException(label + "은(는) " + maxLength + "자 이하여야 합니다."); + } + return normalized; + } + + private String qualifiedTable(String tableName) { + return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name")); + } + + private String quoteName(String value) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private String quoteLiteral(String value) { + return "'" + value.replace("'", "''") + "'"; + } + + private String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private String tableTargetKey() { + return ""; + } + + private String columnTargetKey(String columnName) { + return requireSimpleName(columnName, "column name"); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java new file mode 100644 index 0000000..fa38008 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptExplanationService.java @@ -0,0 +1,136 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript; +import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptExplanation; +import java.util.stream.IntStream; +import org.springframework.stereotype.Service; + +/** + * Sends a selected, read-only SQL source to the configured LLM for explanation. + * The source comes from SecuritySqlScriptService's fixed catalogue; neither a + * request value nor an LLM response can modify or execute database SQL. + */ +@Service +public class SecuritySqlScriptExplanationService { + + private final SecuritySqlScriptService scriptService; + private final OciGenerativeAiChatClient aiClient; + + public SecuritySqlScriptExplanationService( + SecuritySqlScriptService scriptService, + OciGenerativeAiChatClient aiClient + ) { + this.scriptService = scriptService; + this.aiClient = aiClient; + } + + public SecuritySqlScriptExplanation explain(String scriptId) { + SecuritySqlScript script = scriptService.find(scriptId); + String prompt = buildPrompt(script); + if (!aiClient.configured()) { + return new SecuritySqlScriptExplanation( + "AI_NOT_CONFIGURED", + aiClient.modelName(), + "OCI AI 호출 설정이 없어 설명을 생성하지 않았습니다. 누락 또는 불일치: " + + aiClient.configurationHint(), + prompt, + script + ); + } + try { + return new SecuritySqlScriptExplanation( + "SUCCESS", + aiClient.modelName(), + aiClient.chat(systemPrompt(), prompt), + prompt, + script + ); + } catch (AppException exception) { + return new SecuritySqlScriptExplanation( + "AI_CALL_FAILED", + aiClient.modelName(), + exception.getMessage(), + prompt, + script + ); + } catch (Exception exception) { + return new SecuritySqlScriptExplanation( + "AI_CALL_FAILED", + aiClient.modelName(), + "AI 설명 호출에 실패했습니다. SQL 원문은 변경되지 않았으며, 잠시 후 다시 시도하세요.", + prompt, + script + ); + } + } + + private String systemPrompt() { + return """ + 당신은 Oracle 보안 운영 SQL을 검토하는 선임 데이터베이스 보안 엔지니어다. + 제공된 source만 근거로 한국어 Markdown 설명을 작성한다. SQL을 실행·수정·제안된 명령으로 바꾸지 않는다. + source에 없는 객체·권한·실행 결과를 추측하지 않는다. 비밀값을 요청하거나 출력하지 않는다. + 최종 응답은 반드시 explanation 키 하나에 Markdown 문자열을 담은 JSON 객체로 반환한다. + """; + } + + private String buildPrompt(SecuritySqlScript script) { + return """ + 다음은 Git 형상에 저장된 Oracle 보안 SQL 스크립트다. 운영자가 코드를 이해할 수 있도록 전체 설명과 부분별 주석을 작성한다. + + [스크립트 메타데이터] + - 분류: %s + - 파일: %s + - 제목: %s + - 용도: %s + + [줄 번호가 붙은 SQL 원문] + %s + + [출력 형식] + ## 전체 설명 + - 이 스크립트가 만드는/변경하는 DB 객체와 목적을 5줄 이내로 설명한다. + - 실행 전제조건, 실행 사용자, 다른 스크립트와의 순서가 source에 있으면 명시한다. + + ## 실행 흐름 + - source의 실제 실행 순서를 번호 목록으로 정리한다. + + ## 블록별 주석 + - 주석 heading, PROMPT, CREATE/ALTER/MERGE/GRANT/DECLARE/BEGIN, PACKAGE, PROCEDURE, FUNCTION 단위로 블록을 나눈다. + - 각 블록은 반드시 `### [L시작-L끝] 블록명` 제목으로 시작한다. + - 각 블록에서 “무엇을 하는지”, “입력/참조 객체”, “보안·VPD·ASO 영향”, “실패/주의점”을 source 근거가 있는 범위에서 bullet로 적는다. + + ## 토큰 처리·사용자 적용 흐름 + - source에 Bearer/Authorization/auth_header/token/key 또는 token을 받아 context를 설정하는 코드가 있으면, 반드시 “입력 → 검증/조회 → CB_AGENT_CTX 등 context 설정 → VPD/ASO/Select AI 적용 → 실패 시 동작” 순서로 설명한다. + - 각 단계에는 source line range와 실제 식별자(예: auth_header, set_vpd_context, SYS_CONTEXT)를 붙인다. + - source가 토큰을 직접 다루지 않는 메타데이터/초기값 스크립트라면 “이 스크립트는 토큰을 직접 검증하거나 사용자별 접근을 판정하지 않는다”라고 명시하고, source 주석/호출 관계에서 확인되는 다음 런타임 단계를 설명한다. + - VPD는 행 접근, ASO/DBMS_REDACT는 컬럼 표시 보호라는 점을 source 근거가 있는 범위에서 구분한다. + - 실제 어떤 사용자가 어떤 행·원문 컬럼을 볼지는 토큰으로 설정된 DB context와 당시 권한 데이터에 따라 확정된다고 구분한다. + - source에 없는 역할명, 사용자명, 권한 결과는 만들지 않는다. + + ## 운영 확인 포인트 + - source에서 직접 확인 가능한 DB 객체·권한·정책·ORDS endpoint를 최대 7개로 정리한다. + + ## 판단 한계 + - source만으로 확인할 수 없는 실행 결과나 권한 효과가 있으면 명시한다. + + [엄격한 규칙] + - Markdown으로 120줄 이내에 작성한다. + - 줄 번호와 SQL 객체명은 제공된 source와 일치해야 한다. + - 일반론이나 추측은 쓰지 않는다. + """.formatted( + script.category(), + script.fileName(), + script.title(), + script.description(), + numberedSource(script.source()) + ); + } + + private String numberedSource(String source) { + String[] lines = source.split("\\R", -1); + return IntStream.range(0, lines.length) + .mapToObj(index -> "%4d | %s".formatted(index + 1, lines[index])) + .reduce((left, right) -> left + "\n" + right) + .orElse(""); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java new file mode 100644 index 0000000..fab300f --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java @@ -0,0 +1,102 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript; +import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +/** + * Read-only catalogue of security deployment SQL bundled from the Git-tracked + * sql/adb directory. Script ids are an application whitelist: request input + * never becomes a filesystem or classpath path. + */ +@Service +public class SecuritySqlScriptService { + + private static final List CURATED_SCRIPTS = List.of( + new ScriptDefinition( + "aso-masking-metadata", + "ASO / 마스킹", + "62_kb_aso_masking_backoffice_metadata.sql", + "컬럼 마스킹 규칙 메타데이터", + "ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다." + ), + new ScriptDefinition( + "aso-masking-runtime", + "ASO / 마스킹", + "63_kb_aso_masking_rule_runtime.sql", + "ASO 마스킹 런타임 적용", + "백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다." + ), + new ScriptDefinition( + "aso-masking-default-columns", + "ASO / 마스킹", + "64_kb_aso_masking_default_column_rules.sql", + "ASO 기본 대상 컬럼", + "주민번호·청구/지급금·타사보유 컬럼의 마스킹 블랙리스트 초기값을 연결합니다." + ), + new ScriptDefinition( + "select-ai-vpd-api", + "Select AI / 행 접근", + "65_kb_select_ai_vpd_query_api.sql", + "행 접근 적용 Select AI 조회 API", + "생성 SQL을 KB 업무 테이블의 단일 읽기 전용 SELECT/WITH로 검증해 행 접근 컨텍스트에서 실행합니다." + ), + new ScriptDefinition( + "select-ai-vpd-ords", + "ORDS / Select AI", + "66_kb_select_ai_vpd_query_ords.sql", + "Select AI 행 접근 ORDS Endpoint", + "Bearer 토큰을 검증해 행 접근 컨텍스트를 설정한 뒤 Select AI 조회 API를 노출합니다." + ) + ); + + public List list() { + return CURATED_SCRIPTS.stream() + .map(definition -> new SecuritySqlScriptSummary( + definition.scriptId(), + definition.category(), + definition.fileName(), + definition.title(), + definition.description() + )) + .toList(); + } + + public SecuritySqlScript find(String scriptId) { + ScriptDefinition definition = CURATED_SCRIPTS.stream() + .filter(candidate -> candidate.scriptId().equals(scriptId)) + .findFirst() + .orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다.")); + return new SecuritySqlScript( + definition.scriptId(), + definition.category(), + definition.fileName(), + definition.title(), + definition.description(), + readSource(definition.fileName()) + ); + } + + private String readSource(String fileName) { + ClassPathResource resource = new ClassPathResource("sql/adb/" + fileName); + try (InputStream input = resource.getInputStream()) { + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new AppException("배포된 보안 SQL 스크립트를 읽을 수 없습니다: " + fileName); + } + } + + private record ScriptDefinition( + String scriptId, + String category, + String fileName, + String title, + String description + ) { + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java index db48a3b..bec7d62 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java @@ -5,24 +5,25 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.net.URI; import java.util.UUID; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; -/** Calls the ORDS boundary; the database endpoint owns bearer-to-VPD-context mapping. */ +/** Calls the ORDS boundary; the database endpoint owns bearer-to-row-access-context mapping. */ @Service public class SelectAiAgentOrdsService { - static final String ORDS_PATH = "/cb-ords/kb-select-ai-agent/run"; - private static final int MAX_PROMPT_LENGTH = 8_000; + static final String ORDS_PATH = "/cb-ords/kb-select-ai-vpd/query"; + private static final int MAX_PROMPT_LENGTH = 4_000; + private static final int MAX_LIMIT = 100; private final SettingService settingService; private final RestTemplate restTemplate; @@ -30,21 +31,29 @@ public class SelectAiAgentOrdsService { public SelectAiAgentOrdsService( SettingService settingService, - @Qualifier("ordsAgentRestTemplate") RestTemplate restTemplate, + RestTemplate ordsAgentRestTemplate, ObjectMapper objectMapper ) { this.settingService = settingService; - this.restTemplate = restTemplate; + this.restTemplate = ordsAgentRestTemplate; this.objectMapper = objectMapper; } + /** + * Compatibility overload for callers of the former SQL-generation tool. + * The row-access query endpoint is stateless and therefore ignores conversationId. + */ public JsonNode run(String bearerToken, String prompt, String conversationId) { + return run(bearerToken, prompt, 50); + } + + public JsonNode run(String bearerToken, String prompt, int limit) { String normalizedToken = required(bearerToken, "bearerToken"); String normalizedPrompt = required(prompt, "prompt"); if (normalizedPrompt.length() > MAX_PROMPT_LENGTH) { throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다."); } - String normalizedConversationId = normalizeConversationId(conversationId); + int normalizedLimit = normalizeLimit(limit); String baseUrl = settingService.ordsBaseUrl(); if (baseUrl == null || baseUrl.isBlank()) { throw new AppException("ORDS base URL이 설정되지 않았습니다."); @@ -52,9 +61,7 @@ public class SelectAiAgentOrdsService { ObjectNode requestBody = objectMapper.createObjectNode(); requestBody.put("prompt", normalizedPrompt); - if (normalizedConversationId != null) { - requestBody.put("conversationId", normalizedConversationId); - } + requestBody.put("limit", normalizedLimit); HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(normalizedToken); @@ -70,14 +77,18 @@ public class SelectAiAgentOrdsService { ); JsonNode body = parse(response.getBody()); if (body.hasNonNull("error")) { - throw new AppException("Select AI Agent ORDS 오류: " + body.path("error").asText()); + throw new AppException("Select AI 행 접근 ORDS 오류: " + body.path("error").asText()); } return body; } catch (HttpStatusCodeException e) { - throw new AppException("Select AI Agent ORDS HTTP " + e.getStatusCode().value() + if (e.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED) + || e.getStatusCode().isSameCodeAs(HttpStatus.FORBIDDEN)) { + throw new VpdTokenAccessDeniedException(); + } + throw new AppException("Select AI 행 접근 ORDS HTTP " + e.getStatusCode().value() + ": " + responseError(e.getResponseBodyAsString())); } catch (ResourceAccessException e) { - throw new AppException("Select AI Agent ORDS 연결 또는 응답 시간 초과: " + e.getMessage()); + throw new AppException("Select AI 행 접근 ORDS 연결 또는 응답 시간 초과: " + e.getMessage()); } } @@ -91,13 +102,13 @@ public class SelectAiAgentOrdsService { private JsonNode parse(String value) { try { if (value == null || value.isBlank()) { - throw new AppException("Select AI Agent ORDS 응답 본문이 비어 있습니다."); + throw new AppException("Select AI 행 접근 ORDS 응답 본문이 비어 있습니다."); } return objectMapper.readTree(value); } catch (AppException e) { throw e; } catch (Exception e) { - throw new AppException("Select AI Agent ORDS 응답 JSON 파싱 실패: " + e.getMessage()); + throw new AppException("Select AI 행 접근 ORDS 응답 JSON 파싱 실패: " + e.getMessage()); } } @@ -117,14 +128,10 @@ public class SelectAiAgentOrdsService { return value.trim(); } - private String normalizeConversationId(String value) { - if (value == null || value.isBlank()) { - return null; + private int normalizeLimit(int value) { + if (value < 1) { + return 50; } - String normalized = value.trim(); - if (!normalized.matches("[A-Za-z0-9._:-]{1,128}")) { - throw new AppException("conversationId는 영문/숫자/._:-만 사용하고 128자 이하여야 합니다."); - } - return normalized; + return Math.min(value, MAX_LIMIT); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java index 6d98b8e..9acc974 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java @@ -60,10 +60,27 @@ public class StructuredDataService { } List> rows = jdbcTemplate.queryForList( - "SELECT * FROM " + OWNER + "." + table.tableName() + " WHERE ROWNUM <= ?", ROW_LIMIT); + previewSql(table), ROW_LIMIT); return new StructuredDataPreview(table, columns, rows, ROW_LIMIT); } catch (DataAccessException exception) { throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요."); } } + + /** + * The table is selected from a closed application whitelist, so the query + * text remains fixed and no request value can become a SQL identifier. + */ + private String previewSql(StructuredDataTable table) { + return switch (table.key()) { + case "customers" -> "SELECT * FROM POC_2.KB_CUSTOMERS WHERE ROWNUM <= ?"; + case "products" -> "SELECT * FROM POC_2.KB_PRODUCTS WHERE ROWNUM <= ?"; + case "contracts" -> "SELECT * FROM POC_2.KB_CONTRACTS WHERE ROWNUM <= ?"; + case "coverages" -> "SELECT * FROM POC_2.KB_COVERAGES WHERE ROWNUM <= ?"; + case "claims" -> "SELECT * FROM POC_2.KB_CLAIMS WHERE ROWNUM <= ?"; + case "external-holdings" -> "SELECT * FROM POC_2.KB_EXTERNAL_HOLDINGS WHERE ROWNUM <= ?"; + case "stakeholders" -> "SELECT * FROM POC_2.KB_STAKEHOLDERS WHERE ROWNUM <= ?"; + default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다."); + }; + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java index de3d8d0..79afed1 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/UserService.java @@ -15,21 +15,21 @@ public class UserService { private final UserMapper userMapper; private final AuditService auditService; - private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier; + private final ExternalAuthorizationChangeNotifier authorizationChangeNotifier; @Autowired public UserService( UserMapper userMapper, AuditService auditService, - DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier + ExternalAuthorizationChangeNotifier authorizationChangeNotifier ) { this.userMapper = userMapper; this.auditService = auditService; - this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier; + this.authorizationChangeNotifier = authorizationChangeNotifier; } public UserService(UserMapper userMapper, AuditService auditService) { - this(userMapper, auditService, DdsAuthorizationChangeNotifier.noop()); + this(userMapper, auditService, ExternalAuthorizationChangeNotifier.noop()); } public List findAll() { @@ -45,7 +45,7 @@ public class UserService { long userId = userMapper.nextUserId(); userMapper.insertUser(userId, command); auditService.record(new AuditEvent("USER_CREATED", null, null, "SUCCESS", null, null, command.username())); - ddsAuthorizationChangeNotifier.changed("USER_CREATED"); + authorizationChangeNotifier.changed("USER_CREATED"); } @Transactional @@ -56,7 +56,7 @@ public class UserService { } auditService.record(new AuditEvent("USER_ACTIVE_CHANGED", null, null, "SUCCESS", null, null, "userId=" + userId + ",active=" + active)); - ddsAuthorizationChangeNotifier.changed("USER_ACTIVE_CHANGED"); + authorizationChangeNotifier.changed("USER_ACTIVE_CHANGED"); } @Transactional @@ -64,7 +64,7 @@ public class UserService { userMapper.insertUserRole(userId, roleId); auditService.record(new AuditEvent("USER_ROLE_GRANTED", null, null, "SUCCESS", null, null, "userId=" + userId + ",roleId=" + roleId)); - ddsAuthorizationChangeNotifier.changed("USER_ROLE_GRANTED"); + authorizationChangeNotifier.changed("USER_ROLE_GRANTED"); } @Transactional @@ -75,6 +75,6 @@ public class UserService { } auditService.record(new AuditEvent("USER_ROLE_REVOKED", null, null, "SUCCESS", null, null, "userId=" + userId + ",roleId=" + roleId)); - ddsAuthorizationChangeNotifier.changed("USER_ROLE_REVOKED"); + authorizationChangeNotifier.changed("USER_ROLE_REVOKED"); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java index 177aa6b..55b26a4 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java @@ -1,6 +1,7 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdBulkApplyResult; +import com.cloudhandson.vpdbackoffice.domain.vpd.VpdDescriptionNote; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionSource; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdObjectFilterDetail; @@ -20,6 +21,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.springframework.dao.DataAccessException; @@ -31,9 +33,54 @@ import org.springframework.transaction.annotation.Transactional; public class VpdPolicyService { private static final Set ALLOWED_STATEMENTS = Set.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX"); - private static final long CATALOG_CACHE_MILLIS = 60_000L; + // ALL_* dictionary views are comparatively expensive in Autonomous Database. + // Mutating VPD operations call clearCatalogCache(), so a longer read cache does + // not delay an administrator's own changes from appearing in the UI. + private static final long CATALOG_CACHE_MILLIS = 15 * 60_000L; private static final String COMMON_POLICY_NAME = "CB_PERMISSION_SELECT_POLICY"; private static final String DEFAULT_PERMISSION_FILTER_FUNCTION = "CB_AGENT_DOC_VPD_FILTER"; + /* + * DBMS_RLS.ADD_POLICY accepts PL/SQL BOOLEAN arguments. Keep all four + * permitted flag combinations as fixed statements: database object names + * and function references are JDBC bind values, and no request value is + * ever interpolated into executable SQL or PL/SQL source. + */ + private static final String ADD_POLICY_ENABLED_WITH_UPDATE_CHECK = """ + BEGIN + DBMS_RLS.ADD_POLICY( + object_schema => ?, object_name => ?, policy_name => ?, + function_schema => ?, policy_function => ?, statement_types => ?, + update_check => TRUE, enable => TRUE, policy_type => DBMS_RLS.DYNAMIC + ); + END; + """; + private static final String ADD_POLICY_ENABLED_WITHOUT_UPDATE_CHECK = """ + BEGIN + DBMS_RLS.ADD_POLICY( + object_schema => ?, object_name => ?, policy_name => ?, + function_schema => ?, policy_function => ?, statement_types => ?, + update_check => FALSE, enable => TRUE, policy_type => DBMS_RLS.DYNAMIC + ); + END; + """; + private static final String ADD_POLICY_DISABLED_WITH_UPDATE_CHECK = """ + BEGIN + DBMS_RLS.ADD_POLICY( + object_schema => ?, object_name => ?, policy_name => ?, + function_schema => ?, policy_function => ?, statement_types => ?, + update_check => TRUE, enable => FALSE, policy_type => DBMS_RLS.DYNAMIC + ); + END; + """; + private static final String ADD_POLICY_DISABLED_WITHOUT_UPDATE_CHECK = """ + BEGIN + DBMS_RLS.ADD_POLICY( + object_schema => ?, object_name => ?, policy_name => ?, + function_schema => ?, policy_function => ?, statement_types => ?, + update_check => FALSE, enable => FALSE, policy_type => DBMS_RLS.DYNAMIC + ); + END; + """; private static final Pattern RETURN_LITERAL = Pattern.compile( "(?is)\\bRETURN\\s+'((?:''|[^'])*)'\\s*;" ); @@ -42,7 +89,8 @@ public class VpdPolicyService { private final JdbcTemplate jdbcTemplate; private final OpenAiCompatibleClient aiClient; private final Map>> vpdTargetsCache = new ConcurrentHashMap<>(); - private volatile CacheEntry formOptionsCache; + private final AtomicReference> formOptionsCache = + new AtomicReference<>(); public VpdPolicyService(VpdPolicyMapper mapper, JdbcTemplate jdbcTemplate, OpenAiCompatibleClient aiClient) { this.mapper = mapper; @@ -71,7 +119,7 @@ public class VpdPolicyService { } public VpdPolicyFormOptions formOptions() { - CacheEntry cached = formOptionsCache; + CacheEntry cached = formOptionsCache.get(); if (cached != null && !cached.expired()) { return cached.value(); } @@ -84,7 +132,7 @@ public class VpdPolicyService { buildPolicyTemplateOptions(functions, mapper.findPolicyTemplateOptions()), List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX") ); - formOptionsCache = new CacheEntry<>(options, System.currentTimeMillis() + CATALOG_CACHE_MILLIS); + formOptionsCache.set(new CacheEntry<>(options, System.currentTimeMillis() + CATALOG_CACHE_MILLIS)); return options; } @@ -108,7 +156,7 @@ public class VpdPolicyService { String functionName = requiredIdentifier(functionNameValue, "Function name"); if (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) { throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION - + "는 이 화면에서 수정할 수 없습니다. 권한체계는 사용자·그룹·역할·권한 규칙 화면에서 변경하세요."); + + "는 이 화면에서 수정할 수 없습니다. 권한체계는 사용자·그룹·역할·행 접근 규칙 화면에서 변경하세요."); } String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class); String functionOwner = functionOwnerValue == null || functionOwnerValue.isBlank() @@ -292,7 +340,7 @@ public class VpdPolicyService { description = null; } return description == null || description.isBlank() - ? objectOwner + "." + objectName + "에 요청마다 현재 권한체계의 행 접근 조건을 적용하는 " + policyName + " policy입니다." + ? objectOwner + "." + objectName + "에 요청마다 현재 행 접근 규칙의 조건을 적용하는 " + policyName + " policy입니다." : description; } @@ -310,6 +358,18 @@ public class VpdPolicyService { : description; } + /** + * Retrieves all UI descriptions in one round trip. The policy screen used to + * issue one ADB query for every displayed policy and filter. + */ + public Map findPolicyDescriptionMap() { + return descriptionMap(mapper.findPolicyDescriptions()); + } + + public Map findFilterDescriptionMap() { + return descriptionMap(mapper.findFilterDescriptions()); + } + /** * Returns the literal predicate used by a simple standalone Filter function created by this UI. * Packaged or system-managed functions intentionally return an empty string because their @@ -414,7 +474,17 @@ public class VpdPolicyService { public void clearCatalogCache() { vpdTargetsCache.clear(); - formOptionsCache = null; + formOptionsCache.set(null); + } + + private Map descriptionMap(List notes) { + Map descriptions = new LinkedHashMap<>(); + for (VpdDescriptionNote note : notes) { + if (note.noteKey() != null && note.description() != null && !note.description().isBlank()) { + descriptions.put(note.noteKey(), note.description()); + } + } + return descriptions; } private List buildPolicyTemplateOptions( @@ -453,21 +523,7 @@ public class VpdPolicyService { boolean enabled, boolean updateCheck ) { - jdbcTemplate.update(""" - BEGIN - DBMS_RLS.ADD_POLICY( - object_schema => ?, - object_name => ?, - policy_name => ?, - function_schema => ?, - policy_function => ?, - statement_types => ?, - update_check => %s, - enable => %s, - policy_type => DBMS_RLS.DYNAMIC - ); - END; - """.formatted(updateCheck ? "TRUE" : "FALSE", enabled ? "TRUE" : "FALSE"), + jdbcTemplate.update(addPolicySql(enabled, updateCheck), objectOwner, objectName, policyName, @@ -476,6 +532,17 @@ public class VpdPolicyService { statementTypes); } + private String addPolicySql(boolean enabled, boolean updateCheck) { + if (enabled) { + return updateCheck + ? ADD_POLICY_ENABLED_WITH_UPDATE_CHECK + : ADD_POLICY_ENABLED_WITHOUT_UPDATE_CHECK; + } + return updateCheck + ? ADD_POLICY_DISABLED_WITH_UPDATE_CHECK + : ADD_POLICY_DISABLED_WITHOUT_UPDATE_CHECK; + } + public VpdPolicyExplanation explainPolicy(String objectOwner, String objectName, String policyName) { VpdPolicyDetail detail = findPolicyDetail(objectOwner, objectName, policyName); VpdPolicyView policy = detail.policy(); @@ -538,7 +605,7 @@ public class VpdPolicyService { ## 요약 - 이 policy가 무엇을 허용/차단하는지 3줄 이내로 먼저 설명한다. - fail-closed 조건이 있으면 요약에 포함한다. - - 컬럼 마스킹/NULL 처리 판단 가능 여부를 요약에 포함한다. + - 컬럼 마스킹은 ASO/Data Redaction 별도 정책에서 판단한다. 이 VPD source만으로 알 수 있는 행 접근 범위와, 컬럼 마스킹 판단 가능 여부를 구분한다. ## 상세 @@ -557,7 +624,7 @@ public class VpdPolicyService { ### 4. 실제 접근 결과 해석 - 이 policy가 행(row)을 허용하는 조건과 제외하는 조건을 구분한다. - - 컬럼 마스킹/NULL 처리는 source에 직접 있지 않으면 "이 policy source만으로는 판단 불가"라고 쓴다. + - 컬럼 마스킹은 ASO/Data Redaction 별도 정책이다. source에 직접 있지 않으면 "이 VPD policy source만으로는 컬럼 마스킹 판단 불가"라고 쓴다. ### 5. 운영 확인 포인트 - 운영자가 DB에서 확인할 테이블/컬럼/컨텍스트 값을 5개 이하로 적는다. @@ -628,6 +695,14 @@ public class VpdPolicyService { } private void createFilterFunction(String functionName, String filterPredicate) { + /* + * Oracle DDL cannot bind an object identifier or a function body. This + * is therefore deliberately the sole dynamic-DDL boundary in this + * service. functionName passed here has already gone through + * requiredIdentifier() ([A-Z][A-Z0-9_$#]{0,127}); filterPredicate is put + * inside one SQL literal after every quote is doubled. Those two checks + * prevent a caller from terminating the statement or adding DDL. + */ jdbcTemplate.execute(""" CREATE OR REPLACE FUNCTION %s( p_schema_name IN VARCHAR2, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java new file mode 100644 index 0000000..a47be6c --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdTokenAccessDeniedException.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.service; + +/** Raised when ORDS rejects a missing, invalid, expired, or unauthorized user bearer token. */ +public class VpdTokenAccessDeniedException extends AppException { + + public VpdTokenAccessDeniedException() { + super("사용자 Bearer Token이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다."); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java index 1eccf30..7e41085 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/LoginController.java @@ -1,13 +1,23 @@ package com.cloudhandson.vpdbackoffice.web; +import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @Controller public class LoginController { + private final BackofficeProperties properties; + + public LoginController(BackofficeProperties properties) { + this.properties = properties; + } + @GetMapping("/login") - public String login() { + public String login(Model model) { + model.addAttribute("rememberMeAvailable", properties.security().rememberMeConfigured()); + model.addAttribute("rememberMeDays", properties.security().rememberMeDays()); return "login"; } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java new file mode 100644 index 0000000..6a68fed --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleController.java @@ -0,0 +1,178 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; +import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; +import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn; +import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; +import com.cloudhandson.vpdbackoffice.service.AppException; +import com.cloudhandson.vpdbackoffice.service.MaskingRuleService; +import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +public class MaskingRuleController { + + private final MaskingRuleService maskingRuleService; + private final ProtectedObjectService protectedObjectService; + + public MaskingRuleController( + MaskingRuleService maskingRuleService, + ProtectedObjectService protectedObjectService + ) { + this.maskingRuleService = maskingRuleService; + this.protectedObjectService = protectedObjectService; + } + + @GetMapping("/masking-rules") + public String maskingRules(Model model) { + var objects = protectedObjectService.findEnabled(); + var managedObjectNames = maskingRuleService.managedObjectNames(); + var policyStatuses = maskingRuleService.findPolicyStatuses(); + var columnsByObject = protectedObjectService.findColumnsByObjectIds( + objects.stream().map(object -> object.objectId()).toList() + ); + var maskingTargetObjects = objects.stream() + .filter(object -> managedObjectNames.contains(object.objectName())) + .toList(); + model.addAttribute("rules", maskingRuleService.findAllRules()); + model.addAttribute("templates", Arrays.asList(MaskingTemplate.values())); + model.addAttribute("columnRules", maskingRuleService.findColumnRules()); + model.addAttribute("policyStatuses", policyStatuses); + model.addAttribute("policyStatusByObjectName", policyStatuses.stream().collect(Collectors.toMap( + status -> status.objectName(), + status -> status, + (left, right) -> left + ))); + model.addAttribute("objects", objects); + model.addAttribute("maskingTargetObjects", maskingTargetObjects); + model.addAttribute("sensitiveColumnsByObject", objects.stream().collect(Collectors.toMap( + object -> object.objectId(), + object -> columnsByObject.getOrDefault(object.objectId(), List.of()).stream() + .filter(column -> column.sensitive()) + .toList() + ))); + model.addAttribute("availableMaskingColumnsByObject", maskingTargetObjects.stream().collect(Collectors.toMap( + object -> object.objectId(), + object -> availableMaskingColumns(object, columnsByObject.getOrDefault(object.objectId(), List.of())) + ))); + return "masking-rules"; + } + + @PostMapping("/masking-rules") + public String createRule( + @RequestParam String ruleCode, + @RequestParam String ruleName, + @RequestParam String templateCode, + @RequestParam(required = false) String description, + RedirectAttributes redirectAttributes + ) { + try { + maskingRuleService.createRule(new MaskingRuleCreateCommand(ruleCode, ruleName, templateCode, description)); + redirectAttributes.addFlashAttribute("message", "컬럼 마스킹 규칙을 등록했습니다."); + } catch (AppException | DataAccessException exception) { + redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception)); + } + return "redirect:/masking-rules"; + } + + @PostMapping("/masking-rules/active") + public String setRuleActive( + @RequestParam long ruleId, + @RequestParam boolean active, + RedirectAttributes redirectAttributes + ) { + try { + var result = maskingRuleService.setRuleActive(ruleId, active); + redirectAttributes.addFlashAttribute("message", (active ? "컬럼 마스킹 규칙을 활성화했습니다. " : "컬럼 마스킹 규칙을 비활성화했습니다. ") + + result.summary()); + } catch (AppException | DataAccessException exception) { + redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); + } + return "redirect:/masking-rules"; + } + + @PostMapping("/masking-rules/columns") + public String assignColumnRule( + @RequestParam long columnId, + @RequestParam long ruleId, + RedirectAttributes redirectAttributes + ) { + try { + var result = maskingRuleService.assignRuleToColumn(columnId, ruleId); + redirectAttributes.addFlashAttribute("message", "컬럼에 컬럼 마스킹 규칙을 연결했습니다. " + result.summary()); + } catch (AppException | DataAccessException exception) { + redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); + } + return "redirect:/masking-rules"; + } + + @PostMapping("/masking-rules/target-columns") + public String addTargetColumn( + @RequestParam String target, + RedirectAttributes redirectAttributes + ) { + try { + String[] parts = target == null ? new String[0] : target.split(":", 2); + if (parts.length != 2) { + throw new AppException("추가할 보호 객체와 컬럼을 선택하세요."); + } + var result = maskingRuleService.addTargetColumn(Long.parseLong(parts[0]), parts[1]); + redirectAttributes.addFlashAttribute("message", + "마스킹 대상 컬럼으로 등록했습니다. 실제 적용하려면 아래에서 기본 규칙을 연결하세요. " + result.summary()); + } catch (NumberFormatException exception) { + redirectAttributes.addFlashAttribute("errorMessage", "추가할 보호 객체와 컬럼을 선택하세요."); + } catch (AppException | DataAccessException exception) { + redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception)); + } + return "redirect:/masking-rules"; + } + + @PostMapping("/masking-rules/columns/delete") + public String removeColumnRule(@RequestParam long columnId, RedirectAttributes redirectAttributes) { + try { + var result = maskingRuleService.removeRuleFromColumn(columnId); + redirectAttributes.addFlashAttribute("message", "컬럼 마스킹 규칙과 관련 사용자 예외를 해제했습니다. " + result.summary()); + } catch (AppException | DataAccessException exception) { + redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); + } + return "redirect:/masking-rules"; + } + + @PostMapping("/masking-rules/synchronize") + public String synchronizeDatabasePolicies(RedirectAttributes redirectAttributes) { + try { + redirectAttributes.addFlashAttribute("message", maskingRuleService.synchronizeDatabasePolicies().summary()); + } catch (AppException | DataAccessException exception) { + redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception)); + } + return "redirect:/masking-rules"; + } + + private String safeMessage(Exception exception) { + return exception instanceof AppException ? exception.getMessage() : "컬럼 마스킹 규칙을 저장하지 못했습니다. 입력값과 DB 상태를 확인하세요."; + } + + private List availableMaskingColumns( + ProtectedObject object, + List protectedColumns + ) { + var registeredSensitiveColumns = new HashSet(); + protectedColumns.stream() + .filter(ProtectedColumn::sensitive) + .map(ProtectedColumn::columnName) + .forEach(registeredSensitiveColumns::add); + return protectedObjectService.findDatabaseColumns(object.owner(), object.objectName()).stream() + .filter(columnName -> !registeredSensitiveColumns.contains(columnName)) + .toList(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java index 0b6cda4..0b83085 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java @@ -4,10 +4,9 @@ import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand; import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import com.cloudhandson.vpdbackoffice.service.BearerTokenService; import com.cloudhandson.vpdbackoffice.service.McpReasoningService; +import com.cloudhandson.vpdbackoffice.service.McpSseService; import com.cloudhandson.vpdbackoffice.service.McpToolRegistry; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -20,17 +19,20 @@ import org.springframework.web.bind.annotation.ResponseBody; public class McpReasoningController { private final McpToolRegistry toolRegistry; + private final McpSseService mcpSseService; private final McpReasoningService reasoningService; private final BearerTokenService tokenService; private final UserMapper userMapper; public McpReasoningController( McpToolRegistry toolRegistry, + McpSseService mcpSseService, McpReasoningService reasoningService, BearerTokenService tokenService, UserMapper userMapper ) { this.toolRegistry = toolRegistry; + this.mcpSseService = mcpSseService; this.reasoningService = reasoningService; this.tokenService = tokenService; this.userMapper = userMapper; @@ -53,28 +55,12 @@ public class McpReasoningController { @GetMapping("/mcp/tools") @ResponseBody public Object tools() { - try { - return toolRegistry.listTools(); - } catch (DataAccessException e) { - RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e); - Map response = new LinkedHashMap<>(); - response.put("status", "DB_NOT_AVAILABLE"); - response.put("title", message.title()); - response.put("message", message.message()); - response.put("tools", List.of()); - return response; - } + return mcpSseService.registeredTools(); } @GetMapping("/mcp-sse") public String ssePage(Model model) { - try { - model.addAttribute("tools", toolRegistry.listTools()); - } catch (DataAccessException e) { - RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e); - model.addAttribute("tools", List.of()); - model.addAttribute("runtimeError", message); - } + model.addAttribute("tools", mcpSseService.registeredTools()); return "mcp-sse"; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java index 6834b3c..e78738d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -34,13 +36,16 @@ public class McpSseController { * The legacy SSE endpoints remain available for existing integrations. */ @PostMapping(path = "/mcp", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity streamableMessage(@RequestBody JsonNode request) { + public ResponseEntity streamableMessage( + @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @RequestBody JsonNode request + ) { // JSON-RPC notifications never receive a response body. Current MCP // clients send notifications/initialized immediately after initialize. if (request != null && !request.has("id")) { return ResponseEntity.accepted().build(); } - return ResponseEntity.ok(mcpSseService.handle("default", request)); + return ResponseEntity.ok(mcpSseService.handle("default", request, bearerToken(authorization))); } @GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @@ -56,18 +61,20 @@ public class McpSseController { @PostMapping(path = "/mcp/messages", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity defaultMessage( @RequestParam(required = false) String sessionId, + @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization, @RequestBody JsonNode request ) throws IOException { - return handleMessage("default", sessionId, request); + return handleMessage("default", sessionId, authorization, request); } @PostMapping(path = "/mcp/{contextPath}/messages", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity contextMessage( @PathVariable String contextPath, @RequestParam(required = false) String sessionId, + @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization, @RequestBody JsonNode request ) throws IOException { - return handleMessage(contextPath, sessionId, request); + return handleMessage(contextPath, sessionId, authorization, request); } private SseEmitter openSse(String contextPath) throws IOException { @@ -84,9 +91,15 @@ public class McpSseController { return emitter; } - private ResponseEntity handleMessage(String contextPath, String sessionId, JsonNode request) throws IOException { + private ResponseEntity handleMessage( + String contextPath, + String sessionId, + String authorization, + JsonNode request + ) throws IOException { String normalizedContextPath = normalizeContextPath(contextPath); - ObjectNode response = mcpSseService.handle(normalizedContextPath, request); + ObjectNode response = mcpSseService.handle( + normalizedContextPath, request, bearerToken(authorization)); if (sessionId == null || sessionId.isBlank()) { return ResponseEntity.ok(response); } @@ -119,6 +132,13 @@ public class McpSseController { return normalized.toLowerCase(); } + private String bearerToken(String authorization) { + if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) { + return ""; + } + return authorization.substring(7).trim(); + } + private record McpSseSession(String contextPath, SseEmitter emitter) { } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java index 86622d7..c13e761 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/OperationStatusController.java @@ -1,5 +1,6 @@ package com.cloudhandson.vpdbackoffice.web; +import com.cloudhandson.vpdbackoffice.service.MaskingRuleService; import com.cloudhandson.vpdbackoffice.service.OperationStatusService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -9,14 +10,17 @@ import org.springframework.web.bind.annotation.GetMapping; public class OperationStatusController { private final OperationStatusService service; + private final MaskingRuleService maskingRuleService; - public OperationStatusController(OperationStatusService service) { + public OperationStatusController(OperationStatusService service, MaskingRuleService maskingRuleService) { this.service = service; + this.maskingRuleService = maskingRuleService; } @GetMapping("/operation-status") public String status(Model model) { model.addAttribute("rows", service.findRows()); + model.addAttribute("maskingPolicyStatuses", maskingRuleService.findPolicyStatuses()); return "operation-status"; } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java index 9c4310d..3c75998 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java @@ -10,7 +10,6 @@ import com.cloudhandson.vpdbackoffice.service.PermissionService; import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; import com.cloudhandson.vpdbackoffice.service.UserService; import java.util.ArrayList; -import java.util.Arrays; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -85,17 +84,20 @@ public class PermissionController { long rolesAt = System.nanoTime(); var roleImpact = buildRoleImpact(roles); long roleImpactAt = System.nanoTime(); + var protectedColumnsByObject = protectedObjectService.findColumnsByObjectIds( + objects.stream().map(object -> object.objectId()).toList() + ); var columnsByObject = objects.stream() .collect(Collectors.toMap( object -> object.objectId(), - object -> protectedObjectService.findColumns(object.objectId()).stream() + object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream() .map(column -> column.columnName()) .toList() )); var maskableColumnsByObject = objects.stream() .collect(Collectors.toMap( object -> object.objectId(), - object -> protectedObjectService.findColumns(object.objectId()).stream() + object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream() .filter(ProtectedColumn::sensitive) .map(column -> column.columnName()) .toList() @@ -103,7 +105,7 @@ public class PermissionController { var maskableColumnLabelsByObject = objects.stream() .collect(Collectors.toMap( object -> object.objectId(), - object -> protectedObjectService.findColumns(object.objectId()).stream() + object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream() .filter(ProtectedColumn::sensitive) .map(column -> column.columnName() + " [" + column.policyLabel() + "]") .toList() @@ -112,11 +114,17 @@ public class PermissionController { var dbObjects = protectedObjectService.findDatabaseObjects(); long dbObjectsAt = System.nanoTime(); var permissions = permissionService.findPermissionViews(); + var permissionCountByObject = permissions.stream() + .filter(permission -> permission.objectId() > 0) + .collect(Collectors.groupingBy( + permission -> permission.objectId(), + Collectors.counting() + )); var lastPermissionByPermissionId = permissions.stream() .collect(Collectors.toMap( permission -> permission.permissionId(), permission -> permission.objectId() > 0 - && permissionService.countPermissionsByObjectId(permission.objectId()) <= 1, + && permissionCountByObject.getOrDefault(permission.objectId(), 0L) <= 1, (left, right) -> left, LinkedHashMap::new )); @@ -201,7 +209,6 @@ public class PermissionController { @RequestParam(required = false) List ruleColumn, @RequestParam(required = false) List ruleType, @RequestParam(required = false) List ruleValue, - @RequestParam(required = false) String visibleColumns, RedirectAttributes redirectAttributes ) { try { @@ -215,7 +222,7 @@ public class PermissionController { "SELECT", permissionEffect, buildRules(ruleColumn, ruleType, ruleValue), - splitColumns(visibleColumns) + List.of() )); redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다."); } catch (AppException | IllegalArgumentException exception) { @@ -263,16 +270,6 @@ public class PermissionController { throw new IllegalArgumentException("보호 객체 형식이 올바르지 않습니다."); } - private List splitColumns(String visibleColumns) { - if (visibleColumns == null || visibleColumns.isBlank()) { - return List.of(); - } - return Arrays.stream(visibleColumns.split(",")) - .map(String::trim) - .filter(value -> !value.isBlank()) - .toList(); - } - @PostMapping("/permissions/delete") public String delete( @RequestParam long permissionId, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java index 39d3742..24a7e67 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java @@ -53,7 +53,7 @@ final class RuntimeErrorMessages { if (cause instanceof SQLException sqlException) { return trim(sqlException.getMessage()); } - return trim(cause == null ? exception.getMessage() : safeMessage(cause)); + return trim(safeMessage(cause)); } private static String safeMessage(Throwable throwable) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java new file mode 100644 index 0000000..2c518e5 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java @@ -0,0 +1,108 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.service.AppException; +import com.cloudhandson.vpdbackoffice.service.SchemaMetadataService; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +public class SchemaMetadataController { + + private final SchemaMetadataService schemaMetadataService; + + public SchemaMetadataController(SchemaMetadataService schemaMetadataService) { + this.schemaMetadataService = schemaMetadataService; + } + + @GetMapping("/schema-metadata") + public String schemaMetadata(@RequestParam(required = false) String table, Model model) { + String selectedKey = table == null || table.isBlank() ? schemaMetadataService.defaultKey() : table; + model.addAttribute("tables", schemaMetadataService.tables()); + model.addAttribute("selectedKey", selectedKey); + try { + model.addAttribute("metadata", schemaMetadataService.find(selectedKey)); + } catch (AppException | DataAccessException exception) { + model.addAttribute("errorMessage", safeMessage(exception)); + } + return "schema-metadata"; + } + + @PostMapping("/schema-metadata/table-comment") + public String updateTableComment( + @RequestParam String table, + @RequestParam(required = false) String comment, + RedirectAttributes redirectAttributes + ) { + try { + schemaMetadataService.updateTableComment(table, comment); + redirectAttributes.addFlashAttribute("message", "테이블 comment를 저장했습니다."); + } catch (AppException | DataAccessException exception) { + redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception)); + } + return redirect(table); + } + + @PostMapping("/schema-metadata/column-comment") + public String updateColumnComment( + @RequestParam String table, + @RequestParam String column, + @RequestParam(required = false) String comment, + RedirectAttributes redirectAttributes + ) { + try { + schemaMetadataService.updateColumnComment(table, column, comment); + redirectAttributes.addFlashAttribute("message", column + " 컬럼 comment를 저장했습니다."); + } catch (AppException | DataAccessException exception) { + redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception)); + } + return redirect(table); + } + + @PostMapping("/schema-metadata/table-annotation") + public String updateTableAnnotation( + @RequestParam String table, + @RequestParam String annotationName, + @RequestParam(required = false) String annotationValue, + RedirectAttributes redirectAttributes + ) { + try { + schemaMetadataService.updateTableAnnotation(table, annotationName, annotationValue); + redirectAttributes.addFlashAttribute("message", "테이블 annotation을 저장했습니다."); + } catch (AppException | DataAccessException exception) { + redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception)); + } + return redirect(table); + } + + @PostMapping("/schema-metadata/column-annotation") + public String updateColumnAnnotation( + @RequestParam String table, + @RequestParam String column, + @RequestParam String annotationName, + @RequestParam(required = false) String annotationValue, + RedirectAttributes redirectAttributes + ) { + try { + schemaMetadataService.updateColumnAnnotation(table, column, annotationName, annotationValue); + redirectAttributes.addFlashAttribute("message", column + " 컬럼 annotation을 저장했습니다."); + } catch (AppException | DataAccessException exception) { + redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception)); + } + return redirect(table); + } + + private String redirect(String table) { + return "redirect:/schema-metadata?table=" + (table == null ? "" : table); + } + + private String safeMessage(Exception exception) { + return exception instanceof AppException + ? exception.getMessage() + : "DB 메타데이터를 저장하지 못했습니다. 권한, 식별자, Oracle annotation 문법을 확인하세요."; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java new file mode 100644 index 0000000..f84adcb --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/SecurityModelAdvice.java @@ -0,0 +1,28 @@ +package com.cloudhandson.vpdbackoffice.web; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +@ControllerAdvice +public class SecurityModelAdvice { + + @ModelAttribute("canMutate") + public boolean canMutate() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return false; + } + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch("ROLE_ADMIN"::equals); + } + + @ModelAttribute("readOnlyMode") + public boolean readOnlyMode() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null && authentication.isAuthenticated() && !canMutate(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java new file mode 100644 index 0000000..942bf99 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/SecuritySqlScriptController.java @@ -0,0 +1,57 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary; +import com.cloudhandson.vpdbackoffice.service.AppException; +import com.cloudhandson.vpdbackoffice.service.SecuritySqlScriptExplanationService; +import com.cloudhandson.vpdbackoffice.service.SecuritySqlScriptService; +import java.util.List; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** Read-only page for the curated ASO, ORDS, and Select AI deployment scripts. */ +@Controller +public class SecuritySqlScriptController { + + private final SecuritySqlScriptService securitySqlScriptService; + private final SecuritySqlScriptExplanationService explanationService; + + public SecuritySqlScriptController( + SecuritySqlScriptService securitySqlScriptService, + SecuritySqlScriptExplanationService explanationService + ) { + this.securitySqlScriptService = securitySqlScriptService; + this.explanationService = explanationService; + } + + @GetMapping("/security-sql-scripts") + public String scripts(@RequestParam(required = false) String script, Model model) { + List scripts = securitySqlScriptService.list(); + model.addAttribute("scripts", scripts); + if (scripts.isEmpty()) { + model.addAttribute("errorMessage", "표시할 보안 SQL 스크립트가 없습니다."); + return "security-sql-scripts"; + } + + String selectedId = script == null || script.isBlank() ? scripts.getFirst().scriptId() : script; + try { + model.addAttribute("selectedScript", securitySqlScriptService.find(selectedId)); + } catch (AppException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + model.addAttribute("selectedScript", securitySqlScriptService.find(scripts.getFirst().scriptId())); + } + return "security-sql-scripts"; + } + + @PostMapping("/security-sql-scripts/explanation") + public String explain(@RequestParam String script, Model model) { + try { + model.addAttribute("explanation", explanationService.explain(script)); + } catch (AppException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } + return "fragments/security-sql-script-explanation :: explanation"; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java new file mode 100644 index 0000000..6a66514 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/UserMaskingRuleController.java @@ -0,0 +1,63 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.service.AppException; +import com.cloudhandson.vpdbackoffice.service.MaskingRuleService; +import com.cloudhandson.vpdbackoffice.service.UserService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +public class UserMaskingRuleController { + + private final MaskingRuleService maskingRuleService; + private final UserService userService; + + public UserMaskingRuleController(MaskingRuleService maskingRuleService, UserService userService) { + this.maskingRuleService = maskingRuleService; + this.userService = userService; + } + + @GetMapping("/user-masking-rules") + public String userMaskingRules(Model model) { + model.addAttribute("users", userService.findAll()); + model.addAttribute("columnRules", maskingRuleService.findColumnRules().stream() + .filter(columnRule -> columnRule.ruleEnabled()) + .toList()); + model.addAttribute("userRules", maskingRuleService.findUserRules()); + return "user-masking-rules"; + } + + @PostMapping("/user-masking-rules") + public String assign( + @RequestParam long userId, + @RequestParam long columnId, + RedirectAttributes redirectAttributes + ) { + try { + maskingRuleService.assignUserRule(userId, columnId, "UNMASK"); + redirectAttributes.addFlashAttribute("message", "컬럼 원문 표시 허용 사용자를 저장했습니다. 이 설정은 행 접근 권한을 추가하지 않습니다."); + } catch (AppException exception) { + redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); + } + return "redirect:/user-masking-rules"; + } + + @PostMapping("/user-masking-rules/delete") + public String remove( + @RequestParam long userId, + @RequestParam long columnId, + RedirectAttributes redirectAttributes + ) { + try { + maskingRuleService.removeUserRule(userId, columnId); + redirectAttributes.addFlashAttribute("message", "컬럼 원문 표시 허용을 해제하고 컬럼의 기본 컬럼 마스킹 규칙으로 되돌렸습니다."); + } catch (AppException exception) { + redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); + } + return "redirect:/user-masking-rules"; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java index 858c604..78bb8b9 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java @@ -1,12 +1,15 @@ package com.cloudhandson.vpdbackoffice.web; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand; +import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView; import com.cloudhandson.vpdbackoffice.service.AppException; import com.cloudhandson.vpdbackoffice.service.VpdPolicyService; import java.util.List; import java.util.LinkedHashMap; import java.util.Map; import org.springframework.dao.DataAccessException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -17,6 +20,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes; @Controller public class VpdPolicyController { + private static final Logger log = LoggerFactory.getLogger(VpdPolicyController.class); private final VpdPolicyService vpdPolicyService; public VpdPolicyController(VpdPolicyService vpdPolicyService) { @@ -28,7 +32,7 @@ public class VpdPolicyController { @RequestParam(required = false) String schemaOwner, Model model ) { - populatePolicyModel(schemaOwner, model); + populatePolicyModel(schemaOwner, false, model); return "vpd-policies"; } @@ -37,41 +41,95 @@ public class VpdPolicyController { @RequestParam(required = false) String schemaOwner, Model model ) { - populatePolicyModel(schemaOwner, model); + populatePolicyModel(schemaOwner, true, model); return "vpd-filter-policies"; } - private void populatePolicyModel(String schemaOwner, Model model) { + /** Read-only operational view of the default dynamic permission filter. */ + @GetMapping("/vpd-filter-runtime") + public String filterRuntime(Model model) { try { + List policies = vpdPolicyService.findPolicies().stream() + .filter(VpdPolicyView::permissionSystemDefault) + .toList(); + model.addAttribute("policies", policies); + if (!policies.isEmpty()) { + VpdPolicyView filter = policies.get(0); + model.addAttribute("source", vpdPolicyService.findFunctionSource( + filter.functionOwner(), filter.packageName(), filter.functionName())); + } + } catch (DataAccessException exception) { + RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception); + model.addAttribute("runtimeError", message); + model.addAttribute("policies", List.of()); + } catch (AppException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + model.addAttribute("policies", List.of()); + } + return "vpd-filter-runtime"; + } + + private void populatePolicyModel(String schemaOwner, boolean includeFilterEditor, Model model) { + try { + long started = System.nanoTime(); String selectedSchemaOwner = schemaOwner == null ? "" : schemaOwner.trim().toUpperCase(); List policies = vpdPolicyService.findPolicies(); + long policiesAt = System.nanoTime(); Map policyDescriptions = new LinkedHashMap<>(); - policies.forEach(policy -> policyDescriptions.put( - policy.objectDisplayName() + "|" + policy.policyName(), - vpdPolicyService.findPolicyDescription(policy.objectOwner(), policy.objectName(), policy.policyName()) - )); + if (!includeFilterEditor) { + policyDescriptions.putAll(vpdPolicyService.findPolicyDescriptionMap()); + policies.forEach(policy -> policyDescriptions.put( + policy.objectDisplayName() + "|" + policy.policyName(), + policyDescriptions.getOrDefault( + policy.objectDisplayName() + "|" + policy.policyName(), + policy.objectDisplayName() + "에 요청마다 현재 권한체계의 행 접근 조건을 적용하는 " + + policy.policyName() + " policy입니다." + ) + )); + } + long policyDescriptionsAt = System.nanoTime(); model.addAttribute("policies", policies); model.addAttribute("policyDescriptions", policyDescriptions); - model.addAttribute("vpdTargets", vpdPolicyService.findVpdTargets(selectedSchemaOwner)); + model.addAttribute("vpdTargets", includeFilterEditor + ? List.of() + : vpdPolicyService.findVpdTargets(selectedSchemaOwner)); + long targetsAt = System.nanoTime(); model.addAttribute("selectedSchemaOwner", selectedSchemaOwner); var formOptions = vpdPolicyService.formOptions(); + long formOptionsAt = System.nanoTime(); Map filterDescriptions = new LinkedHashMap<>(); - formOptions.functions().forEach(function -> filterDescriptions.put( - function.owner() + "|" + function.functionName(), - vpdPolicyService.findFilterDescription(function.owner(), function.functionName()) - )); + filterDescriptions.putAll(vpdPolicyService.findFilterDescriptionMap()); policies.forEach(policy -> filterDescriptions.putIfAbsent( policy.functionOwner() + "|" + policy.functionName(), - vpdPolicyService.findFilterDescription(policy.functionOwner(), policy.functionName()) + defaultFilterDescription(policy.functionName()) )); + if (includeFilterEditor) { + formOptions.functions().forEach(function -> filterDescriptions.putIfAbsent( + function.owner() + "|" + function.functionName(), + defaultFilterDescription(function.functionName()) + )); + } + long filterDescriptionsAt = System.nanoTime(); Map filterPredicates = new LinkedHashMap<>(); - formOptions.functions().forEach(function -> filterPredicates.put( - function.owner() + "|" + function.functionName(), - vpdPolicyService.findFilterPredicate(function.owner(), function.packageName(), function.functionName()) - )); + if (includeFilterEditor) { + formOptions.functions().forEach(function -> filterPredicates.put( + function.owner() + "|" + function.functionName(), + vpdPolicyService.findFilterPredicate(function.owner(), function.packageName(), function.functionName()) + )); + } + long predicatesAt = System.nanoTime(); model.addAttribute("formOptions", formOptions); model.addAttribute("filterDescriptions", filterDescriptions); model.addAttribute("filterPredicates", filterPredicates); + log.info("vpd page timings: editor={} policies={}ms policyDescriptions={}ms targets={}ms formOptions={}ms filterDescriptions={}ms predicates={}ms total={}ms", + includeFilterEditor, + elapsedMillis(started, policiesAt), + elapsedMillis(policiesAt, policyDescriptionsAt), + elapsedMillis(policyDescriptionsAt, targetsAt), + elapsedMillis(targetsAt, formOptionsAt), + elapsedMillis(formOptionsAt, filterDescriptionsAt), + elapsedMillis(filterDescriptionsAt, predicatesAt), + elapsedMillis(started, predicatesAt)); } catch (DataAccessException exception) { RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception); model.addAttribute("runtimeError", message); @@ -94,6 +152,16 @@ public class VpdPolicyController { } } + private static String defaultFilterDescription(String functionName) { + return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName) + ? "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다." + : "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다."; + } + + private static long elapsedMillis(long started, long finished) { + return (finished - started) / 1_000_000; + } + @PostMapping("/vpd-policies") public String createPolicy( @RequestParam String objectKey, diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f7152c9..5728558 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,7 +36,16 @@ backoffice: security: admin-user: ${BACKOFFICE_ADMIN_USER:admin} admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin} + # A stable encoded value keeps signed remember-me cookies valid across restarts. + admin-password-hash: ${BACKOFFICE_ADMIN_PASSWORD_HASH:} + guest-enabled: ${BACKOFFICE_GUEST_ENABLED:false} + guest-user: ${BACKOFFICE_GUEST_USER:guest} + guest-password: ${BACKOFFICE_GUEST_PASSWORD:} + guest-password-hash: ${BACKOFFICE_GUEST_PASSWORD_HASH:} require-https: ${BACKOFFICE_REQUIRE_HTTPS:false} + remember-me-enabled: ${BACKOFFICE_REMEMBER_ME_ENABLED:false} + remember-me-key: ${BACKOFFICE_REMEMBER_ME_KEY:} + remember-me-days: ${BACKOFFICE_REMEMBER_ME_DAYS:14} token: max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365} ords: @@ -49,10 +58,13 @@ backoffice: password: ${BACKOFFICE_ORDS_DB_PASSWORD:} ai: enabled: ${BACKOFFICE_AI_ENABLED:false} - base-url: ${BACKOFFICE_AI_BASE_URL:} - model: ${BACKOFFICE_AI_MODEL:} + provider: ${BACKOFFICE_AI_PROVIDER:openai} + base-url: ${BACKOFFICE_AI_BASE_URL:${POC3_LLM_GPT55_OCI_ENDPOINT:}} + model: ${BACKOFFICE_AI_MODEL:${POC3_LLM_GPT55_OCI_MODEL_ID:}} embedding-model: ${BACKOFFICE_AI_EMBEDDING_MODEL:} api-key: ${BACKOFFICE_AI_API_KEY:} timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s - mcp: - access-token: ${BACKOFFICE_MCP_ACCESS_TOKEN:} + oci-config-file: ${BACKOFFICE_AI_OCI_CONFIG_FILE:${OCI_CONFIG_FILE:}} + oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}} + oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}} + oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}} diff --git a/src/main/resources/mapper/MaskingRuleMapper.xml b/src/main/resources/mapper/MaskingRuleMapper.xml new file mode 100644 index 0000000..82b1ac4 --- /dev/null +++ b/src/main/resources/mapper/MaskingRuleMapper.xml @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + INSERT INTO cb_masking_rule (rule_id, rule_code, rule_name, template_code, description, enabled_yn) + VALUES (#{ruleId}, #{command.ruleCode}, #{command.ruleName}, #{command.templateCode}, + #{command.description}, 'Y') + + + + UPDATE cb_masking_rule + SET enabled_yn = #{enabledYn} + WHERE rule_id = #{ruleId} + + + + + + + + + + + MERGE INTO cb_column_masking_rule dst + USING (SELECT #{columnId} column_id, #{ruleId} rule_id FROM dual) src + ON (dst.column_id = src.column_id) + WHEN MATCHED THEN UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP + WHEN NOT MATCHED THEN INSERT (column_id, rule_id, updated_at) + VALUES (src.column_id, src.rule_id, SYSTIMESTAMP) + + + + DELETE FROM cb_column_masking_rule + WHERE column_id = #{columnId} + + + + DELETE FROM cb_user_masking_rule + WHERE column_id = #{columnId} + + + + + + + + MERGE INTO cb_user_masking_rule dst + USING (SELECT #{userId} user_id, #{columnId} column_id, #{decision} decision FROM dual) src + ON (dst.user_id = src.user_id AND dst.column_id = src.column_id) + WHEN MATCHED THEN UPDATE SET dst.decision = src.decision, dst.active_yn = 'Y', dst.updated_at = SYSTIMESTAMP + WHEN NOT MATCHED THEN INSERT (user_id, column_id, decision, active_yn, updated_at) + VALUES (src.user_id, src.column_id, src.decision, 'Y', SYSTIMESTAMP) + + + + DELETE FROM cb_user_masking_rule + WHERE user_id = #{userId} + AND column_id = #{columnId} + + diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml index 024ddf1..a18bc5e 100644 --- a/src/main/resources/mapper/PermissionMapper.xml +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -100,13 +100,15 @@ WHEN pr2.rule_type = 'CHANNEL_CONTRACT' THEN pr2.rule_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')' WHEN pr2.rule_type = 'OWN_CUSTOMER' THEN - 'EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS c ' - || 'WHERE c.' || pr2.rule_column || ' = [CURRENT_ROW].' || pr2.rule_column || ' ' - || 'AND c.FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID''))' + 'EXISTS (SELECT 1 FROM (SELECT c.' || pr2.rule_column || ' AS access_key ' + || 'FROM POC_2.KB_CONTRACTS c ' + || 'WHERE c.FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')) allowed_contract ' + || 'WHERE allowed_contract.access_key = ' || pr2.rule_column || ')' WHEN pr2.rule_type = 'CHANNEL_CUSTOMER' THEN - 'EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS c ' - || 'WHERE c.' || pr2.rule_column || ' = [CURRENT_ROW].' || pr2.rule_column || ' ' - || 'AND c.FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL''))' + 'EXISTS (SELECT 1 FROM (SELECT c.' || pr2.rule_column || ' AS access_key ' + || 'FROM POC_2.KB_CONTRACTS c ' + || 'WHERE c.FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')) allowed_contract ' + || 'WHERE allowed_contract.access_key = ' || pr2.rule_column || ')' WHEN pr2.rule_type = 'STATIC_SQL' THEN pr2.rule_value ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value END, @@ -117,8 +119,8 @@ ) AS filter_preview, ( SELECT CASE - WHEN COUNT(*) = 0 THEN '모든 민감 컬럼 NULL 처리' - ELSE 'NULL 제외: ' || LISTAGG(pc2.column_name, ', ') WITHIN GROUP (ORDER BY pc2.column_name) + WHEN COUNT(*) = 0 THEN '컬럼 마스킹은 ASO에서 관리' + ELSE '레거시 권한별 컬럼 예외: ' || LISTAGG(pc2.column_name, ', ') WITHIN GROUP (ORDER BY pc2.column_name) END FROM cb_permission_column pc2 WHERE pc2.permission_id = p.perm_id diff --git a/src/main/resources/mapper/ProtectedObjectMapper.xml b/src/main/resources/mapper/ProtectedObjectMapper.xml index 8bd08f0..2e10bed 100644 --- a/src/main/resources/mapper/ProtectedObjectMapper.xml +++ b/src/main/resources/mapper/ProtectedObjectMapper.xml @@ -69,6 +69,47 @@ ORDER BY column_id + + + + + + diff --git a/src/main/resources/mapper/VpdPolicyMapper.xml b/src/main/resources/mapper/VpdPolicyMapper.xml index e4e5726..fa70efe 100644 --- a/src/main/resources/mapper/VpdPolicyMapper.xml +++ b/src/main/resources/mapper/VpdPolicyMapper.xml @@ -295,6 +295,18 @@ AND function_name = UPPER(#{functionName,jdbcType=VARCHAR}) + + + + MERGE INTO cb_vpd_policy_note dst USING ( diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index afc5c65..d61deaf 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -2745,3 +2745,12 @@ body { grid-template-columns: 1fr; } } + +.readonly-mutation-notice { + margin-bottom: .75rem; +} + +.readonly-mutation-disabled { + opacity: .58; + pointer-events: none; +} diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 9cfe39d..cec3b70 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -5,6 +5,67 @@ document.body.addEventListener('htmx:responseError', (event) => { } }); +const READ_ONLY_POST_ALLOWLIST = new Set([ + '/login', + '/logout', + '/probe', + '/vector-knowledge/search', + '/security-sql-scripts/explanation', + '/mcp-chatbot', + '/mcp-client-demo', + '/mcp-reasoning', +]); + +function normalizeActionPath(value) { + if (!value) { + return ''; + } + try { + return new URL(value, window.location.origin).pathname; + } catch (_error) { + return value; + } +} + +function isReadOnlyMode() { + return document.querySelector('meta[name="backoffice-read-only"]')?.content === 'true'; +} + +function isFormMutation(form) { + const method = (form.getAttribute('method') || 'get').toLowerCase(); + const hxPost = form.getAttribute('hx-post'); + if (hxPost) { + return !READ_ONLY_POST_ALLOWLIST.has(normalizeActionPath(hxPost)); + } + if (method !== 'post') { + return false; + } + return !READ_ONLY_POST_ALLOWLIST.has(normalizeActionPath(form.getAttribute('action'))); +} + +function disableReadOnlyMutationForms() { + if (!isReadOnlyMode()) { + return; + } + document.querySelectorAll('form').forEach((form) => { + if (!isFormMutation(form)) { + return; + } + if (!form.previousElementSibling?.matches?.('[data-readonly-mutation-notice]')) { + const notice = document.createElement('div'); + notice.className = 'alert alert-secondary readonly-mutation-notice'; + notice.setAttribute('data-readonly-mutation-notice', 'true'); + notice.textContent = '읽기 전용 계정은 이 변경 작업을 실행할 수 없습니다.'; + form.parentNode?.insertBefore(notice, form); + } + form.classList.add('readonly-mutation-disabled'); + form.setAttribute('aria-disabled', 'true'); + form.querySelectorAll('input:not([type="hidden"]), select, textarea, button').forEach((control) => { + control.disabled = true; + }); + }); +} + function resetFilterToggle(button, target) { if (!button || !target) { return; @@ -324,60 +385,6 @@ function renderRuleColumnOptions(columns) { }); } -function renderMaskableColumnOptions(option) { - const list = document.querySelector('[data-maskable-column-list]'); - if (!list) { - return; - } - const columns = (option?.dataset.maskableColumns || '').split(',').filter(Boolean); - const labels = (option?.dataset.maskableLabels || '').split('|').filter(Boolean); - if (!columns.length) { - list.innerHTML = '선택한 객체에 권한별로 허용할 마스킹 컬럼이 없습니다.'; - return; - } - list.innerHTML = columns.map((column, index) => ( - `` - )).join(''); - list.querySelectorAll('[data-mask-column]').forEach((button) => { - button.addEventListener('click', () => { - const input = document.querySelector('input[name="visibleColumns"]'); - if (!input) { - return; - } - const current = input.value.split(',').map((value) => value.trim()).filter(Boolean); - const column = button.dataset.maskColumn; - if (!current.includes(column)) { - current.push(column); - } - input.value = current.join(', '); - renderSelectedVisibleColumns(); - updatePermissionWizardPreview(document); - }); - }); -} - -function renderSelectedVisibleColumns() { - const input = document.querySelector('input[name="visibleColumns"]'); - const list = document.querySelector('[data-selected-visible-columns]'); - if (!input || !list) { - return; - } - const columns = input.value.split(',').map((value) => value.trim().toUpperCase()).filter(Boolean); - list.innerHTML = columns.map((column) => ( - `` - )).join(''); - list.querySelectorAll('[data-remove-visible-column]').forEach((button) => { - button.addEventListener('click', () => { - const remove = button.dataset.removeVisibleColumn; - input.value = columns.filter((column) => column !== remove).join(', '); - renderSelectedVisibleColumns(); - updatePermissionWizardPreview(document); - }); - }); -} - function syncRuleTypeHints(root = document) { root.querySelectorAll('.rule-row').forEach((row) => { const typeSelect = row.querySelector('.rule-type-select'); @@ -440,7 +447,6 @@ async function syncRuleColumnOptions() { renderRuleColumnOptions(columns); updatePermissionWizardPreview(document); }; - renderMaskableColumnOptions(selectedOption); applyColumns(fallbackColumns); try { const response = await fetch(`/permissions/object-columns?objectRef=${encodeURIComponent(objectSelect.value)}`); @@ -700,6 +706,54 @@ function sqlLiteral(value) { return `'${String(value || '').replaceAll("'", "''")}'`; } +function permissionRuleBusinessLabel(type, column, value) { + const displayColumn = column || { + MY_DEPT: 'DEPT_CODE', + SELF: 'OWNER_EMP_NO', + DEPT: 'DEPT_CODE', + EMP_NO: 'OWNER_EMP_NO', + TAG: 'TECH_TAG', + TOKEN_SUBJECT: 'USER_ID', + OWN_CONTRACT: 'FC_ID', + CHANNEL_CONTRACT: 'FC_CHANNEL', + OWN_CUSTOMER: 'CUST_ID/CONTRACT_NO', + CHANNEL_CUSTOMER: 'CUST_ID/CONTRACT_NO', + STATIC_SQL: '', + ALL: '' + }[type] || '선택 컬럼'; + if (type === 'ALL') { + return '전체 행'; + } + if (type === 'TOKEN_SUBJECT') { + return `토큰 이해관계자 본인 행 (${displayColumn})`; + } + if (type === 'OWN_CONTRACT') { + return `담당 설계사 본인 계약 (${displayColumn})`; + } + if (type === 'CHANNEL_CONTRACT') { + return `토큰 사용자 채널 계약 (${displayColumn})`; + } + if (type === 'OWN_CUSTOMER') { + return `담당 설계사 본인 계약에 연결된 고객/청구/외부보유 (${displayColumn})`; + } + if (type === 'CHANNEL_CUSTOMER') { + return `토큰 사용자 채널 계약에 연결된 고객/청구/외부보유 (${displayColumn})`; + } + if (type === 'STATIC_SQL') { + return `정적 SQL 조건: ${value || '조건식 미입력'}`; + } + if (type === 'MY_DEPT') { + return `내 부서 행 (${displayColumn})`; + } + if (type === 'SELF') { + return `내 사번/소유자 행 (${displayColumn})`; + } + if (type === 'TAG') { + return `태그 조건 ${displayColumn} contains ${value || '-'}`; + } + return [displayColumn, type, value].filter(Boolean).join(' '); +} + function collectWizardRules(root) { return Array.from(root.querySelectorAll('.rule-row')).map((row) => { const column = row.querySelector('[name="ruleColumn"]')?.value || ''; @@ -721,25 +775,7 @@ function collectWizardRules(root) { STATIC_SQL: '', ALL: '' }[type] || ''; - if (type === 'ALL') { - return 'ALL'; - } - if (type === 'STATIC_SQL') { - return `정적 SQL: ${value}`; - } - if (['MY_DEPT', 'SELF'].includes(type)) { - return `${displayColumn} ${type}`; - } - if (['STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type)) { - return `${displayColumn} ${type} 역할=${value}`; - } - if (['TOKEN_SUBJECT', 'OWN_CONTRACT', 'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'].includes(type)) { - return `${displayColumn} ${type}`; - } - if (type === 'TAG') { - return `${displayColumn} TAG ${value.toUpperCase()}`; - } - return [displayColumn, type, value].filter(Boolean).join(' '); + return permissionRuleBusinessLabel(type, displayColumn, value); }).filter(Boolean); } @@ -821,7 +857,6 @@ function updatePermissionWizardPreview(root = document) { const roleSelect = wizard.querySelector('[name="roleId"]'); const objectSelect = wizard.querySelector('[name="objectRef"]'); const effectSelect = wizard.querySelector('[name="permissionEffect"]'); - const visibleColumns = wizard.querySelector('[name="visibleColumns"]')?.value.trim() || ''; const roleOption = selectedOption(roleSelect); const objectOption = selectedOption(objectSelect); const effect = effectSelect?.value || 'ALLOW'; @@ -837,20 +872,15 @@ function updatePermissionWizardPreview(root = document) { const groupUsers = splitList(roleOption?.dataset.groupUsers || ''); const objectColumns = splitList(objectOption?.dataset.columns || '', ','); const maskableColumns = splitList(objectOption?.dataset.maskableColumns || '', ','); - const visibleColumnList = splitList(visibleColumns, ',').map((column) => column.toUpperCase()); - const nullColumns = maskableColumns.filter((column) => !visibleColumnList.includes(column.toUpperCase())); const rowPolicy = effect === 'DENY' ? `거부 규칙: ${ruleText}` : `허용 규칙: ${ruleText}`; const predicateText = effect === 'DENY' ? (rules.includes('ALL') ? 'DENY ALL: 1 = 0' : `DENY 후보: NOT (${predicates.join(' AND ') || '조건 없음'})`) : `ALLOW 후보: ${predicates.join(' AND ') || '조건 없음'}`; - const columnPolicy = visibleColumns - ? `이 권한에서 원문 표시 허용: ${visibleColumns}` - : '마스킹 대상 컬럼은 기본 정책대로 NULL/마스킹 처리'; - const nullPolicy = !maskableColumns.length - ? '마스킹 대상 컬럼 없음' - : (nullColumns.length ? `NULL 처리: ${nullColumns.join(', ')}` : '선택한 마스킹 컬럼 모두 원문 표시 허용'); + const columnPolicy = maskableColumns.length + ? `ASO 마스킹 후보: ${maskableColumns.join(', ')}. 실제 원문/마스킹은 컬럼 마스킹 화면에서 설정합니다.` + : '이 행 접근 규칙은 행만 제어합니다. 컬럼 원문/마스킹은 컬럼 마스킹에서 관리합니다.'; const affectedPrincipals = `직접 사용자 ${directUsers.length}명 / 그룹 ${groups.length}개 / 그룹 상속 사용자 ${groupUsers.length}명`; const readiness = !hasRole ? '역할을 선택하세요' @@ -883,7 +913,6 @@ function updatePermissionWizardPreview(root = document) { setWizardPreview(wizard, 'rowPolicy', rowPolicy); setWizardPreview(wizard, 'predicatePreview', predicateText); setWizardPreview(wizard, 'columnPolicy', columnPolicy); - setWizardPreview(wizard, 'nullPolicy', nullPolicy); setWizardPreview(wizard, 'saveGuard', hasRole && hasObject ? saveGuard : '역할과 보호 객체를 선택하면 저장 영향을 계산합니다.'); } @@ -1185,12 +1214,72 @@ function initVpdTargetFilters() { applyFilters(); } +function initMaskingExceptionPreview() { + const preview = document.querySelector('[data-masking-exception-preview]'); + const columnSelect = document.querySelector('select[name="columnId"]'); + const userSelect = document.querySelector('select[name="userId"]'); + if (!preview || !columnSelect) { + return; + } + + const empty = preview.querySelector('[data-masking-preview-empty]'); + const details = preview.querySelector('[data-masking-preview-details]'); + const target = preview.querySelector('[data-masking-preview-target]'); + const masked = preview.querySelector('[data-masking-preview-masked]'); + const unmasked = preview.querySelector('[data-masking-preview-unmasked]'); + const context = preview.querySelector('[data-masking-preview-context]'); + + const update = () => { + const option = columnSelect.options[columnSelect.selectedIndex]; + if (!option?.value) { + if (empty) { + empty.hidden = false; + } + if (details) { + details.hidden = true; + } + return; + } + const selectedUser = userSelect?.options[userSelect.selectedIndex]; + const userLabel = selectedUser?.dataset.userLabel || '선택한 사용자'; + const columnTarget = option.dataset.target || option.textContent?.trim() || '선택한 컬럼'; + const template = option.dataset.template || '컬럼 마스킹 규칙'; + const result = option.dataset.result || '마스킹된 값'; + const asoFunction = option.dataset.asoFunction || 'DBMS_REDACT'; + const contextName = option.dataset.context || 'MR_'; + + if (empty) { + empty.hidden = true; + } + if (details) { + details.hidden = false; + } + if (target) { + target.textContent = `${columnTarget} · ${template} (${asoFunction})`; + } + if (masked) { + masked.textContent = `${columnTarget} 값은 ${result}로 반환됩니다.`; + } + if (unmasked) { + unmasked.textContent = `${userLabel}은(는) 행 접근 정책이 허용한 행에서 원래 ${columnTarget} 값을 봅니다.`; + } + if (context) { + context.textContent = `CB_AGENT_CTX.${contextName} = Y이면 원문, 기본값 또는 N이면 ${asoFunction}으로 마스킹합니다.`; + } + }; + + columnSelect.addEventListener('change', update); + userSelect?.addEventListener('change', update); + update(); +} + document.addEventListener('DOMContentLoaded', () => { initPersistentMenus(); initQuestionPresets(); initCopyButtons(); renderMarkdownViews(); initVpdTargetFilters(); + initMaskingExceptionPreview(); const master = document.getElementById('userRoleMaster'); if (master) { master.addEventListener('change', filterUserRoleDetail); @@ -1219,8 +1308,6 @@ document.addEventListener('DOMContentLoaded', () => { }); }); syncRuleTypeHints(); - document.querySelector('input[name="visibleColumns"]')?.addEventListener('input', renderSelectedVisibleColumns); - renderSelectedVisibleColumns(); const catalog = document.getElementById('objectCatalogSelect'); if (catalog) { catalog.addEventListener('change', syncObjectCatalogSelection); @@ -1261,6 +1348,7 @@ document.addEventListener('DOMContentLoaded', () => { }); }); updatePermissionWizardPreview(document); + disableReadOnlyMutationForms(); }); document.body.addEventListener('htmx:beforeRequest', (event) => { @@ -1284,4 +1372,5 @@ document.body.addEventListener('htmx:afterSwap', (event) => { button.classList.add('active'); } renderMarkdownViews(event.detail.target || document); + disableReadOnlyMutationForms(); }); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 209ee3f..5d29467 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -1,31 +1,31 @@ - +
- VPD 권한 운영 -

권한 운영 흐름

+ 데이터 접근 제어 +

행 접근과 컬럼 마스킹 운영 흐름

업무 사용자와 데이터 접근 기준을 관리하고, DB가 적용한 결과까지 확인합니다.

도움말: 메뉴와 권한 적용 구조 보기

왜 권한 테이블을 따로 관리하나요?

-

DB 연결은 VPD/DDS 실행 계정 하나로 유지하되, 실제 업무 사용자는 CB_APP_USER와 역할·권한 테이블에서 찾습니다. 요청마다 토큰이 사용자를 식별하고 VPD가 그 사용자의 권한만 조건으로 계산하므로, 같은 실행 계정으로도 사용자마다 다른 행과 컬럼을 안전하게 반환할 수 있습니다.

+

DB 연결은 공용 실행 계정 하나로 유지하되, 실제 업무 사용자는 CB_APP_USER와 역할·권한 테이블에서 찾습니다. 요청마다 토큰이 사용자를 식별하고 행 접근 정책이 그 사용자의 행 조건을 계산하며, 컬럼 원문/마스킹은 ASO/Data Redaction 정책이 별도로 판단합니다.

메뉴 안내

@@ -60,8 +60,8 @@

요청마다 권한이 적용되는 흐름

- VPD 권한 적용 흐름 - Bearer Token 요청이 공용 실행 계정과 사용자 컨텍스트를 거쳐 권한 테이블에서 계산한 VPD 조건으로 보호 데이터를 조회하는 흐름 + 행 접근 적용 흐름 + Bearer Token 요청이 공용 실행 계정과 사용자 컨텍스트를 거쳐 권한 테이블에서 계산한 행 조건으로 보호 데이터를 조회하는 흐름 @@ -71,8 +71,8 @@ 요청·토큰Bearer Token 공용 실행 계정CB_ORDS한 개의 DB 연결 사용자 컨텍스트CB_AGENT_CTX사용자별로 설정 - VPD 조건 계산ALLOW · DENY · 행 · 열 - 보호 데이터 조회허용된 행·컬럼만 반환 + 행 조건 계산ALLOW · DENY · predicate + 보호 데이터 조회허용된 행 반환 사용자 · 역할 · 권한 테이블요청 시 동적으로 조회
@@ -82,7 +82,7 @@

권한 데이터 모델

- VPD 권한 ERD + 접근 제어 ERD 사용자와 그룹에서 역할을 얻고 역할에서 권한과 권한 규칙을 거쳐 보호 대상으로 연결되는 데이터 모델 @@ -92,7 +92,7 @@ CB_USER_ROLE직접 역할 연결 CB_APP_ROLE역할 CB_PERMISSION객체 접근 - CB_PERMISSION_RULE행·열 조건 + CB_PERMISSION_RULE행 조건 매핑 보호 대상TABLE / VIEW CB_USER_GROUP그룹 소속 CB_GROUP사용자 그룹 @@ -119,10 +119,10 @@
업무 흐름 - +
- +
path 사용 가능
아직 검증할 보호 객체가 없습니다. 권한 규칙에서 객체 권한을 먼저 등록하세요.
아직 검증할 보호 객체가 없습니다. 행 접근 규칙에서 객체 접근을 먼저 등록하세요.
diff --git a/src/main/resources/templates/effective-matrix.html b/src/main/resources/templates/effective-matrix.html index b877642..9cffabe 100644 --- a/src/main/resources/templates/effective-matrix.html +++ b/src/main/resources/templates/effective-matrix.html @@ -8,7 +8,7 @@

사용자별 접근 확인

도움말 -

직접 역할과 그룹 상속 역할을 합쳐 최종 권한을 계산합니다. 여기의 결과는 토큰을 발급해 ORDS/VPD 조회를 검증하기 전 확인하는 설계 근거입니다.

+

직접 역할과 그룹 상속 역할을 합쳐 최종 권한을 계산합니다. 여기의 결과는 토큰을 발급해 ORDS 행 접근 조회를 검증하기 전 확인하는 설계 근거입니다.

diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index f0ccaaf..eac7101 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -3,7 +3,9 @@ - VPD 권한 운영 + + + 데이터 접근 제어 @@ -13,15 +15,15 @@