From 908ac9a3868c513df36aadceef5426829d052c46 Mon Sep 17 00:00:00 2001 From: devmrko Date: Mon, 29 Jun 2026 12:11:25 +0900 Subject: [PATCH] fix #557: guide permission-driven VPD flow --- .../557-guided-permission-vpd-flow/README.md | 10 +- .../558-vector-tag-vpd-search/README.md | 172 ++++++++++++ ...17_agent_ords_security_local_vpd_setup.sql | 11 + ...agent_ords_security_backoffice_support.sql | 48 +++- ...agent_ords_security_dynamic_vpd_filter.sql | 17 ++ .../28_agent_ords_vector_tag_vpd_setup.sql | 257 ++++++++++++++++++ sql/adb/29_agent_ords_vector_search_ords.sql | 153 +++++++++++ sql/adb/30_agent_ords_vector_tag_vpd_test.sql | 130 +++++++++ .../31_agent_ords_backoffice_ux_metadata.sql | 58 ++++ .../domain/mcp/McpReasoningCommand.java | 1 - .../domain/probe/ProbeCommand.java | 7 +- .../protectedobject/ProtectedColumn.java | 26 ++ .../protectedobject/ProtectedObject.java | 13 +- .../ProtectedObjectCreateCommand.java | 13 +- .../domain/vpd/VpdTargetView.java | 1 + .../mapper/BearerTokenMapper.java | 2 +- .../mapper/ProtectedObjectMapper.java | 2 + .../vpdbackoffice/mapper/VpdPolicyMapper.java | 24 ++ .../service/BackofficeSchemaService.java | 24 +- .../service/BearerTokenService.java | 16 +- .../service/McpReasoningService.java | 33 ++- .../vpdbackoffice/service/McpSseService.java | 32 ++- .../service/McpToolRegistry.java | 6 +- .../service/OrdsMetadataService.java | 12 + .../service/OrdsProbeService.java | 9 +- .../service/PermissionService.java | 30 +- .../service/ProtectedObjectService.java | 29 +- .../service/VpdPolicyService.java | 89 ++++++ .../web/McpChatbotController.java | 38 ++- .../web/McpClientDemoController.java | 7 +- .../web/McpReasoningController.java | 40 ++- .../vpdbackoffice/web/ProbeController.java | 28 +- .../web/ProtectedObjectController.java | 22 +- .../vpdbackoffice/web/TokenController.java | 8 +- .../web/VpdPolicyController.java | 61 ++++- .../resources/mapper/BearerTokenMapper.xml | 2 + .../resources/mapper/PermissionMapper.xml | 3 + .../mapper/ProtectedObjectMapper.xml | 19 +- src/main/resources/mapper/VpdPolicyMapper.xml | 49 +++- src/main/resources/static/css/app.css | 30 ++ src/main/resources/static/js/app.js | 93 ++++++- .../fragments/mcp-reasoning-result.html | 3 + .../fragments/ords-handler-source.html | 1 + src/main/resources/templates/mcp-chatbot.html | 31 ++- .../resources/templates/mcp-client-demo.html | 35 +-- .../resources/templates/mcp-reasoning.html | 105 ++----- src/main/resources/templates/mcp-sse.html | 13 +- src/main/resources/templates/objects.html | 135 +++++---- src/main/resources/templates/permissions.html | 25 +- src/main/resources/templates/probe.html | 39 ++- src/main/resources/templates/roles.html | 10 +- src/main/resources/templates/tokens.html | 14 +- .../templates/vpd-filter-policies.html | 29 +- .../resources/templates/vpd-policies.html | 29 +- .../protectedobject/ProtectedColumnTest.java | 26 ++ .../service/PermissionServiceTest.java | 82 +++++- .../web/GuidedFlowTemplateTest.java | 62 ++++- 57 files changed, 1952 insertions(+), 312 deletions(-) create mode 100644 docs/design/558-vector-tag-vpd-search/README.md create mode 100644 sql/adb/28_agent_ords_vector_tag_vpd_setup.sql create mode 100644 sql/adb/29_agent_ords_vector_search_ords.sql create mode 100644 sql/adb/30_agent_ords_vector_tag_vpd_test.sql create mode 100644 sql/adb/31_agent_ords_backoffice_ux_metadata.sql create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumnTest.java diff --git a/docs/design/557-guided-permission-vpd-flow/README.md b/docs/design/557-guided-permission-vpd-flow/README.md index 4617d70..ffcd54e 100644 --- a/docs/design/557-guided-permission-vpd-flow/README.md +++ b/docs/design/557-guided-permission-vpd-flow/README.md @@ -2,7 +2,7 @@ > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-29 -> **추적성** — Redmine: #557 · 관련 ADR: 없음 +> **추적성** — Redmine: #557 · 후속 UX: #558~#565 · 관련 ADR: 없음 > · 구현 파일: `ProbeController`, `ProbeResult`, `VpdPolicyService`, 대시보드/VPD/토큰/검증 템플릿 > · 테스트: `ProbeResultTest`, `VpdPolicyServiceTest`, `GuidedFlowTemplateTest` @@ -12,7 +12,7 @@ ## 2. 범위 (Scope) -- **포함**: Macro→Micro 설명 구조, 내비게이션 재분류, 동적 권한 VPD 기본 적용, custom filter 고급 분리, 토큰 검증 단순화, 상태별 일반 문장과 다음 행동, 발급→검증 연결. +- **포함**: Macro→Micro 설명 구조, 내비게이션 재분류, 동적 권한 VPD 기본 적용, custom filter 고급 분리, 토큰 검증 단순화, 원문/임시 토큰 검증, 상태별 일반 문장과 다음 행동, 발급→검증 연결. - **제외**: 권한 데이터 모델 변경, VPD 함수 SQL 알고리즘 변경, 토큰 원문 저장, 운영 DB DDL 자동 실행, ORDS handler 생성 방식 변경. ## 3. 인수조건 (Acceptance Criteria) @@ -21,7 +21,7 @@ - [x] 기본 VPD 적용은 객체만 선택하고 권한체계 동적 함수와 SELECT 정책을 서버가 결정한다. - [x] `CB_AGENT_DOC_VPD_FILTER`는 일반 UI에서 수정할 수 없고 직접 POST도 거부된다. - [x] 별도 filter/predicate와 policy 교체는 사용 기준·안전 규칙이 있는 고급 영역에만 노출된다. -- [x] 검증 화면은 등록 토큰 선택 없이 Bearer 원문과 대상만 입력한다. +- [x] 검증 화면은 등록 토큰 선택 없이 Bearer 원문 또는 테스트 중에만 존재하는 임시 토큰과 대상을 입력한다. - [x] 검증 결과가 상태, 적용 주체, 행 결과, 다음 행동 순으로 설명되고 HTTP 원문은 접혀 있다. - [x] 존재하지 않는 테스트 토큰은 “DB에 등록된 토큰이 아님”과 재발급 절차를 안내한다. - [x] 발급 직후 토큰 복사와 검증 화면 이동이 명확하다. @@ -45,7 +45,7 @@ │ ▼ [Evidence: 실제로 지켜졌는가] - 1회 표시 토큰 → ORDS 호출 → 사용자/역할 + 보이는 행 + 다음 행동 + 1회 표시/임시 토큰 → ORDS 호출 → 사용자/역할 + 보이는 행 + 다음 행동 ``` - I/O 경계: controller/service는 DB catalog와 ORDS를 호출한다. @@ -57,7 +57,7 @@ - 입력: `objectKey`, `bearerToken`, `objectId`, `limit`. - 기본 VPD 명령: policy `CB_PERMISSION_SELECT_POLICY`, function `*.CB_AGENT_DOC_VPD_FILTER`, statements `SELECT`, enabled `true`, update check `false`. - 검증 표현: 기존 `ProbeResult`에 상태별 `title`, `plainSummary`, `nextAction`, `successLike` 계산 메서드를 둔다. -- 검증 컨텍스트: hash로 찾은 `TokenContextView`와 `ProtectedObject`를 controller model에 추가한다. 원문은 model/result/log에 저장하지 않는다. +- 검증 컨텍스트: hash로 찾은 `TokenContextView`와 `ProtectedObject`를 controller model에 추가한다. 임시 토큰은 실행 후 회수한다. 원문은 model/result/log에 저장하지 않는다. ## 7. 함수 명세 (Function Specs) diff --git a/docs/design/558-vector-tag-vpd-search/README.md b/docs/design/558-vector-tag-vpd-search/README.md new file mode 100644 index 0000000..bd9dddd --- /dev/null +++ b/docs/design/558-vector-tag-vpd-search/README.md @@ -0,0 +1,172 @@ +# 설계서: 기술 태그 기반 벡터 지식자료 검색과 VPD 연결 + +> **상태**: 구현 초안 +> **추적성**: Redmine #565 · 기준 구현: `28_agent_ords_vector_tag_vpd_setup.sql`, `29_agent_ords_vector_search_ords.sql` + +## 1. 한 문장으로 이해하기 + +문서를 잘게 나누고(청킹) 숫자 벡터로 바꾼 뒤(벡터화) 각 조각에 `SPRING_BOOT`, `ORACLE_VPD` 같은 기술 태그를 붙인다. 사용자의 권한에는 “이 태그를 볼 수 있음”을 등록하고, ORDS 검색은 권한에 맞는 조각만 검색 결과에 남긴다. + +이 기능에서 태그는 문서의 분류표이고, VPD는 그 분류표를 이용해 DB가 실제로 보여 줄 행을 제한하는 장치다. 화면에서 검색 조건을 넣는 것만으로는 우회할 수 없도록 DB 안에서 마지막 필터를 적용한다. + +## 2. 큰 흐름 (일반 사용자가 보는 순서) + +```text +지식자료 등록 + → 문서를 문단 단위 청크로 나눔 + → 각 청크를 임베딩 모델로 벡터화 + → 청크마다 TECH_TAG를 하나 이상 부여 + → ADMIN.CB_VECTOR_SEARCH_DOCUMENTS 뷰에 노출 + +권한 설계 + → 역할에 SELECT 권한 부여 + → 행 규칙에서 특정 기술 태그(TAG)를 여러 개 등록 + → 같은 ALLOW 권한의 태그는 OR(하나라도 일치하면 허용) + → DENY 태그는 허용 후보에서 다시 제외 + +검색 + → 호출자가 Bearer 토큰을 보냄 + → ORDS가 토큰으로 사용자 컨텍스트를 설정 + → VPD가 TECH_TAG 권한에 맞지 않는 행을 먼저 제거 + → 남은 청크만 벡터 거리순으로 정렬해 반환 +``` + +## 3. 마이크로 동작 (DB에서 실제로 일어나는 일) + +### 3.1 저장 구조 + +| 객체 | 역할 | +|---|---| +| `CB_VECTOR_DOCUMENT_CHUNK` | 문서 ID, 청크 번호, 제목, 본문, 원본 주소, 임베딩 벡터 저장 | +| `CB_VECTOR_DOCUMENT_TAG` | 청크와 기술 태그의 다대다 연결. 한 청크에 태그 여러 개 가능 | +| `CB_VECTOR_SEARCH_DOCUMENTS` | 청크 한 개당 한 행으로 태그를 쉼표로 합친 검색용 뷰. VPD를 이 뷰에 부착 | +| `CB_PERMISSION_RULE` | 기존 권한 체계에 `rule_type = TAG`, `rule_value = 태그`로 저장 | + +`TECH_TAG`는 VPD가 읽는 행 접근 기준이다. `EMBEDDING`의 민감도나 `CHUNK_TEXT`의 표시 방식은 별도의 컬럼 표시 보호 정책이며, 태그 권한과 섞지 않는다. + +### 3.2 TAG 규칙 + +- 컬럼을 비워 두면 기본 컬럼 `TECH_TAG`를 사용한다. +- 태그 값은 저장할 때 대문자로 정규화한다. 예: `spring_boot` → `SPRING_BOOT`. +- 같은 `ALLOW` 권한에 태그를 여러 개 넣으면 OR이다. + +```text +ALLOW TAG SPRING_BOOT +ALLOW TAG ORACLE_VPD + +결과: TECH_TAG 목록에 SPRING_BOOT가 포함됨 + OR TECH_TAG 목록에 ORACLE_VPD가 포함됨 +``` + +- `DENY` 태그도 내부적으로 OR로 묶은 뒤 허용 결과에서 뺀다. + +```text +(허용 태그 A OR 허용 태그 B) +AND NOT (거부 태그 C OR 거부 태그 D) +``` + +- 허용 규칙이 없거나 태그 컬럼이 실제 객체에 없으면 `1 = 0`으로 닫는다(fail-closed). +- 사용자가 여러 역할을 가지고 있어도 허용 태그는 합쳐져 OR가 된다. 어떤 역할의 DENY 태그와 일치하면 최종 결과에서 제외된다. + +## 4. 예시 시나리오 + +샘플 데이터에는 다음과 같은 청크가 있다. + +| 청크 | 태그 | +|---|---| +| Spring Boot VPD 시작하기 | `SPRING_BOOT`, `ORDS` | +| Oracle VPD 정책 연결 | `ORACLE_VPD`, `ORDS` | +| MCP 도구 권한 설계 | `MCP` | + +역할을 다음처럼 만든다. + +1. `KNOWLEDGE_BACKEND`: `ALLOW TAG SPRING_BOOT`, `ALLOW TAG ORACLE_VPD` +2. `KNOWLEDGE_MCP`: `ALLOW TAG MCP` +3. `KNOWLEDGE_NO_ORDS`: `ALLOW TAG SPRING_BOOT`, `ALLOW TAG ORACLE_VPD`, `DENY TAG ORDS` + +그러면: + +- `KNOWLEDGE_BACKEND` 사용자는 첫 번째와 두 번째 청크를 본다. +- `KNOWLEDGE_MCP` 사용자는 세 번째 청크만 본다. +- `KNOWLEDGE_NO_ORDS` 사용자는 `ORDS` 태그가 붙은 첫 번째·두 번째 청크가 거부되어 결과가 없다. +- 태그 권한이 전혀 없는 사용자는 결과가 없다. + +## 5. ORDS API 계약 + +### 요청 + +```http +POST /ords/cb-ords/cb-agent-vector/search?limit=10 +Authorization: Bearer <발급된 토큰> +Content-Type: application/json + +{"embedding":[0.10,0.20,0.30,0.40]} +``` + +`embedding`은 검색어를 외부 임베딩 모델로 변환한 벡터다. DB는 임베딩 모델을 호출하지 않는다. 따라서 모델 선택·차원·재임베딩 일정은 지식자료 파이프라인에서 관리한다. + +### 응답 + +```json +{ + "items": [ + { + "chunk_id": 28001, + "document_id": "knowledge-001", + "chunk_no": 1, + "title": "Spring Boot VPD 시작하기", + "chunk_text": "...", + "source_uri": "kb://security/spring-boot-vpd", + "tech_tag": "ORDS,SPRING_BOOT", + "score": 0.0123 + } + ] +} +``` + +`CB_VECTOR_SEARCH_DOCUMENTS`는 청크 한 개당 한 행을 만들고 `TECH_TAG`에 태그 목록을 담는다. 그래서 VPD가 허용/거부를 청크 전체에 적용할 수 있다. ORDS 핸들러는 VPD를 통과한 청크를 벡터 거리순으로 정렬한다. `EMBEDDING` 자체는 응답에 포함하지 않는다. + +## 6. 설치·등록 순서 + +운영 DB에 자동 실행하지 않고 다음 순서로 승인된 환경에서 실행한다. + +1. `25_agent_ords_security_backoffice_support.sql` — 기존 backoffice 권한 메타데이터 +2. `31_agent_ords_backoffice_ux_metadata.sql` — 기존 설치에 조회 대상 설명/정책 설명 메타데이터 추가 +3. `26_agent_ords_security_dynamic_vpd_filter.sql` — `TAG` 분기를 포함한 동적 VPD 함수 +4. `28_agent_ords_vector_tag_vpd_setup.sql` — VECTOR 테이블·태그 테이블·뷰·VPD·보호 객체 등록 +5. `29_agent_ords_vector_search_ords.sql` — `cb-agent-vector/search` ORDS 모듈/핸들러 (CB_ORDS로 실행) +6. backoffice `/permissions`에서 역할별 `특정 기술 태그` 규칙을 저장 +7. `30_agent_ords_vector_tag_vpd_test.sql` — OR와 DENY 우선순위 확인 + +backoffice의 `/objects` 화면은 이 객체에 일반 Handler를 생성하지 않도록 막는다. 일반 Handler를 사용하면 벡터 컬럼을 그대로 반환할 수 있기 때문에, 임베딩을 응답에서 제외하고 벡터 거리 검색을 하는 전용 Handler만 허용한다. + +실제 지식자료를 넣을 때는 샘플 청크 대신 ingestion 작업이 다음을 반복한다. + +```text +원문 → 청크 분할 → 임베딩 생성 → CB_VECTOR_DOCUMENT_CHUNK 저장 + → CB_VECTOR_DOCUMENT_TAG에 태그 저장 +``` + +## 7. 운영 가이드라인 + +- 태그는 자유 문장보다 대문자·언더스코어 형태의 안정적인 ID로 관리한다. 예: `SPRING_BOOT`, `ORACLE_VPD`, `INTERNAL_ONLY`. +- 현재 예시의 태그 ID 허용 문자는 영문·숫자·`_`·`-`다. 이 범위를 벗어난 값은 UI와 VPD 함수에서 모두 무시해 권한이 넓어지지 않게 한다. +- 동의어를 무분별하게 만들지 말고 태그 사전을 먼저 정한다. `SPRING`, `SPRINGBOOT`, `SPRING_BOOT`을 모두 별개로 만들면 권한 누락이 생긴다. +- 문서 청크에 최소 한 개의 기술 태그를 부여한다. 태그가 없는 청크는 이 뷰에 노출되지 않는다. +- 태그를 수정하면 권한 결과가 즉시 바뀐다. 임베딩을 다시 만들 필요가 없는 태그 변경과, 내용이 바뀌어 재임베딩해야 하는 변경을 구분한다. +- `TECH_TAG`는 행 접근용이고, 컬럼 민감도/마스킹은 별도 정책이다. “컬럼을 PUBLIC으로 표시했다”는 사실이 행 조회 권한을 부여하지 않는다. +- 벡터 차원은 ingestion 모델과 테이블 데이터가 일치해야 한다. 차원이 다르면 ORDS가 4xx/5xx 오류를 반환하므로 모델 버전을 함께 기록한다. + +## 8. 검증 기준 + +- 같은 ALLOW 권한의 태그 두 개가 각각 일치하는 청크를 반환한다. +- ALLOW 태그와 DENY 태그가 함께 있으면 DENY 일치 청크는 반환되지 않는다. +- 태그 권한이 없는 사용자, 잘못된 토큰, 존재하지 않는 태그는 결과가 0건 또는 차단으로 끝난다. +- ORDS 응답에는 허용되지 않은 태그의 청크가 섞이지 않는다. +- 검색 결과에 임베딩 원문이 포함되지 않는다. +- `/permissions` 화면에서 `TAG` 규칙이 기본 `TECH_TAG`와 OR 의미를 일반 문장으로 설명한다. + +## 9. 범위 밖 + +- 임베딩 모델 호출·문서 업로드 UI·태그 사전 승인 워크플로는 이번 시나리오에서 구현하지 않는다. +- 운영 DB에 대한 DDL, 기존 정책 교체, 방화벽/NSG 변경은 별도 승인을 받아 실행한다. diff --git a/sql/adb/17_agent_ords_security_local_vpd_setup.sql b/sql/adb/17_agent_ords_security_local_vpd_setup.sql index 6fb70da..f0b0d56 100644 --- a/sql/adb/17_agent_ords_security_local_vpd_setup.sql +++ b/sql/adb/17_agent_ords_security_local_vpd_setup.sql @@ -372,6 +372,17 @@ BEGIN IF v_column IS NOT NULL THEN append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value)); END IF; + ELSIF r.rule_type = 'TAG' THEN + v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'TECH_TAG')); + IF v_column IS NOT NULL + AND r.rule_value IS NOT NULL + AND REGEXP_LIKE(TRIM(r.rule_value), '^[A-Za-z0-9_-]+$') THEN + append_or( + r.permission_effect, + 'REGEXP_LIKE(UPPER(' || v_column || '), ' || + quote_literal('(^|,)' || UPPER(TRIM(r.rule_value)) || '(,|$)') || ')' + ); + END IF; ELSIF r.rule_type = '=' THEN v_column := safe_column(p_schema, p_object, r.rule_column); IF v_column IS NOT NULL THEN diff --git a/sql/adb/25_agent_ords_security_backoffice_support.sql b/sql/adb/25_agent_ords_security_backoffice_support.sql index 941a265..404862d 100644 --- a/sql/adb/25_agent_ords_security_backoffice_support.sql +++ b/sql/adb/25_agent_ords_security_backoffice_support.sql @@ -125,9 +125,20 @@ CREATE TABLE cb_protected_object ( owner VARCHAR2(128) NOT NULL, object_name VARCHAR2(128) NOT NULL UNIQUE, ords_path VARCHAR2(300) NOT NULL, - enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL + enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL, + description VARCHAR2(200) ); +BEGIN + EXECUTE IMMEDIATE 'ALTER TABLE cb_protected_object ADD (description VARCHAR2(200))'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -1430 THEN + RAISE; + END IF; +END; +/ + CREATE TABLE cb_protected_column ( column_id NUMBER PRIMARY KEY, object_id NUMBER NOT NULL REFERENCES cb_protected_object(object_id), @@ -184,6 +195,41 @@ CREATE TABLE cb_ords_probe_audit ( created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL ); +BEGIN + EXECUTE IMMEDIATE ' + CREATE TABLE cb_vpd_policy_note ( + object_owner VARCHAR2(128) NOT NULL, + object_name VARCHAR2(128) NOT NULL, + policy_name VARCHAR2(128) NOT NULL, + description VARCHAR2(500) NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT cb_vpd_policy_note_pk PRIMARY KEY (object_owner, object_name, policy_name) + )'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ + +BEGIN + EXECUTE IMMEDIATE ' + CREATE TABLE cb_vpd_filter_note ( + function_owner VARCHAR2(128) NOT NULL, + function_name VARCHAR2(128) NOT NULL, + description VARCHAR2(500) NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT cb_vpd_filter_note_pk PRIMARY KEY (function_owner, function_name) + )'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ + PROMPT === Creating backoffice settings === BEGIN EXECUTE IMMEDIATE ' diff --git a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql index 37d2aea..90360aa 100644 --- a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +++ b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql @@ -6,6 +6,8 @@ -- CB_AGENT_CTX.USER_ID -> direct CB_USER_ROLE + group CB_USER_GROUP/CB_GROUP_ROLE -- -> CB_PERMISSION -> CB_PERMISSION_RULE -- and builds a row predicate for the object passed by DBMS_RLS. +-- Supported rule types include TAG. TAG matches a token in a comma-delimited +-- TECH_TAG value, allowing the vector search view to remain one row per chunk. -- ============================================================ WHENEVER SQLERROR EXIT SQL.SQLCODE SET ECHO OFF @@ -152,6 +154,21 @@ BEGIN IF v_column IS NOT NULL THEN append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value)); END IF; + ELSIF r.rule_type = 'TAG' THEN + -- A TAG rule defaults to the ingestion metadata column TECH_TAG. + -- Multiple ALLOW TAG rows are accumulated by append_or, so any + -- matching tag grants visibility. DENY rows are accumulated + -- separately and removed from the final result. + v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'TECH_TAG')); + IF v_column IS NOT NULL + AND r.rule_value IS NOT NULL + AND REGEXP_LIKE(TRIM(r.rule_value), '^[A-Za-z0-9_-]+$') THEN + append_or( + r.permission_effect, + 'REGEXP_LIKE(UPPER(' || v_column || '), ' || + quote_literal('(^|,)' || UPPER(TRIM(r.rule_value)) || '(,|$)') || ')' + ); + END IF; ELSIF r.rule_type = '=' THEN v_column := safe_column(p_schema, p_object, r.rule_column); IF v_column IS NOT NULL THEN diff --git a/sql/adb/28_agent_ords_vector_tag_vpd_setup.sql b/sql/adb/28_agent_ords_vector_tag_vpd_setup.sql new file mode 100644 index 0000000..192922b --- /dev/null +++ b/sql/adb/28_agent_ords_vector_tag_vpd_setup.sql @@ -0,0 +1,257 @@ +-- ============================================================ +-- 28_agent_ords_vector_tag_vpd_setup.sql +-- Vector/chunk knowledge search data plane with tag-based VPD. +-- +-- Run as ADMIN after: +-- * 25_agent_ords_security_backoffice_support.sql +-- * 26_agent_ords_security_dynamic_vpd_filter.sql +-- +-- Target: Oracle AI Database 23ai/26ai (VECTOR data type). +-- The embedding is produced by the ingestion pipeline. The database +-- stores the chunk, its vector, and one or more normalized TECH_TAG rows. +-- The VPD policy protects a one-row-per-chunk view whose TECH_TAG value is a +-- comma-delimited tag list, so ALLOW and DENY apply to the whole chunk. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO ON +SET FEEDBACK ON +SET DEFINE OFF + +PROMPT === 1. Creating vector chunk and tag tables === +BEGIN + EXECUTE IMMEDIATE q'! + CREATE TABLE cb_vector_document_chunk ( + chunk_id NUMBER PRIMARY KEY, + document_id VARCHAR2(200) NOT NULL, + chunk_no NUMBER NOT NULL, + title VARCHAR2(500), + chunk_text CLOB NOT NULL, + source_uri VARCHAR2(1000), + embedding VECTOR, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT cb_vector_chunk_uk UNIQUE (document_id, chunk_no) + )!'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ + +BEGIN + EXECUTE IMMEDIATE q'! + CREATE TABLE cb_vector_document_tag ( + chunk_id NUMBER NOT NULL REFERENCES cb_vector_document_chunk(chunk_id) ON DELETE CASCADE, + tech_tag VARCHAR2(100) NOT NULL, + CONSTRAINT cb_vector_tag_pk PRIMARY KEY (chunk_id, tech_tag), + CONSTRAINT cb_vector_tag_format_ck CHECK (REGEXP_LIKE(tech_tag, '^[A-Z0-9_-]+$')) + )!'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ + +BEGIN + EXECUTE IMMEDIATE 'CREATE INDEX cb_vector_tag_ix ON cb_vector_document_tag (tech_tag, chunk_id)'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ + +PROMPT === 2. Seeding a small, repeatable vector search example === +-- These four-dimensional vectors are only a smoke-test fixture. Replace +-- them with the embedding model's dimension in the real ingestion pipeline. +INSERT INTO cb_vector_document_chunk ( + chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding +) +SELECT 28001, 'knowledge-001', 1, 'Spring Boot VPD 시작하기', + TO_CLOB('Spring Boot 애플리케이션은 토큰으로 사용자를 식별하고 ORDS를 호출한다.'), + 'kb://security/spring-boot-vpd', TO_VECTOR('[0.10,0.20,0.30,0.40]') +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM cb_vector_document_chunk WHERE chunk_id = 28001); + +INSERT INTO cb_vector_document_chunk ( + chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding +) +SELECT 28002, 'knowledge-001', 2, 'Oracle VPD 정책 연결', + TO_CLOB('Oracle VPD는 현재 사용자 권한에 맞는 행만 반환하도록 WHERE 조건을 동적으로 붙인다.'), + 'kb://security/oracle-vpd', TO_VECTOR('[0.20,0.30,0.40,0.50]') +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM cb_vector_document_chunk WHERE chunk_id = 28002); + +INSERT INTO cb_vector_document_chunk ( + chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding +) +SELECT 28003, 'knowledge-002', 1, 'MCP 도구 권한 설계', + TO_CLOB('MCP 도구도 같은 역할과 기술 태그 규칙을 사용해 지식자료 검색 범위를 제한할 수 있다.'), + 'kb://platform/mcp-permission', TO_VECTOR('[0.80,0.70,0.60,0.50]') +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM cb_vector_document_chunk WHERE chunk_id = 28003); + +INSERT INTO cb_vector_document_tag (chunk_id, tech_tag) +SELECT 28001, 'SPRING_BOOT' FROM dual +WHERE NOT EXISTS ( + SELECT 1 FROM cb_vector_document_tag WHERE chunk_id = 28001 AND tech_tag = 'SPRING_BOOT' +); +INSERT INTO cb_vector_document_tag (chunk_id, tech_tag) +SELECT 28001, 'ORDS' FROM dual +WHERE NOT EXISTS ( + SELECT 1 FROM cb_vector_document_tag WHERE chunk_id = 28001 AND tech_tag = 'ORDS' +); +INSERT INTO cb_vector_document_tag (chunk_id, tech_tag) +SELECT 28002, 'ORACLE_VPD' FROM dual +WHERE NOT EXISTS ( + SELECT 1 FROM cb_vector_document_tag WHERE chunk_id = 28002 AND tech_tag = 'ORACLE_VPD' +); +INSERT INTO cb_vector_document_tag (chunk_id, tech_tag) +SELECT 28002, 'ORDS' FROM dual +WHERE NOT EXISTS ( + SELECT 1 FROM cb_vector_document_tag WHERE chunk_id = 28002 AND tech_tag = 'ORDS' +); +INSERT INTO cb_vector_document_tag (chunk_id, tech_tag) +SELECT 28003, 'MCP' FROM dual +WHERE NOT EXISTS ( + SELECT 1 FROM cb_vector_document_tag WHERE chunk_id = 28003 AND tech_tag = 'MCP' +); + +PROMPT === 3. Creating the tag-aware one-row-per-chunk protected view === +CREATE OR REPLACE VIEW cb_vector_search_documents AS +SELECT c.chunk_id, + c.document_id, + c.chunk_no, + c.title, + c.chunk_text, + c.source_uri, + c.embedding, + ( + SELECT LISTAGG(t.tech_tag, ',') WITHIN GROUP (ORDER BY t.tech_tag) + FROM cb_vector_document_tag t + WHERE t.chunk_id = c.chunk_id + ) AS tech_tag +FROM cb_vector_document_chunk c +WHERE EXISTS ( + SELECT 1 + FROM cb_vector_document_tag t + WHERE t.chunk_id = c.chunk_id +); + +PROMPT === 4. Attaching dynamic VPD to the view === +BEGIN + DBMS_RLS.DROP_POLICY( + object_schema => USER, + object_name => 'CB_VECTOR_SEARCH_DOCUMENTS', + policy_name => 'CB_VECTOR_TAG_SELECT_POLICY' + ); +EXCEPTION + WHEN OTHERS THEN NULL; +END; +/ + +BEGIN + DBMS_RLS.ADD_POLICY( + object_schema => USER, + object_name => 'CB_VECTOR_SEARCH_DOCUMENTS', + policy_name => 'CB_VECTOR_TAG_SELECT_POLICY', + function_schema => USER, + policy_function => 'CB_AGENT_DOC_VPD_FILTER', + statement_types => 'SELECT', + policy_type => DBMS_RLS.DYNAMIC, + enable => TRUE + ); +END; +/ + +PROMPT === 5. Registering the protected object and its columns === +DECLARE + v_object_id NUMBER; + + PROCEDURE ensure_column( + p_column_name IN VARCHAR2, + p_sensitive_yn IN CHAR, + p_sensitivity_level IN VARCHAR2, + p_redaction_method IN VARCHAR2 + ) IS + v_count NUMBER; + v_id NUMBER; + BEGIN + SELECT COUNT(*) + INTO v_count + FROM cb_protected_column + WHERE object_id = v_object_id + AND column_name = p_column_name; + + IF v_count = 0 THEN + SELECT NVL(MAX(column_id), 0) + 1 + INTO v_id + FROM cb_protected_column; + + INSERT INTO cb_protected_column ( + column_id, object_id, column_name, sensitive_yn, sensitivity_level, redaction_method + ) VALUES ( + v_id, v_object_id, p_column_name, p_sensitive_yn, p_sensitivity_level, p_redaction_method + ); + ELSE + UPDATE cb_protected_column + SET sensitive_yn = p_sensitive_yn, + sensitivity_level = p_sensitivity_level, + redaction_method = p_redaction_method + WHERE object_id = v_object_id + AND column_name = p_column_name; + END IF; + END; +BEGIN + BEGIN + SELECT object_id + INTO v_object_id + FROM cb_protected_object + WHERE object_name = 'CB_VECTOR_SEARCH_DOCUMENTS'; + + UPDATE cb_protected_object + SET owner = USER, + ords_path = 'cb-ords/cb-agent-vector/search', + enabled_yn = 'Y', + description = '기술 태그 권한이 적용된 벡터 청크 검색 전용 ORDS Handler' + WHERE object_id = v_object_id; + EXCEPTION + WHEN NO_DATA_FOUND THEN + SELECT NVL(MAX(object_id), 0) + 1 + INTO v_object_id + FROM cb_protected_object; + + INSERT INTO cb_protected_object ( + object_id, owner, object_name, ords_path, enabled_yn, description + ) VALUES ( + v_object_id, USER, 'CB_VECTOR_SEARCH_DOCUMENTS', + 'cb-ords/cb-agent-vector/search', 'Y', + '기술 태그 권한이 적용된 벡터 청크 검색 전용 ORDS Handler' + ); + END; + + -- Column policy here is only a display-policy hint. Row access is the TAG + -- VPD rule above; the vector handler never returns EMBEDDING to the caller. + ensure_column('CHUNK_ID', 'N', 'PUBLIC', 'NONE'); + ensure_column('DOCUMENT_ID', 'N', 'PUBLIC', 'NONE'); + ensure_column('CHUNK_NO', 'N', 'PUBLIC', 'NONE'); + ensure_column('TITLE', 'N', 'PUBLIC', 'NONE'); + ensure_column('CHUNK_TEXT', 'N', 'PUBLIC', 'NONE'); + ensure_column('SOURCE_URI', 'N', 'PUBLIC', 'NONE'); + ensure_column('TECH_TAG', 'N', 'PUBLIC', 'NONE'); + ensure_column('EMBEDDING', 'Y', 'RESTRICTED', 'NULLIFY'); +END; +/ + +PROMPT === 6. Granting the ORDS runtime read access to the protected view === +GRANT SELECT ON cb_vector_search_documents TO cb_ords; + +COMMIT; + +PROMPT === Vector/tag VPD setup complete === +PROMPT Next: run 29_agent_ords_vector_search_ords.sql as CB_ORDS. +EXIT; diff --git a/sql/adb/29_agent_ords_vector_search_ords.sql b/sql/adb/29_agent_ords_vector_search_ords.sql new file mode 100644 index 0000000..865fd7f --- /dev/null +++ b/sql/adb/29_agent_ords_vector_search_ords.sql @@ -0,0 +1,153 @@ +-- ============================================================ +-- 29_agent_ords_vector_search_ords.sql +-- Dedicated ORDS vector search endpoint for the tag-protected view. +-- +-- Run as CB_ORDS after 22_agent_ords_security_ords_handler_setup.sql and +-- 28_agent_ords_vector_tag_vpd_setup.sql. +-- +-- Request: +-- POST /ords/cb-ords/cb-agent-vector/search?limit=10 +-- Authorization: Bearer +-- {"embedding":[0.10,0.20,0.30,0.40]} +-- +-- The handler sets the application context, lets VPD filter one row per +-- chunk by the aggregated TECH_TAG list, then calculates vector distance. +-- EMBEDDING is never returned. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO ON +SET FEEDBACK ON +SET DEFINE OFF + +PROMPT === Creating the vector search ORDS module === +BEGIN + ORDS.DELETE_MODULE(p_module_name => 'cb.agent.vector'); +EXCEPTION + WHEN OTHERS THEN NULL; +END; +/ + +BEGIN + ORDS.DEFINE_MODULE( + p_module_name => 'cb.agent.vector', + p_base_path => 'cb-agent-vector/', + p_items_per_page => 25, + p_status => 'PUBLISHED' + ); + + ORDS.DEFINE_TEMPLATE( + p_module_name => 'cb.agent.vector', + p_pattern => 'search' + ); + + ORDS.DEFINE_HANDLER( + p_module_name => 'cb.agent.vector', + p_pattern => 'search', + p_method => 'POST', + p_source_type => ORDS.source_type_plsql, + p_source => q'! +DECLARE + v_rows SYS_REFCURSOR; + v_embedding_text VARCHAR2(32767); +BEGIN + cb_ords_handler_pkg.set_vpd_context(:auth_header); + + -- JSON_QUERY accepts a numeric JSON array. The JSON_VALUE fallback also + -- permits clients that send the vector as the text "[0.1,0.2,...]". + v_embedding_text := JSON_QUERY( + :body_text, + '$.embedding' RETURNING VARCHAR2(32767) + ); + IF v_embedding_text IS NULL THEN + v_embedding_text := JSON_VALUE( + :body_text, + '$.embedding' RETURNING VARCHAR2(32767) + ); + END IF; + + IF v_embedding_text IS NULL OR TRIM(v_embedding_text) IS NULL THEN + RAISE_APPLICATION_ERROR(-20801, 'embedding is required'); + END IF; + + OPEN v_rows FOR + SELECT chunk_id, + document_id, + chunk_no, + title, + chunk_text, + source_uri, + tech_tag, + score + FROM ( + SELECT d.chunk_id, + d.document_id, + d.chunk_no, + d.title, + d.chunk_text, + d.source_uri, + d.tech_tag, + VECTOR_DISTANCE( + d.embedding, + TO_VECTOR(v_embedding_text), + COSINE + ) AS score + FROM admin.cb_vector_search_documents d + WHERE d.embedding IS NOT NULL + ORDER BY score + ) ranked_chunks + WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100); + + :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('items', v_rows); + APEX_JSON.CLOSE_OBJECT; + + cb_ords_handler_pkg.clear_vpd_context; +EXCEPTION + WHEN OTHERS THEN + cb_ords_handler_pkg.clear_vpd_context; + :status_code := CASE WHEN SQLCODE = -20801 THEN 400 ELSE 403 END; + 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('error', SQLERRM); + APEX_JSON.CLOSE_OBJECT; +END; +!', + p_items_per_page => 0 + ); + + ORDS.DEFINE_PARAMETER( + p_module_name => 'cb.agent.vector', + p_pattern => 'search', + 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 => 'cb.agent.vector', + p_pattern => 'search', + p_method => 'POST', + p_name => 'limit', + p_bind_variable_name => 'row_limit', + p_source_type => 'URI', + p_param_type => 'INT', + p_access_method => 'IN' + ); + + COMMIT; +END; +/ + +PROMPT === Vector search ORDS endpoint ready === +PROMPT Path: /ords/cb-ords/cb-agent-vector/search +EXIT; diff --git a/sql/adb/30_agent_ords_vector_tag_vpd_test.sql b/sql/adb/30_agent_ords_vector_tag_vpd_test.sql new file mode 100644 index 0000000..c36e10c --- /dev/null +++ b/sql/adb/30_agent_ords_vector_tag_vpd_test.sql @@ -0,0 +1,130 @@ +-- ============================================================ +-- 30_agent_ords_vector_tag_vpd_test.sql +-- Unit-style checks for TAG rules on CB_VECTOR_SEARCH_DOCUMENTS. +-- +-- Run as ADMIN after 26_agent_ords_security_dynamic_vpd_filter.sql and +-- 28_agent_ords_vector_tag_vpd_setup.sql. +-- This script only creates temporary permission-plane rows in the 458xxx +-- range, checks the generated predicates, and removes those rows. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO OFF +SET FEEDBACK ON +SET DEFINE OFF +SET SERVEROUTPUT ON + +PROMPT === Preparing TAG rule test data === + +BEGIN + cb_agent_ctx_pkg.clear_user; + + DELETE FROM cb_permission_rule WHERE perm_id BETWEEN 458000 AND 458999; + DELETE FROM cb_permission WHERE perm_id BETWEEN 458000 AND 458999; + DELETE FROM cb_user_role WHERE role_id BETWEEN 458000 AND 458999; + DELETE FROM cb_app_role WHERE role_id BETWEEN 458000 AND 458999; + DELETE FROM cb_app_user WHERE user_id BETWEEN 458000 AND 458999; + + INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) + VALUES (458101, 'tag_test_or', 'E458101', 'QA', 'N', 'Y'); + INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) + VALUES (458102, 'tag_test_deny', 'E458102', 'QA', 'N', 'Y'); + INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) + VALUES (458103, 'tag_test_none', 'E458103', 'QA', 'N', 'Y'); + + INSERT INTO cb_app_role(role_id, role_name) VALUES (458101, 'TAG_TEST_OR'); + INSERT INTO cb_app_role(role_id, role_name) VALUES (458102, 'TAG_TEST_ALLOW_DENY'); + INSERT INTO cb_app_role(role_id, role_name) VALUES (458103, 'TAG_TEST_NONE'); + + INSERT INTO cb_user_role(user_id, role_id) VALUES (458101, 458101); + INSERT INTO cb_user_role(user_id, role_id) VALUES (458102, 458102); + INSERT INTO cb_user_role(user_id, role_id) VALUES (458103, 458103); + + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect) + VALUES (458101, 458101, 'CB_VECTOR_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW'); + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect) + VALUES (458102, 458102, 'CB_VECTOR_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW'); + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect) + VALUES (458103, 458102, 'CB_VECTOR_SEARCH_DOCUMENTS', 'SELECT', 'DENY'); + + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect) + VALUES (458104, 458103, 'CB_VECTOR_SEARCH_DOCUMENTS', 'SELECT', 'DENY'); + + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (458101, 458101, NULL, 'TAG', 'SPRING_BOOT'); + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (458102, 458101, NULL, 'TAG', 'ORACLE_VPD'); + + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (458103, 458102, NULL, 'TAG', 'SPRING_BOOT'); + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (458104, 458102, NULL, 'TAG', 'ORACLE_VPD'); + + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (458105, 458103, NULL, 'TAG', 'ORDS'); + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (458106, 458104, NULL, 'TAG', 'SPRING_BOOT'); + + COMMIT; +END; +/ + +PROMPT === Checking ALLOW TAG OR semantics and DENY precedence === + +DECLARE + PROCEDURE assert_contains( + p_label IN VARCHAR2, + p_user_id IN NUMBER, + p_expected IN VARCHAR2 + ) IS + v_actual VARCHAR2(32767); + BEGIN + cb_agent_ctx_pkg.set_user(p_user_id); + v_actual := cb_agent_doc_vpd_filter('ADMIN', 'CB_VECTOR_SEARCH_DOCUMENTS'); + IF INSTR(v_actual, p_expected) = 0 THEN + RAISE_APPLICATION_ERROR(-20458, + p_label || ' expected [' || p_expected || '] actual [' || v_actual || ']'); + END IF; + DBMS_OUTPUT.PUT_LINE('PASS ' || p_label || ': ' || v_actual); + END; + + PROCEDURE assert_equals( + p_label IN VARCHAR2, + p_user_id IN NUMBER, + p_expected IN VARCHAR2 + ) IS + v_actual VARCHAR2(32767); + BEGIN + cb_agent_ctx_pkg.set_user(p_user_id); + v_actual := cb_agent_doc_vpd_filter('ADMIN', 'CB_VECTOR_SEARCH_DOCUMENTS'); + IF v_actual != p_expected THEN + RAISE_APPLICATION_ERROR(-20458, + p_label || ' expected [' || p_expected || '] actual [' || v_actual || ']'); + END IF; + DBMS_OUTPUT.PUT_LINE('PASS ' || p_label || ': ' || v_actual); + END; +BEGIN + assert_contains('TWO_ALLOW_TAGS_ARE_OR', 458101, 'OR'); + assert_contains('TWO_ALLOW_TAGS_INCLUDE_SPRING', 458101, 'REGEXP_LIKE(UPPER(TECH_TAG), ''(^|,)SPRING_BOOT(,|$)'')'); + assert_contains('TWO_ALLOW_TAGS_INCLUDE_VPD', 458101, 'REGEXP_LIKE(UPPER(TECH_TAG), ''(^|,)ORACLE_VPD(,|$)'')'); + assert_contains('DENY_TAG_IS_SUBTRACTED', 458102, 'AND NOT'); + assert_contains('DENY_TAG_DETAIL', 458102, 'REGEXP_LIKE(UPPER(TECH_TAG), ''(^|,)ORDS(,|$)'')'); + assert_equals('DENY_ONLY_FAILS_CLOSED', 458103, '1 = 0'); + cb_agent_ctx_pkg.clear_user; +END; +/ + +PROMPT === Cleaning TAG rule test data === + +BEGIN + cb_agent_ctx_pkg.clear_user; + DELETE FROM cb_permission_rule WHERE perm_id BETWEEN 458000 AND 458999; + DELETE FROM cb_permission WHERE perm_id BETWEEN 458000 AND 458999; + DELETE FROM cb_user_role WHERE role_id BETWEEN 458000 AND 458999; + DELETE FROM cb_app_role WHERE role_id BETWEEN 458000 AND 458999; + DELETE FROM cb_app_user WHERE user_id BETWEEN 458000 AND 458999; + COMMIT; +END; +/ + +PROMPT === TAG rule checks passed === +EXIT; diff --git a/sql/adb/31_agent_ords_backoffice_ux_metadata.sql b/sql/adb/31_agent_ords_backoffice_ux_metadata.sql new file mode 100644 index 0000000..804a557 --- /dev/null +++ b/sql/adb/31_agent_ords_backoffice_ux_metadata.sql @@ -0,0 +1,58 @@ +-- ============================================================ +-- 31_agent_ords_backoffice_ux_metadata.sql +-- Upgrade existing backoffice metadata for the screen explanations. +-- Run as ADMIN before deploying this application version. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO ON +SET FEEDBACK ON +SET DEFINE OFF + +BEGIN + EXECUTE IMMEDIATE 'ALTER TABLE cb_protected_object ADD (description VARCHAR2(200))'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -1430 THEN + RAISE; + END IF; +END; +/ + +BEGIN + EXECUTE IMMEDIATE q'! + CREATE TABLE cb_vpd_policy_note ( + object_owner VARCHAR2(128) NOT NULL, + object_name VARCHAR2(128) NOT NULL, + policy_name VARCHAR2(128) NOT NULL, + description VARCHAR2(500) NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT cb_vpd_policy_note_pk PRIMARY KEY (object_owner, object_name, policy_name) + )!'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ + +BEGIN + EXECUTE IMMEDIATE q'! + CREATE TABLE cb_vpd_filter_note ( + function_owner VARCHAR2(128) NOT NULL, + function_name VARCHAR2(128) NOT NULL, + description VARCHAR2(500) NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT cb_vpd_filter_note_pk PRIMARY KEY (function_owner, function_name) + )!'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ + +COMMIT; +PROMPT === Backoffice UX metadata upgrade complete === +EXIT; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java index 3b137a4..85b78e5 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java @@ -2,7 +2,6 @@ package com.cloudhandson.vpdbackoffice.domain.mcp; public record McpReasoningCommand( Long tokenKeyId, - long objectId, String bearerToken, int limit, String question diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java index a830e74..d6830a8 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java @@ -9,6 +9,11 @@ public record ProbeCommand( Long tokenKeyId, @Positive long objectId, @NotBlank String bearerToken, - @Min(1) @Max(500) int limit + @Min(1) @Max(500) int limit, + String requestBody ) { + + public ProbeCommand(Long tokenKeyId, long objectId, String bearerToken, int limit) { + this(tokenKeyId, objectId, bearerToken, limit, null); + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java index 6957596..e81df11 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java @@ -1,5 +1,7 @@ package com.cloudhandson.vpdbackoffice.domain.protectedobject; +import java.util.Locale; + public record ProtectedColumn( long columnId, long objectId, @@ -20,4 +22,28 @@ public record ProtectedColumn( String method = redactionMethod == null || redactionMethod.isBlank() ? "NONE" : redactionMethod; return level + "/" + method; } + + public String sensitivityLabel() { + String level = sensitivityLevel == null || sensitivityLevel.isBlank() ? "PUBLIC" : sensitivityLevel; + return switch (level.toUpperCase(Locale.ROOT)) { + case "INTERNAL" -> "내부용"; + case "CONFIDENTIAL" -> "기밀"; + case "RESTRICTED" -> "제한"; + default -> "기본 표시"; + }; + } + + public String redactionLabel() { + String method = redactionMethod == null || redactionMethod.isBlank() ? "NONE" : redactionMethod; + return switch (method.toUpperCase(Locale.ROOT)) { + case "NULLIFY" -> "값 숨김(NULL)"; + case "PARTIAL" -> "일부 숨김"; + case "FULL" -> "전체 숨김"; + default -> "마스킹 없음"; + }; + } + + public String displayPolicyLabel() { + return sensitive() ? sensitivityLabel() + " · " + redactionLabel() : "기본 표시 · 마스킹 없음"; + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java index bedbf86..b0a6b62 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java @@ -5,9 +5,14 @@ public record ProtectedObject( String owner, String objectName, String ordsPath, - String enabledYn + String enabledYn, + String description ) { + public ProtectedObject(long objectId, String owner, String objectName, String ordsPath, String enabledYn) { + this(objectId, owner, objectName, ordsPath, enabledYn, null); + } + public boolean enabled() { return "Y".equalsIgnoreCase(enabledYn); } @@ -15,4 +20,10 @@ public record ProtectedObject( public String displayName() { return owner + "." + objectName; } + + public String descriptionOrDefault() { + return description == null || description.isBlank() + ? "등록된 DB 객체를 권한체계와 ORDS로 조회하는 대상" + : description; + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java index 364d3d9..6f5b7b3 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObjectCreateCommand.java @@ -7,6 +7,17 @@ public record ProtectedObjectCreateCommand( @NotBlank String objectName, @NotBlank String ordsPath, String columns, - String sensitiveColumns + String sensitiveColumns, + String description ) { + + public ProtectedObjectCreateCommand( + String owner, + String objectName, + String ordsPath, + String columns, + String sensitiveColumns + ) { + this(owner, objectName, ordsPath, columns, sensitiveColumns, null); + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java index 911088b..58fd8a6 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdTargetView.java @@ -6,6 +6,7 @@ public record VpdTargetView( String objectType, String protectedYn, String ordsPath, + String description, int policyCount, String policyNames, String filterNames diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java index 6d1a807..5964f1c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java @@ -9,7 +9,7 @@ import org.apache.ibatis.annotations.Param; @Mapper public interface BearerTokenMapper { - List findAll(); + List findAll(@Param("includeInactive") int includeInactive); BearerTokenRecord findById(@Param("keyId") long keyId); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java index 80f8510..d1c7dc8 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java @@ -44,6 +44,8 @@ public interface ProtectedObjectMapper { int updateOrdsPath(@Param("objectId") long objectId, @Param("ordsPath") String ordsPath); + int updateDescription(@Param("objectId") long objectId, @Param("description") String description); + int enableObject(@Param("objectId") long objectId); int disableObject(@Param("objectId") long objectId); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java index 0d2f60e..e357a2f 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/VpdPolicyMapper.java @@ -54,4 +54,28 @@ public interface VpdPolicyMapper { @Param("objectName") String objectName, @Param("objectType") String objectType ); + + String findPolicyDescription( + @Param("objectOwner") String objectOwner, + @Param("objectName") String objectName, + @Param("policyName") String policyName + ); + + String findFilterDescription( + @Param("functionOwner") String functionOwner, + @Param("functionName") String functionName + ); + + int upsertPolicyDescription( + @Param("objectOwner") String objectOwner, + @Param("objectName") String objectName, + @Param("policyName") String policyName, + @Param("description") String description + ); + + int upsertFilterDescription( + @Param("functionOwner") String functionOwner, + @Param("functionName") String functionName, + @Param("description") String description + ); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index 5b61ad6..ab1f53b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -114,7 +114,8 @@ public class BackofficeSchemaService { owner VARCHAR2(128) NOT NULL, object_name VARCHAR2(128) NOT NULL UNIQUE, ords_path VARCHAR2(300) NOT NULL, - enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL + enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL, + description VARCHAR2(200) ) """), new TableDefinition("CB_PROTECTED_COLUMN", """ @@ -148,6 +149,25 @@ public class BackofficeSchemaService { setting_value VARCHAR2(1000), updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL ) + """), + new TableDefinition("CB_VPD_POLICY_NOTE", """ + CREATE TABLE cb_vpd_policy_note ( + object_owner VARCHAR2(128) NOT NULL, + object_name VARCHAR2(128) NOT NULL, + policy_name VARCHAR2(128) NOT NULL, + description VARCHAR2(500) NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT cb_vpd_policy_note_pk PRIMARY KEY (object_owner, object_name, policy_name) + ) + """), + new TableDefinition("CB_VPD_FILTER_NOTE", """ + CREATE TABLE cb_vpd_filter_note ( + function_owner VARCHAR2(128) NOT NULL, + function_name VARCHAR2(128) NOT NULL, + description VARCHAR2(500) NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT cb_vpd_filter_note_pk PRIMARY KEY (function_owner, function_name) + ) """) ); @@ -160,6 +180,8 @@ public class BackofficeSchemaService { "ALTER TABLE cb_permission_rule ADD (rule_column VARCHAR2(128))"), new ColumnDefinition("CB_AGENT_BEARER_KEY", "DESCRIPTION", "ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))"), + new ColumnDefinition("CB_PROTECTED_OBJECT", "DESCRIPTION", + "ALTER TABLE cb_protected_object ADD (description VARCHAR2(200))"), new ColumnDefinition("CB_PROTECTED_COLUMN", "SENSITIVITY_LEVEL", "ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)"), new ColumnDefinition("CB_PROTECTED_COLUMN", "REDACTION_METHOD", diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java index 22ca3be..cbddf94 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java @@ -14,6 +14,7 @@ import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper; import com.cloudhandson.vpdbackoffice.mapper.GroupMapper; import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import java.time.Clock; +import java.time.Duration; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZoneId; @@ -59,11 +60,15 @@ public class BearerTokenService { } public List findAll() { - return tokenMapper.findAll(); + return findAll(false); + } + + public List findAll(boolean includeInactive) { + return tokenMapper.findAll(includeInactive ? 1 : 0); } public List findTokenContextOptions() { - List tokens = tokenMapper.findAll(); + List tokens = findAll(true); List groupUsers = groupMapper.findGroupUsers(); Map> directRolesByUser = directRolesByUser(); Map> groupsByUser = groupsByUser(groupUsers); @@ -196,6 +201,13 @@ public class BearerTokenService { return new IssuedToken(keyId, prefix, plainToken, command.expiresAt()); } + @Transactional + public IssuedToken issueTemporaryToken(long userId, String purpose) { + OffsetDateTime expiresAt = OffsetDateTime.now(clock).plus(Duration.ofMinutes(10)); + return issueToken(new TokenIssueCommand(userId, expiresAt, + purpose == null || purpose.isBlank() ? "임시 검증 토큰" : purpose)); + } + @Transactional public void revokeToken(long keyId, String reason) { int updated = tokenMapper.revokeToken( diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java index 46e1bf0..866b115 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java @@ -6,10 +6,11 @@ import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus; -import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Comparator; import java.util.List; +import java.util.Locale; import org.springframework.stereotype.Service; @Service @@ -17,20 +18,17 @@ public class McpReasoningService { private static final int MAX_EVIDENCE_ROWS = 20; - private final ProtectedObjectService protectedObjectService; private final OrdsProbeService ordsProbeService; private final McpToolRegistry toolRegistry; private final OpenAiCompatibleClient aiClient; private final ObjectMapper objectMapper; public McpReasoningService( - ProtectedObjectService protectedObjectService, OrdsProbeService ordsProbeService, McpToolRegistry toolRegistry, OpenAiCompatibleClient aiClient, ObjectMapper objectMapper ) { - this.protectedObjectService = protectedObjectService; this.ordsProbeService = ordsProbeService; this.toolRegistry = toolRegistry; this.aiClient = aiClient; @@ -38,11 +36,10 @@ public class McpReasoningService { } public McpReasoningResult reason(McpReasoningCommand command) { - ProtectedObject object = protectedObjectService.assertEnabled(command.objectId()); - McpToolView tool = toolRegistry.toolFor(object); + McpToolView tool = selectTool(command.question()); ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand( command.tokenKeyId(), - command.objectId(), + tool.objectId(), command.bearerToken(), normalizeLimit(command.limit()) )); @@ -75,6 +72,28 @@ public class McpReasoningService { } } + private McpToolView selectTool(String question) { + List tools = toolRegistry.listTools(); + if (tools.isEmpty()) { + throw new AppException("사용 가능한 MCP tool이 없습니다. ORDS 조회 대상을 먼저 등록하세요."); + } + String normalized = question == null ? "" : question.toLowerCase(Locale.ROOT); + return tools.stream() + .max(Comparator.comparingInt(tool -> score(tool, normalized))) + .orElseThrow(); + } + + private int score(McpToolView tool, String question) { + int score = 0; + for (String token : (tool.name() + " " + tool.displayName() + " " + tool.description()) + .toLowerCase(Locale.ROOT).split("[^a-z0-9가-힣]+")) { + if (token.length() >= 2 && question.contains(token)) { + score += 1; + } + } + return score; + } + private int normalizeLimit(int limit) { if (limit < 1) { return 50; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 4cf636b..e650245 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -73,14 +73,14 @@ public class McpSseService { ObjectNode item = objectMapper.createObjectNode(); item.put("name", tool.name()); item.put("description", tool.description()); - item.set("inputSchema", inputSchema()); + item.set("inputSchema", inputSchema(tool)); tools.add(item); } result.set("tools", tools); return result; } - private ObjectNode inputSchema() { + private ObjectNode inputSchema(McpToolView tool) { ObjectNode schema = objectMapper.createObjectNode(); schema.put("type", "object"); ObjectNode properties = objectMapper.createObjectNode(); @@ -100,6 +100,17 @@ public class McpSseService { 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", "외부 임베딩 모델이 만든 검색 벡터. 이 예제 fixture는 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; @@ -111,7 +122,18 @@ public class McpSseService { McpToolView tool = findTool(toolName); String bearerToken = arguments.path("bearerToken").asText(""); int limit = normalizeLimit(arguments.path("limit").asInt(50)); - ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(null, tool.objectId(), bearerToken, limit)); + 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(); + } + ProbeResult probeResult = ordsProbeService.runProbe( + new ProbeCommand(null, tool.objectId(), bearerToken, limit, requestBody)); ObjectNode payload = objectMapper.createObjectNode(); payload.put("toolName", tool.name()); @@ -149,6 +171,10 @@ public class McpSseService { .orElseThrow(() -> new AppException("MCP tool을 찾을 수 없습니다: " + toolName)); } + private boolean isVectorTool(McpToolView tool) { + return tool != null && tool.displayName().toUpperCase().endsWith("CB_VECTOR_SEARCH_DOCUMENTS"); + } + private int normalizeLimit(int limit) { if (limit < 1) { return 50; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java index 59db854..3608eb7 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java @@ -27,9 +27,13 @@ public class McpToolRegistry { private McpToolView toTool(ProtectedObject object) { String name = "ords.query." + safeName(object.owner()) + "." + safeName(object.objectName()); + String vectorHint = "CB_VECTOR_SEARCH_DOCUMENTS".equalsIgnoreCase(object.objectName()) + ? " embedding[]을 입력으로 받는 전용 벡터 검색 Handler입니다." + : ""; return new McpToolView( name, - object.displayName() + " 보호 객체를 Bearer Token으로 ORDS 호출해 VPD/Redaction 적용 결과를 조회합니다.", + object.descriptionOrDefault() + " · " + + object.displayName() + "을 Bearer Token으로 ORDS 호출해 VPD/표시 보호 결과를 조회합니다." + vectorHint, object.objectId(), object.displayName(), object.ordsPath() diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java index b231519..c3eb51b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java @@ -20,6 +20,8 @@ import org.springframework.transaction.annotation.Transactional; @Service public class OrdsMetadataService { + private static final String VECTOR_SEARCH_OBJECT = "CB_VECTOR_SEARCH_DOCUMENTS"; + private final OrdsMetadataMapper mapper; private final JdbcTemplate jdbcTemplate; private final JdbcTemplate ordsMetadataJdbcTemplate; @@ -77,6 +79,7 @@ public class OrdsMetadataService { public String objectQueryHandlerSource(long objectId) { ProtectedObject object = protectedObjectService.assertEnabled(objectId); + rejectGenericVectorHandler(object); List columns = protectedObjectService.findColumns(objectId).stream() .map(ProtectedColumn::columnName) .toList(); @@ -138,6 +141,7 @@ public class OrdsMetadataService { @Transactional public OrdsObjectHandlerResult createObjectQueryHandler(long objectId) { ProtectedObject object = protectedObjectService.assertEnabled(objectId); + rejectGenericVectorHandler(object); List columns = protectedObjectService.findColumns(objectId).stream() .map(ProtectedColumn::columnName) .toList(); @@ -263,6 +267,14 @@ public class OrdsMetadataService { """.formatted(selectColumns, object.owner(), object.objectName()); } + private void rejectGenericVectorHandler(ProtectedObject object) { + if (VECTOR_SEARCH_OBJECT.equalsIgnoreCase(object.objectName())) { + throw new AppException( + "벡터 검색 객체는 일반 Handler로 등록할 수 없습니다. " + + "29_agent_ords_vector_search_ords.sql의 전용 Handler를 사용하세요(EMBEDDING 미노출)."); + } + } + private String handlerSourceTypeCode(String sourceType) { String normalized = sourceType == null ? "" : sourceType.toLowerCase(Locale.ROOT); if (normalized.contains("query")) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java index 1e4f227..bb920b3 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java @@ -23,6 +23,7 @@ import java.util.Set; 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.stereotype.Service; import org.springframework.web.client.HttpStatusCodeException; @@ -114,10 +115,14 @@ public class OrdsProbeService { URI uri = buildUri(ordsBaseUrl, object.ordsPath(), command.limit()); HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(command.bearerToken()); + headers.setContentType(MediaType.APPLICATION_JSON); + String requestBody = command.requestBody() == null || command.requestBody().isBlank() + ? "{}" + : command.requestBody().trim(); requestHeaders = prettyHeaders(maskedRequestHeaders(headers)); - requestPayload = prettyJson("{}"); + requestPayload = prettyJson(requestBody); ResponseEntity response = ordsRestTemplate.exchange( - uri, HttpMethod.POST, new HttpEntity<>(headers), String.class); + uri, HttpMethod.POST, new HttpEntity<>(requestBody, headers), String.class); ProbeResult result = parseSuccess( response.getBody(), object.objectId(), diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java index fa8a57f..58e387d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -19,9 +19,12 @@ import org.springframework.transaction.annotation.Transactional; @Service public class PermissionService { - private static final Set RULE_TYPES = Set.of("ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO"); - private static final Set VALUE_REQUIRED_RULE_TYPES = Set.of("=", "!=", "DEPT", "EMP_NO"); - private static final Set DEFAULT_COLUMN_RULE_TYPES = Set.of("MY_DEPT", "SELF", "DEPT", "EMP_NO"); + private static final Set RULE_TYPES = Set.of( + "ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG"); + private static final Set VALUE_REQUIRED_RULE_TYPES = Set.of( + "=", "!=", "DEPT", "EMP_NO", "TAG"); + private static final Set DEFAULT_COLUMN_RULE_TYPES = Set.of( + "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG"); private static final Set PERMISSION_EFFECTS = Set.of("ALLOW", "DENY"); private static final Set SENSITIVITY_LEVELS = Set.of( "PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED"); @@ -124,12 +127,13 @@ public class PermissionService { permissionMapper.deleteRules(permissionId); for (RuleCommand rule : command.rules()) { + String type = normalize(rule.ruleType()); permissionMapper.insertRule(new PermissionRule( permissionMapper.nextRuleId(), permissionId, normalizeNullable(rule.ruleColumn()), - normalize(rule.ruleType()), - clean(rule.ruleValue()) + type, + normalizeRuleValue(type, rule.ruleValue()) )); } @@ -191,16 +195,23 @@ public class PermissionService { if (column != null && !allowedColumns.contains(column)) { throw new AppException("행 규칙 컬럼은 보호 객체 컬럼이어야 합니다: " + column); } - if (!seen.add(column + ":" + type + ":" + clean(rule.ruleValue()))) { + String ruleValue = normalizeRuleValue(type, rule.ruleValue()); + if (!seen.add(column + ":" + type + ":" + ruleValue)) { throw new AppException("중복된 행 규칙이 있습니다."); } hasAll = hasAll || "ALL".equals(type); if (!"ALL".equals(type) && column == null && !DEFAULT_COLUMN_RULE_TYPES.contains(type)) { throw new AppException(type + " 규칙에는 컬럼이 필요합니다."); } - if (VALUE_REQUIRED_RULE_TYPES.contains(type) && clean(rule.ruleValue()).isBlank()) { + if ("TAG".equals(type) && column == null && !allowedColumns.contains("TECH_TAG")) { + throw new AppException("TAG 규칙의 기본 컬럼 TECH_TAG가 보호 객체에 없습니다. 컬럼을 지정하세요."); + } + if (VALUE_REQUIRED_RULE_TYPES.contains(type) && ruleValue.isBlank()) { throw new AppException(type + " 규칙에는 값이 필요합니다."); } + if ("TAG".equals(type) && !ruleValue.matches("[A-Z0-9_-]+")) { + throw new AppException("TAG 값은 영문 대문자, 숫자, '_' 또는 '-'만 사용할 수 있습니다."); + } } if (hasAll && rules.size() > 1) { throw new AppException("ALL 규칙은 다른 규칙과 함께 저장할 수 없습니다."); @@ -255,6 +266,11 @@ public class PermissionService { return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT); } + private String normalizeRuleValue(String type, String value) { + String cleaned = clean(value); + return "TAG".equals(type) ? cleaned.toUpperCase(Locale.ROOT) : cleaned; + } + private String clean(String value) { return value == null ? "" : value.trim(); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java index 97548c9..f3d0ec2 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java @@ -137,7 +137,8 @@ public class ProtectedObjectService { normalizedObjectName, defaultOrdsPath(normalizedOwner, normalizedObjectName), String.join(",", columns), - "" + "", + "DB 객체를 한 개의 ORDS 조회 Handler로 노출하는 예제 대상" ); long objectId = mapper.nextObjectId(); mapper.insertObject(objectId, command); @@ -170,13 +171,20 @@ public class ProtectedObjectService { String objectName = command.objectName().trim().toUpperCase(Locale.ROOT); String ordsPath = command.ordsPath(); if (ordsPath == null || ordsPath.isBlank()) { - throw new AppException("ORDS Path는 실제 ORDS module/template 경로를 입력해야 합니다."); + ordsPath = defaultOrdsPath(owner, objectName); } String columns = command.columns(); if (columns == null || columns.isBlank()) { columns = String.join(",", mapper.findDatabaseColumns(owner, objectName)); } - return new ProtectedObjectCreateCommand(owner, objectName, ordsPath.trim(), columns, command.sensitiveColumns()); + return new ProtectedObjectCreateCommand( + owner, + objectName, + ordsPath.trim(), + columns, + command.sensitiveColumns(), + command.description() + ); } @Transactional @@ -192,6 +200,21 @@ public class ProtectedObjectService { ordsPath.trim())); } + @Transactional + public void updateDescription(long objectId, String description) { + String normalized = description == null ? "" : description.trim(); + if (normalized.length() > 200) { + throw new AppException("조회 대상 설명은 200자 이내여야 합니다."); + } + int updated = mapper.updateDescription(objectId, normalized.isBlank() ? null : normalized); + if (updated == 0) { + throw new AppException("설명을 수정할 조회 대상을 찾을 수 없습니다."); + } + databaseObjectsCache = null; + auditService.record(new AuditEvent("PROTECTED_OBJECT_DESCRIPTION_UPDATED", null, objectId, "SUCCESS", null, null, + normalized)); + } + @Transactional public void updateColumnPolicy(long columnId, String sensitivityLevel, String redactionMethod) { String normalizedLevel = normalizeOption(sensitivityLevel, "PUBLIC", SENSITIVITY_LEVELS, "민감도 등급"); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java index 8c46c99..177aa6b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java @@ -20,6 +20,8 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -32,6 +34,9 @@ public class VpdPolicyService { private static final long CATALOG_CACHE_MILLIS = 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"; + private static final Pattern RETURN_LITERAL = Pattern.compile( + "(?is)\\bRETURN\\s+'((?:''|[^'])*)'\\s*;" + ); private final VpdPolicyMapper mapper; private final JdbcTemplate jdbcTemplate; @@ -94,6 +99,10 @@ public class VpdPolicyService { ); } + public String currentUser() { + return jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class); + } + @Transactional public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) { String functionName = requiredIdentifier(functionNameValue, "Function name"); @@ -275,6 +284,75 @@ public class VpdPolicyService { return new VpdPolicyDetail(policy, buildAddPolicyBlock(policy)); } + public String findPolicyDescription(String objectOwner, String objectName, String policyName) { + String description; + try { + description = mapper.findPolicyDescription(objectOwner, objectName, policyName); + } catch (DataAccessException ignored) { + description = null; + } + return description == null || description.isBlank() + ? objectOwner + "." + objectName + "에 요청마다 현재 권한체계의 행 접근 조건을 적용하는 " + policyName + " policy입니다." + : description; + } + + public String findFilterDescription(String functionOwner, String functionName) { + String description; + try { + description = mapper.findFilterDescription(functionOwner, functionName); + } catch (DataAccessException ignored) { + description = null; + } + return description == null || description.isBlank() + ? (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName) + ? "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다." + : "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다.") + : description; + } + + /** + * 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 + * source is not a single editable literal. + */ + public String findFilterPredicate(String owner, String packageName, String functionName) { + if (packageName != null && !packageName.isBlank() + || DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) { + return ""; + } + try { + VpdFunctionSource source = findFunctionSource(owner, null, functionName); + if (source.source() == null) { + return ""; + } + Matcher matcher = RETURN_LITERAL.matcher(source.source()); + return matcher.find() ? matcher.group(1).replace("''", "'") : ""; + } catch (DataAccessException | AppException ignored) { + return ""; + } + } + + @Transactional + public void savePolicyDescription(String objectOwner, String objectName, String policyName, String description) { + String normalized = normalizeDescription(description); + mapper.upsertPolicyDescription( + requiredIdentifier(objectOwner, "Object owner"), + requiredIdentifier(objectName, "Object name"), + requiredIdentifier(policyName, "Policy name"), + normalized + ); + } + + @Transactional + public void saveFilterDescription(String functionOwner, String functionName, String description) { + String normalized = normalizeDescription(description); + mapper.upsertFilterDescription( + requiredIdentifier(functionOwner, "Function owner"), + requiredIdentifier(functionName, "Function name"), + normalized + ); + } + public VpdObjectFilterDetail findObjectFilterDetail(String objectOwner, String objectName) { String normalizedOwner = requiredIdentifier(objectOwner, "Object owner"); String normalizedObject = requiredIdentifier(objectName, "Object name"); @@ -611,6 +689,17 @@ public class VpdPolicyService { return String.join(",", statements); } + private String normalizeDescription(String value) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isBlank()) { + throw new AppException("정책/필터 설명은 필수입니다."); + } + if (normalized.length() > 500) { + throw new AppException("정책/필터 설명은 500자 이내여야 합니다."); + } + return normalized; + } + private String escapeSqlLiteral(String value) { return value.replace("'", "''"); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java index 75f8475..0f6f14e 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java @@ -1,7 +1,11 @@ package com.cloudhandson.vpdbackoffice.web; import com.cloudhandson.vpdbackoffice.service.McpChatbotService; +import com.cloudhandson.vpdbackoffice.service.BearerTokenService; +import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -13,13 +17,27 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; public class McpChatbotController { private final McpChatbotService chatbotService; + private final BearerTokenService tokenService; + private final UserMapper userMapper; - public McpChatbotController(McpChatbotService chatbotService) { + public McpChatbotController( + McpChatbotService chatbotService, + BearerTokenService tokenService, + UserMapper userMapper + ) { this.chatbotService = chatbotService; + this.tokenService = tokenService; + this.userMapper = userMapper; } @GetMapping("/mcp-chatbot") - public String page() { + public String page(Model model) { + try { + model.addAttribute("users", userMapper.findAll()); + } catch (DataAccessException exception) { + model.addAttribute("users", List.of()); + model.addAttribute("runtimeError", RuntimeErrorMessages.dataAccess(exception)); + } return "mcp-chatbot"; } @@ -28,6 +46,7 @@ public class McpChatbotController { @RequestParam(defaultValue = "vpd-live") String contextPath, @RequestParam(defaultValue = "") String question, @RequestParam(defaultValue = "") String bearerToken, + @RequestParam(required = false) Long tempUserId, @RequestParam(defaultValue = "50") int limit, HttpServletRequest request, Model model @@ -38,7 +57,20 @@ public class McpChatbotController { .replaceQuery(null) .build() .toUriString(); - model.addAttribute("result", chatbotService.chat(serverOrigin, contextPath, question, bearerToken, limit)); + String effectiveToken = bearerToken; + Long temporaryKeyId = null; + if (tempUserId != null) { + var issued = tokenService.issueTemporaryToken(tempUserId, "MCP Chatbot 임시 실행"); + effectiveToken = issued.plainToken(); + temporaryKeyId = issued.keyId(); + } + try { + model.addAttribute("result", chatbotService.chat(serverOrigin, contextPath, question, effectiveToken, limit)); + } finally { + if (temporaryKeyId != null) { + tokenService.revokeToken(temporaryKeyId, "temporary chatbot completed"); + } + } } catch (Exception e) { model.addAttribute("errorMessage", e.getMessage()); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java index 1f92265..f58c45c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java @@ -9,7 +9,6 @@ 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.support.ServletUriComponentsBuilder; @Controller @@ -31,10 +30,6 @@ public class McpClientDemoController { @PostMapping("/mcp-client-demo") public String run( - @RequestParam(defaultValue = "vpd-live") String contextPath, - @RequestParam(defaultValue = "") String toolName, - @RequestParam(defaultValue = "") String bearerToken, - @RequestParam(defaultValue = "50") int limit, HttpServletRequest request, Model model ) { @@ -45,7 +40,7 @@ public class McpClientDemoController { .replaceQuery(null) .build() .toUriString(); - model.addAttribute("result", demoService.run(serverOrigin, contextPath, toolName, bearerToken, limit)); + model.addAttribute("result", demoService.run(serverOrigin, "default", "", "", 50)); } catch (Exception e) { model.addAttribute("errorMessage", e.getMessage()); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java index c20c3f6..0b6cda4 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java @@ -1,10 +1,10 @@ package com.cloudhandson.vpdbackoffice.web; 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.McpToolRegistry; -import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -21,32 +21,30 @@ public class McpReasoningController { private final McpToolRegistry toolRegistry; private final McpReasoningService reasoningService; - private final ProtectedObjectService protectedObjectService; private final BearerTokenService tokenService; + private final UserMapper userMapper; public McpReasoningController( McpToolRegistry toolRegistry, McpReasoningService reasoningService, - ProtectedObjectService protectedObjectService, - BearerTokenService tokenService + BearerTokenService tokenService, + UserMapper userMapper ) { this.toolRegistry = toolRegistry; this.reasoningService = reasoningService; - this.protectedObjectService = protectedObjectService; this.tokenService = tokenService; + this.userMapper = userMapper; } @GetMapping("/mcp-reasoning") public String page(Model model) { try { - model.addAttribute("objects", protectedObjectService.findEnabled()); model.addAttribute("tools", toolRegistry.listTools()); - model.addAttribute("tokens", tokenService.findTokenContextOptions()); + model.addAttribute("users", userMapper.findAll()); } catch (DataAccessException e) { RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e); - model.addAttribute("objects", List.of()); model.addAttribute("tools", List.of()); - model.addAttribute("tokens", List.of()); + model.addAttribute("users", List.of()); model.addAttribute("runtimeError", message); } return "mcp-reasoning"; @@ -82,15 +80,29 @@ public class McpReasoningController { @PostMapping("/mcp-reasoning") public String reason( - @RequestParam(required = false) Long tokenKeyId, - @RequestParam long objectId, - @RequestParam String bearerToken, + @RequestParam(defaultValue = "") String bearerToken, + @RequestParam(required = false) Long tempUserId, @RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "") String question, Model model ) { - model.addAttribute("result", reasoningService.reason( - new McpReasoningCommand(tokenKeyId, objectId, bearerToken, limit, question))); + String effectiveToken = bearerToken; + Long temporaryKeyId = null; + try { + if (tempUserId != null) { + var issued = tokenService.issueTemporaryToken(tempUserId, "MCP Reasoning 임시 실행"); + effectiveToken = issued.plainToken(); + temporaryKeyId = issued.keyId(); + } + model.addAttribute("result", reasoningService.reason( + new McpReasoningCommand(temporaryKeyId, effectiveToken, limit, question))); + } catch (Exception exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } finally { + if (temporaryKeyId != null) { + tokenService.revokeToken(temporaryKeyId, "temporary reasoning completed"); + } + } return "fragments/mcp-reasoning-result :: result"; } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java index fe3bf5a..c84668b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java @@ -6,6 +6,7 @@ import com.cloudhandson.vpdbackoffice.service.BearerTokenService; import com.cloudhandson.vpdbackoffice.service.OrdsProbeService; import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; import com.cloudhandson.vpdbackoffice.service.VpdPolicyService; +import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; @@ -23,17 +24,20 @@ public class ProbeController { private final ProtectedObjectService protectedObjectService; private final BearerTokenService tokenService; private final VpdPolicyService vpdPolicyService; + private final UserMapper userMapper; public ProbeController( OrdsProbeService probeService, ProtectedObjectService protectedObjectService, BearerTokenService tokenService, - VpdPolicyService vpdPolicyService + VpdPolicyService vpdPolicyService, + UserMapper userMapper ) { this.probeService = probeService; this.protectedObjectService = protectedObjectService; this.tokenService = tokenService; this.vpdPolicyService = vpdPolicyService; + this.userMapper = userMapper; } @GetMapping("/probe") @@ -48,19 +52,35 @@ public class ProbeController { .toList(); model.addAttribute("objects", objects); model.addAttribute("defaultObjectKeys", defaultObjectKeys); + model.addAttribute("users", userMapper.findAll()); return "probe"; } @PostMapping("/probe") public String run( @RequestParam long objectId, - @RequestParam String bearerToken, + @RequestParam(defaultValue = "") String bearerToken, + @RequestParam(required = false) Long tempUserId, @RequestParam(defaultValue = "50") int limit, + @RequestParam(required = false) String requestBody, Model model ) { String normalizedToken = bearerToken == null ? "" : bearerToken.trim(); - model.addAttribute("result", probeService.runProbe(new ProbeCommand(null, objectId, normalizedToken, limit))); - model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken)); + Long temporaryKeyId = null; + if (tempUserId != null) { + var issued = tokenService.issueTemporaryToken(tempUserId, "ORDS 검증 임시 실행"); + normalizedToken = issued.plainToken(); + temporaryKeyId = issued.keyId(); + } + try { + model.addAttribute("result", probeService.runProbe( + new ProbeCommand(temporaryKeyId, objectId, normalizedToken, limit, requestBody))); + model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken)); + } finally { + if (temporaryKeyId != null) { + tokenService.revokeToken(temporaryKeyId, "temporary probe completed"); + } + } model.addAttribute("selectedObject", protectedObjectService.findEnabled().stream() .filter(object -> object.objectId() == objectId) .findFirst() diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java index 11d0f57..c277864 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProtectedObjectController.java @@ -44,12 +44,13 @@ public class ProtectedObjectController { public String create( @RequestParam String owner, @RequestParam String objectName, - @RequestParam String ordsPath, + @RequestParam(required = false) String ordsPath, + @RequestParam(required = false) String description, RedirectAttributes redirectAttributes ) { try { protectedObjectService.createObject( - new ProtectedObjectCreateCommand(owner, objectName, ordsPath, null, null)); + new ProtectedObjectCreateCommand(owner, objectName, ordsPath, null, null, description)); redirectAttributes.addFlashAttribute("message", "ORDS 조회 Handler 대상을 추가했습니다."); } catch (AppException exception) { redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); @@ -57,6 +58,21 @@ public class ProtectedObjectController { return "redirect:/objects"; } + @PostMapping("/objects/description") + public String updateDescription( + @RequestParam long objectId, + @RequestParam(required = false) String description, + RedirectAttributes redirectAttributes + ) { + try { + protectedObjectService.updateDescription(objectId, description); + redirectAttributes.addFlashAttribute("message", "조회 대상 설명을 저장했습니다."); + } catch (AppException exception) { + redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); + } + return "redirect:/objects"; + } + @PostMapping("/objects/ords-path") public String updateOrdsPath( @RequestParam long objectId, @@ -81,7 +97,7 @@ public class ProtectedObjectController { ) { try { protectedObjectService.updateColumnPolicy(columnId, sensitivityLevel, redactionMethod); - redirectAttributes.addFlashAttribute("message", "컬럼 민감도/마스킹 정책을 수정했습니다."); + redirectAttributes.addFlashAttribute("message", "컬럼 표시 보호 정책을 수정했습니다. 행 접근 권한은 변경되지 않습니다."); } catch (AppException exception) { redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java index 4d74fe0..72a5b97 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java @@ -30,8 +30,12 @@ public class TokenController { } @GetMapping("/tokens") - public String tokens(Model model) { - model.addAttribute("tokens", tokenService.findAll()); + public String tokens( + @RequestParam(defaultValue = "false") boolean includeInactive, + Model model + ) { + model.addAttribute("tokens", tokenService.findAll(includeInactive)); + model.addAttribute("includeInactive", includeInactive); model.addAttribute("users", userMapper.findAll()); model.addAttribute("defaultExpiresAt", defaultExpiresAt()); return "tokens"; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java index 5f9618c..858c604 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java @@ -4,6 +4,8 @@ import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand; 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.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -42,10 +44,34 @@ public class VpdPolicyController { private void populatePolicyModel(String schemaOwner, Model model) { try { String selectedSchemaOwner = schemaOwner == null ? "" : schemaOwner.trim().toUpperCase(); - model.addAttribute("policies", vpdPolicyService.findPolicies()); + List policies = vpdPolicyService.findPolicies(); + Map policyDescriptions = new LinkedHashMap<>(); + policies.forEach(policy -> policyDescriptions.put( + policy.objectDisplayName() + "|" + policy.policyName(), + vpdPolicyService.findPolicyDescription(policy.objectOwner(), policy.objectName(), policy.policyName()) + )); + model.addAttribute("policies", policies); + model.addAttribute("policyDescriptions", policyDescriptions); model.addAttribute("vpdTargets", vpdPolicyService.findVpdTargets(selectedSchemaOwner)); model.addAttribute("selectedSchemaOwner", selectedSchemaOwner); - model.addAttribute("formOptions", vpdPolicyService.formOptions()); + var formOptions = vpdPolicyService.formOptions(); + Map filterDescriptions = new LinkedHashMap<>(); + formOptions.functions().forEach(function -> filterDescriptions.put( + function.owner() + "|" + function.functionName(), + vpdPolicyService.findFilterDescription(function.owner(), function.functionName()) + )); + policies.forEach(policy -> filterDescriptions.putIfAbsent( + policy.functionOwner() + "|" + policy.functionName(), + vpdPolicyService.findFilterDescription(policy.functionOwner(), policy.functionName()) + )); + Map filterPredicates = new LinkedHashMap<>(); + formOptions.functions().forEach(function -> filterPredicates.put( + function.owner() + "|" + function.functionName(), + vpdPolicyService.findFilterPredicate(function.owner(), function.packageName(), function.functionName()) + )); + model.addAttribute("formOptions", formOptions); + model.addAttribute("filterDescriptions", filterDescriptions); + model.addAttribute("filterPredicates", filterPredicates); } catch (DataAccessException exception) { RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception); model.addAttribute("runtimeError", message); @@ -53,12 +79,18 @@ public class VpdPolicyController { model.addAttribute("vpdTargets", List.of()); model.addAttribute("selectedSchemaOwner", ""); model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions()); + model.addAttribute("policyDescriptions", Map.of()); + model.addAttribute("filterDescriptions", Map.of()); + model.addAttribute("filterPredicates", Map.of()); } catch (AppException exception) { model.addAttribute("errorMessage", exception.getMessage()); model.addAttribute("policies", List.of()); model.addAttribute("vpdTargets", List.of()); model.addAttribute("selectedSchemaOwner", ""); model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions()); + model.addAttribute("policyDescriptions", Map.of()); + model.addAttribute("filterDescriptions", Map.of()); + model.addAttribute("filterPredicates", Map.of()); } } @@ -105,10 +137,18 @@ public class VpdPolicyController { @RequestParam(required = false) String functionOwner, @RequestParam String functionName, @RequestParam String filterPredicate, + @RequestParam(defaultValue = "Filter function이 반환하는 predicate로 조회 행을 제한합니다.") String description, RedirectAttributes redirectAttributes ) { try { vpdPolicyService.saveFilterFunction(functionOwner, functionName, filterPredicate); + vpdPolicyService.saveFilterDescription( + functionOwner == null || functionOwner.isBlank() + ? vpdPolicyService.currentUser() + : functionOwner, + functionName, + description + ); redirectAttributes.addFlashAttribute("successMessage", "Filter function을 저장했습니다: " + functionName); } catch (AppException exception) { redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); @@ -119,6 +159,23 @@ public class VpdPolicyController { return "redirect:/vpd-filter-policies"; } + @PostMapping("/vpd-policies/description") + public String savePolicyDescription( + @RequestParam String objectOwner, + @RequestParam String objectName, + @RequestParam String policyName, + @RequestParam String description, + RedirectAttributes redirectAttributes + ) { + try { + vpdPolicyService.savePolicyDescription(objectOwner, objectName, policyName, description); + redirectAttributes.addFlashAttribute("successMessage", "Policy 설명을 저장했습니다."); + } catch (AppException exception) { + redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); + } + return "redirect:/vpd-policies"; + } + @PostMapping("/vpd-filter-policies/replace") public String replaceFilterPolicy( @RequestParam String oldObjectKey, diff --git a/src/main/resources/mapper/BearerTokenMapper.xml b/src/main/resources/mapper/BearerTokenMapper.xml index 565d9a5..b693ee3 100644 --- a/src/main/resources/mapper/BearerTokenMapper.xml +++ b/src/main/resources/mapper/BearerTokenMapper.xml @@ -7,6 +7,8 @@ k.expires_at, k.revoked_at, k.description FROM cb_agent_bearer_key k JOIN cb_app_user u ON u.user_id = k.user_id + WHERE #{includeInactive} = 1 + OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP) ORDER BY k.key_id DESC diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml index 4afad79..2b74e3b 100644 --- a/src/main/resources/mapper/PermissionMapper.xml +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -90,6 +90,9 @@ WHEN pr2.rule_type = 'SELF' THEN NVL(pr2.rule_column, 'OWNER_EMP_NO') || ' = SYS_CONTEXT(CB_AGENT_CTX.EMP_NO)' WHEN pr2.rule_type = 'DEPT' THEN NVL(pr2.rule_column, 'DEPT_CODE') || ' = ' || pr2.rule_value WHEN pr2.rule_type = 'EMP_NO' THEN NVL(pr2.rule_column, 'OWNER_EMP_NO') || ' = ' || pr2.rule_value + WHEN pr2.rule_type = 'TAG' THEN 'REGEXP_LIKE(UPPER(' + || NVL(pr2.rule_column, 'TECH_TAG') + || '), ''(^|,)' || REPLACE(UPPER(pr2.rule_value), '''', '''''') || '(,|$)'')' ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value END, CHR(10) || 'AND ' diff --git a/src/main/resources/mapper/ProtectedObjectMapper.xml b/src/main/resources/mapper/ProtectedObjectMapper.xml index 7c59cfe..8bd08f0 100644 --- a/src/main/resources/mapper/ProtectedObjectMapper.xml +++ b/src/main/resources/mapper/ProtectedObjectMapper.xml @@ -3,14 +3,14 @@ "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> - INSERT INTO cb_protected_object (object_id, owner, object_name, ords_path, enabled_yn) + INSERT INTO cb_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description) VALUES ( #{objectId}, UPPER(#{command.owner}), UPPER(#{command.objectName}), #{command.ordsPath}, - 'Y' + 'Y', + #{command.description,jdbcType=VARCHAR} ) @@ -115,6 +116,12 @@ WHERE object_id = #{objectId,jdbcType=NUMERIC} + + UPDATE cb_protected_object + SET description = #{description,jdbcType=VARCHAR} + WHERE object_id = #{objectId,jdbcType=NUMERIC} + + UPDATE cb_protected_object SET enabled_yn = 'Y' diff --git a/src/main/resources/mapper/VpdPolicyMapper.xml b/src/main/resources/mapper/VpdPolicyMapper.xml index 9d549e8..e4e5726 100644 --- a/src/main/resources/mapper/VpdPolicyMapper.xml +++ b/src/main/resources/mapper/VpdPolicyMapper.xml @@ -62,6 +62,7 @@ o.object_type, CASE WHEN po.object_id IS NULL THEN 'N' ELSE po.enabled_yn END AS protected_yn, po.ords_path, + po.description, COUNT(p.policy_name) AS policy_count, LISTAGG(p.policy_name, ', ') WITHIN GROUP (ORDER BY p.policy_name) AS policy_names, LISTAGG( @@ -78,7 +79,7 @@ LEFT JOIN all_policies p ON p.object_owner = o.owner AND p.object_name = o.object_name - GROUP BY o.owner, o.object_name, o.object_type, po.object_id, po.enabled_yn, po.ords_path + GROUP BY o.owner, o.object_name, o.object_type, po.object_id, po.enabled_yn, po.ords_path, po.description ORDER BY CASE WHEN COUNT(p.policy_name) > 0 THEN 0 ELSE 1 END, o.owner, o.object_type, @@ -278,4 +279,50 @@ AND name = UPPER(#{objectName,jdbcType=VARCHAR}) AND type = UPPER(#{objectType,jdbcType=VARCHAR}) + + + + + + + MERGE INTO cb_vpd_policy_note dst + USING ( + SELECT UPPER(#{objectOwner,jdbcType=VARCHAR}) object_owner, + UPPER(#{objectName,jdbcType=VARCHAR}) object_name, + UPPER(#{policyName,jdbcType=VARCHAR}) policy_name, + #{description,jdbcType=VARCHAR} description + FROM dual + ) src + ON (dst.object_owner = src.object_owner + AND dst.object_name = src.object_name + AND dst.policy_name = src.policy_name) + WHEN MATCHED THEN UPDATE SET dst.description = src.description, dst.updated_at = SYSTIMESTAMP + WHEN NOT MATCHED THEN INSERT (object_owner, object_name, policy_name, description, updated_at) + VALUES (src.object_owner, src.object_name, src.policy_name, src.description, SYSTIMESTAMP) + + + + MERGE INTO cb_vpd_filter_note dst + USING ( + SELECT UPPER(#{functionOwner,jdbcType=VARCHAR}) function_owner, + UPPER(#{functionName,jdbcType=VARCHAR}) function_name, + #{description,jdbcType=VARCHAR} description + FROM dual + ) src + ON (dst.function_owner = src.function_owner AND dst.function_name = src.function_name) + WHEN MATCHED THEN UPDATE SET dst.description = src.description, dst.updated_at = SYSTIMESTAMP + WHEN NOT MATCHED THEN INSERT (function_owner, function_name, description, updated_at) + VALUES (src.function_owner, src.function_name, src.description, SYSTIMESTAMP) + diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 7ccde8b..8d80952 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -1802,6 +1802,36 @@ body { min-width: 520px; } +.column-policy-explainer { + background: var(--rw-surface-muted); + border: 1px solid var(--rw-border); + border-radius: 8px; + margin: .85rem 0; + padding: .85rem; +} + +.column-policy-explainer p { + color: var(--rw-muted); + line-height: 1.55; + margin: .35rem 0 0; +} + +.object-handler-explainer { + display: grid; + gap: .25rem; + margin-bottom: .75rem; +} + +.object-handler-explainer small { + color: var(--rw-muted); +} + +.selected-column-list { + display: block; + margin-top: .45rem; + min-height: 1.5rem; +} + @media (max-width: 920px) { .journey-grid, .macro-micro-grid, diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index ffaa3c2..8079afe 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -357,6 +357,28 @@ function renderMaskableColumnOptions(option) { 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); }); }); @@ -372,14 +394,15 @@ function syncRuleTypeHints(root = document) { } const type = typeSelect.value; - const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO'].includes(type); - const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO'].includes(type); + const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG'].includes(type); + const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG'].includes(type); const placeholderByType = { ALL: '값 불필요', MY_DEPT: '값 불필요', SELF: '값 불필요', DEPT: '예: HR', EMP_NO: '예: E2001', + TAG: '예: SPRING_BOOT', '=': '비교 값', '!=': '비교 값' }; @@ -441,7 +464,59 @@ async function syncObjectCatalogSelection() { const owner = (selected.dataset.owner || '').toLowerCase(); const objectName = (selected.dataset.objectName || '').toLowerCase(); ordsPathInput.value = owner && objectName ? `cb-ords/cb-object-query/${owner}/${objectName}` : ''; + ordsPathInput.readOnly = true; } + const ordsPathEdit = document.querySelector('[data-ords-path-edit]'); + if (ordsPathEdit) { + ordsPathEdit.checked = false; + } +} + +function initOrdsPathEditToggle() { + const toggle = document.querySelector('[data-ords-path-edit]'); + const input = document.querySelector('[data-ords-path-input]'); + if (!toggle || !input) { + return; + } + toggle.addEventListener('change', () => { + input.readOnly = !toggle.checked; + if (toggle.checked) { + input.focus(); + } + }); +} + +function initProbeObjectDescription() { + const select = document.querySelector('select[name="objectId"]'); + const target = document.querySelector('[data-probe-object-description]'); + if (!select || !target) { + return; + } + const update = () => { + const option = selectedOption(select); + target.textContent = option?.dataset.description + || '선택한 대상의 설명이 등록되지 않았습니다. ORDS 조회 대상에서 설명을 추가하세요.'; + }; + select.addEventListener('change', update); + update(); +} + +function initProbeVectorInput() { + const select = document.querySelector('select[name="objectId"]'); + const block = document.querySelector('[data-vector-probe-input]'); + const input = block?.querySelector('textarea[name="requestBody"]'); + if (!select || !block || !input) { + return; + } + const update = () => { + const option = selectedOption(select); + const isVectorSearch = option?.dataset.vectorSearch === 'true'; + block.hidden = !isVectorSearch; + input.disabled = !isVectorSearch; + input.required = isVectorSearch; + }; + select.addEventListener('change', update); + update(); } function selectedText(select) { @@ -570,6 +645,7 @@ function collectWizardRules(root) { SELF: 'OWNER_EMP_NO', DEPT: 'DEPT_CODE', EMP_NO: 'OWNER_EMP_NO', + TAG: 'TECH_TAG', ALL: '' }[type] || ''; if (type === 'ALL') { @@ -578,6 +654,9 @@ function collectWizardRules(root) { if (['MY_DEPT', 'SELF'].includes(type)) { return `${displayColumn} ${type}`; } + if (type === 'TAG') { + return `${displayColumn} TAG ${value.toUpperCase()}`; + } return [displayColumn, type, value].filter(Boolean).join(' '); }).filter(Boolean); } @@ -592,6 +671,7 @@ function collectWizardPredicates(root) { SELF: 'OWNER_EMP_NO', DEPT: 'DEPT_CODE', EMP_NO: 'OWNER_EMP_NO', + TAG: 'TECH_TAG', ALL: '' }[type] || ''; if (type === 'ALL') { @@ -606,6 +686,9 @@ function collectWizardPredicates(root) { if (type === 'DEPT' || type === 'EMP_NO') { return `${displayColumn} = ${sqlLiteral(value)}`; } + if (type === 'TAG') { + return `REGEXP_LIKE(UPPER(${displayColumn}), ${sqlLiteral(`(^|,)${value.toUpperCase()}(,|$)`)})`; + } if (type === '=') { return `TO_CHAR(${displayColumn}) = ${sqlLiteral(value)}`; } @@ -880,10 +963,16 @@ document.addEventListener('DOMContentLoaded', () => { }); }); syncRuleTypeHints(); + document.querySelector('input[name="visibleColumns"]')?.addEventListener('input', renderSelectedVisibleColumns); + renderSelectedVisibleColumns(); const catalog = document.getElementById('objectCatalogSelect'); if (catalog) { catalog.addEventListener('change', syncObjectCatalogSelection); + syncObjectCatalogSelection(); } + initOrdsPathEditToggle(); + initProbeObjectDescription(); + initProbeVectorInput(); document.querySelectorAll('[data-policy-template-select]').forEach((select) => { select.addEventListener('change', () => syncPolicyTemplate(select)); syncPolicyTemplate(select); diff --git a/src/main/resources/templates/fragments/mcp-reasoning-result.html b/src/main/resources/templates/fragments/mcp-reasoning-result.html index 7291d85..29cee7e 100644 --- a/src/main/resources/templates/fragments/mcp-reasoning-result.html +++ b/src/main/resources/templates/fragments/mcp-reasoning-result.html @@ -2,6 +2,8 @@
+
Reasoning 실행에 실패했습니다.
+

Reasoning 결과

+
diff --git a/src/main/resources/templates/fragments/ords-handler-source.html b/src/main/resources/templates/fragments/ords-handler-source.html index 2cad533..e12fe26 100644 --- a/src/main/resources/templates/fragments/ords-handler-source.html +++ b/src/main/resources/templates/fragments/ords-handler-source.html @@ -8,6 +8,7 @@ ADMIN.TABLE_NAME / 기본 ORDS Handler PL/SQL +

이 소스는 VPD context를 설정한 뒤 한 테이블을 SELECT하는 시작 예제입니다. 실제 수정·저장은 ORDS 핸들러 화면에서 parsing schema 권한으로 진행하세요.

source
diff --git a/src/main/resources/templates/mcp-chatbot.html b/src/main/resources/templates/mcp-chatbot.html index b811b70..dfc59ae 100644 --- a/src/main/resources/templates/mcp-chatbot.html +++ b/src/main/resources/templates/mcp-chatbot.html @@ -4,15 +4,20 @@
-
-

MCP Chatbot

-

질문을 MCP tool로 라우팅하고 ORDS/VPD 조회 결과를 답변으로 정리합니다.

+
+

MCP Chatbot

+

질문을 MCP tool로 라우팅하고, 권한 태그·VPD 행 제한·ORDS 결과를 답변으로 정리합니다.

+
+ +
+ DB 연결 설정이 필요합니다. + message
질문 실행 -

아래 예시를 누르거나 직접 질문을 입력하세요. Bearer Token을 입력하면 실제 ORDS/VPD 결과까지 조회합니다.

+

아래 예시를 누르거나 직접 질문을 입력하세요. Bearer Token 원문을 붙여 넣거나 사용자를 선택해 10분 임시 토큰으로 실제 ORDS/VPD 결과를 조회할 수 있습니다.

@@ -33,6 +38,14 @@ + +
diff --git a/src/main/resources/templates/roles.html b/src/main/resources/templates/roles.html index 6d99510..45735c0 100644 --- a/src/main/resources/templates/roles.html +++ b/src/main/resources/templates/roles.html @@ -6,7 +6,7 @@

역할 관리

-

사용자에게 부여할 역할을 관리합니다.

+

사용자에게 부여할 역할을 관리합니다. 아래 등급은 허용된 행의 컬럼 표시 보호 상한을 기록하는 참고값이며, 행 접근 권한은 권한 규칙과 VPD에서 결정합니다.

@@ -25,13 +25,14 @@ @@ -45,7 +46,7 @@ ID 역할명 - 민감도 허용 상한 + 표시 보호 등급 상한 삭제 영향 @@ -57,6 +58,8 @@ 10 HR_DEPT_ROLE + PUBLIC
@@ -68,6 +71,7 @@
+ 행 접근 권한과 별개인 표시 보호 상한
diff --git a/src/main/resources/templates/tokens.html b/src/main/resources/templates/tokens.html index 63dd959..65226d7 100644 --- a/src/main/resources/templates/tokens.html +++ b/src/main/resources/templates/tokens.html @@ -54,9 +54,19 @@

발급 이력

-

보안상 prefix와 상태만 보관합니다. 이 목록에서는 원문을 복사하거나 복구할 수 없습니다.

+

보안상 prefix와 상태만 보관합니다. 기본 목록은 현재 사용 가능한 토큰만 보여주며, 회수·만료 이력은 필터를 켜서 확인합니다.

+
+
+ 0 +
+ + +
- 0
diff --git a/src/main/resources/templates/vpd-filter-policies.html b/src/main/resources/templates/vpd-filter-policies.html index 9526d22..1b96a82 100644 --- a/src/main/resources/templates/vpd-filter-policies.html +++ b/src/main/resources/templates/vpd-filter-policies.html @@ -40,6 +40,9 @@ CB_AGENT_DOC_VPD_FILTER이 화면과 서버 양쪽에서 직접 덮어쓰기를 차단합니다. +
+ Filter 설명: 이 화면의 function은 DB가 행을 걸러낼 predicate를 반환합니다. 기본 함수는 사용자·그룹·역할·TAG 권한을 동적으로 읽고, 별도 Filter는 객체 고유 조건을 추가할 때만 사용합니다. 저장하는 설명에는 대상, 조건, 예상되는 허용/차단 결과를 적습니다. +
@@ -72,11 +75,16 @@ +
- +
@@ -87,7 +95,7 @@
- + @@ -96,6 +104,9 @@ 권한체계 기본별도 Filter + - - +
Function구분Source / 변경
Function구분설명Source / 변경
+ Filter 목적 +
+
Source 보기를 누르면 현재 함수 내용을 표시합니다.
이 함수는 권한체계의 핵심 실행 경로이므로 직접 수정할 수 없습니다. 권한 규칙을 변경하세요. @@ -115,14 +126,20 @@ +
설치된 VPD 함수가 없습니다.
설치된 VPD 함수가 없습니다.
diff --git a/src/main/resources/templates/vpd-policies.html b/src/main/resources/templates/vpd-policies.html index 562b6d5..656037a 100644 --- a/src/main/resources/templates/vpd-policies.html +++ b/src/main/resources/templates/vpd-policies.html @@ -36,6 +36,9 @@ 허용된 데이터만 반환

CB_AGENT_DOC_VPD_FILTER가 요청할 때마다 현재 권한체계를 읽습니다. 일상적인 권한 변경은 이 함수나 predicate가 아니라 권한 규칙에서 하세요.

+
+ 이 화면의 역할: policy는 “어느 객체의 어떤 SQL에 어떤 Filter function을 붙일지”를 정합니다. 행의 실제 허용/차단 조건은 권한 규칙과 Filter function이 계산하고, 컬럼 표시 보호는 별도 설정입니다. +
@@ -113,6 +116,7 @@ Object + 대상 설명 Type VPD 상태 Policy @@ -130,6 +134,7 @@ data-vpd-applied=${target.vpdApplied()}, data-protected-object=${target.protectedObject()}"> ADMIN.TABLE + 조회 대상 설명 TABLE - +
Filter 이름을 클릭하면 policy/filter source가 표시됩니다.
@@ -174,7 +179,7 @@ - + 선택한 스키마에서 조회 가능한 TABLE/VIEW가 없습니다. @@ -270,6 +275,7 @@ Status Type Options + 이 policy가 하는 일 Explain @@ -298,6 +304,9 @@ OWNER.FUNC
클릭하면 filter source를 봅니다.
+
+ Filter가 반환하는 predicate 설명 +
권한체계 자동 별도 Filter @@ -313,6 +322,18 @@ Static: NO Long: NO + +
+ + + + + + +
+