fix #557: guide permission-driven VPD flow
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **상태**: Approved
|
> **상태**: Approved
|
||||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-29
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-29
|
||||||
> **추적성** — Redmine: #557 · 관련 ADR: 없음
|
> **추적성** — Redmine: #557 · 후속 UX: #558~#565 · 관련 ADR: 없음
|
||||||
> · 구현 파일: `ProbeController`, `ProbeResult`, `VpdPolicyService`, 대시보드/VPD/토큰/검증 템플릿
|
> · 구현 파일: `ProbeController`, `ProbeResult`, `VpdPolicyService`, 대시보드/VPD/토큰/검증 템플릿
|
||||||
> · 테스트: `ProbeResultTest`, `VpdPolicyServiceTest`, `GuidedFlowTemplateTest`
|
> · 테스트: `ProbeResultTest`, `VpdPolicyServiceTest`, `GuidedFlowTemplateTest`
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
## 2. 범위 (Scope)
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
- **포함**: Macro→Micro 설명 구조, 내비게이션 재분류, 동적 권한 VPD 기본 적용, custom filter 고급 분리, 토큰 검증 단순화, 상태별 일반 문장과 다음 행동, 발급→검증 연결.
|
- **포함**: Macro→Micro 설명 구조, 내비게이션 재분류, 동적 권한 VPD 기본 적용, custom filter 고급 분리, 토큰 검증 단순화, 원문/임시 토큰 검증, 상태별 일반 문장과 다음 행동, 발급→검증 연결.
|
||||||
- **제외**: 권한 데이터 모델 변경, VPD 함수 SQL 알고리즘 변경, 토큰 원문 저장, 운영 DB DDL 자동 실행, ORDS handler 생성 방식 변경.
|
- **제외**: 권한 데이터 모델 변경, VPD 함수 SQL 알고리즘 변경, 토큰 원문 저장, 운영 DB DDL 자동 실행, ORDS handler 생성 방식 변경.
|
||||||
|
|
||||||
## 3. 인수조건 (Acceptance Criteria)
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
- [x] 기본 VPD 적용은 객체만 선택하고 권한체계 동적 함수와 SELECT 정책을 서버가 결정한다.
|
- [x] 기본 VPD 적용은 객체만 선택하고 권한체계 동적 함수와 SELECT 정책을 서버가 결정한다.
|
||||||
- [x] `CB_AGENT_DOC_VPD_FILTER`는 일반 UI에서 수정할 수 없고 직접 POST도 거부된다.
|
- [x] `CB_AGENT_DOC_VPD_FILTER`는 일반 UI에서 수정할 수 없고 직접 POST도 거부된다.
|
||||||
- [x] 별도 filter/predicate와 policy 교체는 사용 기준·안전 규칙이 있는 고급 영역에만 노출된다.
|
- [x] 별도 filter/predicate와 policy 교체는 사용 기준·안전 규칙이 있는 고급 영역에만 노출된다.
|
||||||
- [x] 검증 화면은 등록 토큰 선택 없이 Bearer 원문과 대상만 입력한다.
|
- [x] 검증 화면은 등록 토큰 선택 없이 Bearer 원문 또는 테스트 중에만 존재하는 임시 토큰과 대상을 입력한다.
|
||||||
- [x] 검증 결과가 상태, 적용 주체, 행 결과, 다음 행동 순으로 설명되고 HTTP 원문은 접혀 있다.
|
- [x] 검증 결과가 상태, 적용 주체, 행 결과, 다음 행동 순으로 설명되고 HTTP 원문은 접혀 있다.
|
||||||
- [x] 존재하지 않는 테스트 토큰은 “DB에 등록된 토큰이 아님”과 재발급 절차를 안내한다.
|
- [x] 존재하지 않는 테스트 토큰은 “DB에 등록된 토큰이 아님”과 재발급 절차를 안내한다.
|
||||||
- [x] 발급 직후 토큰 복사와 검증 화면 이동이 명확하다.
|
- [x] 발급 직후 토큰 복사와 검증 화면 이동이 명확하다.
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
[Evidence: 실제로 지켜졌는가]
|
[Evidence: 실제로 지켜졌는가]
|
||||||
1회 표시 토큰 → ORDS 호출 → 사용자/역할 + 보이는 행 + 다음 행동
|
1회 표시/임시 토큰 → ORDS 호출 → 사용자/역할 + 보이는 행 + 다음 행동
|
||||||
```
|
```
|
||||||
|
|
||||||
- I/O 경계: controller/service는 DB catalog와 ORDS를 호출한다.
|
- I/O 경계: controller/service는 DB catalog와 ORDS를 호출한다.
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
- 입력: `objectKey`, `bearerToken`, `objectId`, `limit`.
|
- 입력: `objectKey`, `bearerToken`, `objectId`, `limit`.
|
||||||
- 기본 VPD 명령: policy `CB_PERMISSION_SELECT_POLICY`, function `*.CB_AGENT_DOC_VPD_FILTER`, statements `SELECT`, enabled `true`, update check `false`.
|
- 기본 VPD 명령: policy `CB_PERMISSION_SELECT_POLICY`, function `*.CB_AGENT_DOC_VPD_FILTER`, statements `SELECT`, enabled `true`, update check `false`.
|
||||||
- 검증 표현: 기존 `ProbeResult`에 상태별 `title`, `plainSummary`, `nextAction`, `successLike` 계산 메서드를 둔다.
|
- 검증 표현: 기존 `ProbeResult`에 상태별 `title`, `plainSummary`, `nextAction`, `successLike` 계산 메서드를 둔다.
|
||||||
- 검증 컨텍스트: hash로 찾은 `TokenContextView`와 `ProtectedObject`를 controller model에 추가한다. 원문은 model/result/log에 저장하지 않는다.
|
- 검증 컨텍스트: hash로 찾은 `TokenContextView`와 `ProtectedObject`를 controller model에 추가한다. 임시 토큰은 실행 후 회수한다. 원문은 model/result/log에 저장하지 않는다.
|
||||||
|
|
||||||
## 7. 함수 명세 (Function Specs)
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
|||||||
172
docs/design/558-vector-tag-vpd-search/README.md
Normal file
172
docs/design/558-vector-tag-vpd-search/README.md
Normal file
@@ -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 변경은 별도 승인을 받아 실행한다.
|
||||||
@@ -372,6 +372,17 @@ BEGIN
|
|||||||
IF v_column IS NOT NULL THEN
|
IF v_column IS NOT NULL THEN
|
||||||
append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value));
|
append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value));
|
||||||
END IF;
|
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
|
ELSIF r.rule_type = '=' THEN
|
||||||
v_column := safe_column(p_schema, p_object, r.rule_column);
|
v_column := safe_column(p_schema, p_object, r.rule_column);
|
||||||
IF v_column IS NOT NULL THEN
|
IF v_column IS NOT NULL THEN
|
||||||
|
|||||||
@@ -125,9 +125,20 @@ CREATE TABLE cb_protected_object (
|
|||||||
owner VARCHAR2(128) NOT NULL,
|
owner VARCHAR2(128) NOT NULL,
|
||||||
object_name VARCHAR2(128) NOT NULL UNIQUE,
|
object_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||||
ords_path VARCHAR2(300) NOT NULL,
|
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 (
|
CREATE TABLE cb_protected_column (
|
||||||
column_id NUMBER PRIMARY KEY,
|
column_id NUMBER PRIMARY KEY,
|
||||||
object_id NUMBER NOT NULL REFERENCES cb_protected_object(object_id),
|
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
|
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 ===
|
PROMPT === Creating backoffice settings ===
|
||||||
BEGIN
|
BEGIN
|
||||||
EXECUTE IMMEDIATE '
|
EXECUTE IMMEDIATE '
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
-- CB_AGENT_CTX.USER_ID -> direct CB_USER_ROLE + group CB_USER_GROUP/CB_GROUP_ROLE
|
-- CB_AGENT_CTX.USER_ID -> direct CB_USER_ROLE + group CB_USER_GROUP/CB_GROUP_ROLE
|
||||||
-- -> CB_PERMISSION -> CB_PERMISSION_RULE
|
-- -> CB_PERMISSION -> CB_PERMISSION_RULE
|
||||||
-- and builds a row predicate for the object passed by DBMS_RLS.
|
-- 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
|
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||||
SET ECHO OFF
|
SET ECHO OFF
|
||||||
@@ -152,6 +154,21 @@ BEGIN
|
|||||||
IF v_column IS NOT NULL THEN
|
IF v_column IS NOT NULL THEN
|
||||||
append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value));
|
append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value));
|
||||||
END IF;
|
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
|
ELSIF r.rule_type = '=' THEN
|
||||||
v_column := safe_column(p_schema, p_object, r.rule_column);
|
v_column := safe_column(p_schema, p_object, r.rule_column);
|
||||||
IF v_column IS NOT NULL THEN
|
IF v_column IS NOT NULL THEN
|
||||||
|
|||||||
257
sql/adb/28_agent_ords_vector_tag_vpd_setup.sql
Normal file
257
sql/adb/28_agent_ords_vector_tag_vpd_setup.sql
Normal file
@@ -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;
|
||||||
153
sql/adb/29_agent_ords_vector_search_ords.sql
Normal file
153
sql/adb/29_agent_ords_vector_search_ords.sql
Normal file
@@ -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 <key>
|
||||||
|
-- {"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;
|
||||||
130
sql/adb/30_agent_ords_vector_tag_vpd_test.sql
Normal file
130
sql/adb/30_agent_ords_vector_tag_vpd_test.sql
Normal file
@@ -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;
|
||||||
58
sql/adb/31_agent_ords_backoffice_ux_metadata.sql
Normal file
58
sql/adb/31_agent_ords_backoffice_ux_metadata.sql
Normal file
@@ -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;
|
||||||
@@ -2,7 +2,6 @@ package com.cloudhandson.vpdbackoffice.domain.mcp;
|
|||||||
|
|
||||||
public record McpReasoningCommand(
|
public record McpReasoningCommand(
|
||||||
Long tokenKeyId,
|
Long tokenKeyId,
|
||||||
long objectId,
|
|
||||||
String bearerToken,
|
String bearerToken,
|
||||||
int limit,
|
int limit,
|
||||||
String question
|
String question
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ public record ProbeCommand(
|
|||||||
Long tokenKeyId,
|
Long tokenKeyId,
|
||||||
@Positive long objectId,
|
@Positive long objectId,
|
||||||
@NotBlank String bearerToken,
|
@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.domain.protectedobject;
|
package com.cloudhandson.vpdbackoffice.domain.protectedobject;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
public record ProtectedColumn(
|
public record ProtectedColumn(
|
||||||
long columnId,
|
long columnId,
|
||||||
long objectId,
|
long objectId,
|
||||||
@@ -20,4 +22,28 @@ public record ProtectedColumn(
|
|||||||
String method = redactionMethod == null || redactionMethod.isBlank() ? "NONE" : redactionMethod;
|
String method = redactionMethod == null || redactionMethod.isBlank() ? "NONE" : redactionMethod;
|
||||||
return level + "/" + method;
|
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() : "기본 표시 · 마스킹 없음";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ public record ProtectedObject(
|
|||||||
String owner,
|
String owner,
|
||||||
String objectName,
|
String objectName,
|
||||||
String ordsPath,
|
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() {
|
public boolean enabled() {
|
||||||
return "Y".equalsIgnoreCase(enabledYn);
|
return "Y".equalsIgnoreCase(enabledYn);
|
||||||
}
|
}
|
||||||
@@ -15,4 +20,10 @@ public record ProtectedObject(
|
|||||||
public String displayName() {
|
public String displayName() {
|
||||||
return owner + "." + objectName;
|
return owner + "." + objectName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String descriptionOrDefault() {
|
||||||
|
return description == null || description.isBlank()
|
||||||
|
? "등록된 DB 객체를 권한체계와 ORDS로 조회하는 대상"
|
||||||
|
: description;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ public record ProtectedObjectCreateCommand(
|
|||||||
@NotBlank String objectName,
|
@NotBlank String objectName,
|
||||||
@NotBlank String ordsPath,
|
@NotBlank String ordsPath,
|
||||||
String columns,
|
String columns,
|
||||||
|
String sensitiveColumns,
|
||||||
|
String description
|
||||||
|
) {
|
||||||
|
|
||||||
|
public ProtectedObjectCreateCommand(
|
||||||
|
String owner,
|
||||||
|
String objectName,
|
||||||
|
String ordsPath,
|
||||||
|
String columns,
|
||||||
String sensitiveColumns
|
String sensitiveColumns
|
||||||
) {
|
) {
|
||||||
|
this(owner, objectName, ordsPath, columns, sensitiveColumns, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public record VpdTargetView(
|
|||||||
String objectType,
|
String objectType,
|
||||||
String protectedYn,
|
String protectedYn,
|
||||||
String ordsPath,
|
String ordsPath,
|
||||||
|
String description,
|
||||||
int policyCount,
|
int policyCount,
|
||||||
String policyNames,
|
String policyNames,
|
||||||
String filterNames
|
String filterNames
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import org.apache.ibatis.annotations.Param;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface BearerTokenMapper {
|
public interface BearerTokenMapper {
|
||||||
|
|
||||||
List<BearerTokenRecord> findAll();
|
List<BearerTokenRecord> findAll(@Param("includeInactive") int includeInactive);
|
||||||
|
|
||||||
BearerTokenRecord findById(@Param("keyId") long keyId);
|
BearerTokenRecord findById(@Param("keyId") long keyId);
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ public interface ProtectedObjectMapper {
|
|||||||
|
|
||||||
int updateOrdsPath(@Param("objectId") long objectId, @Param("ordsPath") String ordsPath);
|
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 enableObject(@Param("objectId") long objectId);
|
||||||
|
|
||||||
int disableObject(@Param("objectId") long objectId);
|
int disableObject(@Param("objectId") long objectId);
|
||||||
|
|||||||
@@ -54,4 +54,28 @@ public interface VpdPolicyMapper {
|
|||||||
@Param("objectName") String objectName,
|
@Param("objectName") String objectName,
|
||||||
@Param("objectType") String objectType
|
@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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ public class BackofficeSchemaService {
|
|||||||
owner VARCHAR2(128) NOT NULL,
|
owner VARCHAR2(128) NOT NULL,
|
||||||
object_name VARCHAR2(128) NOT NULL UNIQUE,
|
object_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||||
ords_path VARCHAR2(300) NOT NULL,
|
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", """
|
new TableDefinition("CB_PROTECTED_COLUMN", """
|
||||||
@@ -148,6 +149,25 @@ public class BackofficeSchemaService {
|
|||||||
setting_value VARCHAR2(1000),
|
setting_value VARCHAR2(1000),
|
||||||
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
|
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))"),
|
"ALTER TABLE cb_permission_rule ADD (rule_column VARCHAR2(128))"),
|
||||||
new ColumnDefinition("CB_AGENT_BEARER_KEY", "DESCRIPTION",
|
new ColumnDefinition("CB_AGENT_BEARER_KEY", "DESCRIPTION",
|
||||||
"ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))"),
|
"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",
|
new ColumnDefinition("CB_PROTECTED_COLUMN", "SENSITIVITY_LEVEL",
|
||||||
"ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)"),
|
"ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)"),
|
||||||
new ColumnDefinition("CB_PROTECTED_COLUMN", "REDACTION_METHOD",
|
new ColumnDefinition("CB_PROTECTED_COLUMN", "REDACTION_METHOD",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper;
|
|||||||
import com.cloudhandson.vpdbackoffice.mapper.GroupMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.GroupMapper;
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
@@ -59,11 +60,15 @@ public class BearerTokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<BearerTokenRecord> findAll() {
|
public List<BearerTokenRecord> findAll() {
|
||||||
return tokenMapper.findAll();
|
return findAll(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BearerTokenRecord> findAll(boolean includeInactive) {
|
||||||
|
return tokenMapper.findAll(includeInactive ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<TokenContextView> findTokenContextOptions() {
|
public List<TokenContextView> findTokenContextOptions() {
|
||||||
List<BearerTokenRecord> tokens = tokenMapper.findAll();
|
List<BearerTokenRecord> tokens = findAll(true);
|
||||||
List<GroupUserView> groupUsers = groupMapper.findGroupUsers();
|
List<GroupUserView> groupUsers = groupMapper.findGroupUsers();
|
||||||
Map<Long, List<String>> directRolesByUser = directRolesByUser();
|
Map<Long, List<String>> directRolesByUser = directRolesByUser();
|
||||||
Map<Long, List<String>> groupsByUser = groupsByUser(groupUsers);
|
Map<Long, List<String>> groupsByUser = groupsByUser(groupUsers);
|
||||||
@@ -196,6 +201,13 @@ public class BearerTokenService {
|
|||||||
return new IssuedToken(keyId, prefix, plainToken, command.expiresAt());
|
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
|
@Transactional
|
||||||
public void revokeToken(long keyId, String reason) {
|
public void revokeToken(long keyId, String reason) {
|
||||||
int updated = tokenMapper.revokeToken(
|
int updated = tokenMapper.revokeToken(
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
|
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
|
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
|
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.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -17,20 +18,17 @@ public class McpReasoningService {
|
|||||||
|
|
||||||
private static final int MAX_EVIDENCE_ROWS = 20;
|
private static final int MAX_EVIDENCE_ROWS = 20;
|
||||||
|
|
||||||
private final ProtectedObjectService protectedObjectService;
|
|
||||||
private final OrdsProbeService ordsProbeService;
|
private final OrdsProbeService ordsProbeService;
|
||||||
private final McpToolRegistry toolRegistry;
|
private final McpToolRegistry toolRegistry;
|
||||||
private final OpenAiCompatibleClient aiClient;
|
private final OpenAiCompatibleClient aiClient;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public McpReasoningService(
|
public McpReasoningService(
|
||||||
ProtectedObjectService protectedObjectService,
|
|
||||||
OrdsProbeService ordsProbeService,
|
OrdsProbeService ordsProbeService,
|
||||||
McpToolRegistry toolRegistry,
|
McpToolRegistry toolRegistry,
|
||||||
OpenAiCompatibleClient aiClient,
|
OpenAiCompatibleClient aiClient,
|
||||||
ObjectMapper objectMapper
|
ObjectMapper objectMapper
|
||||||
) {
|
) {
|
||||||
this.protectedObjectService = protectedObjectService;
|
|
||||||
this.ordsProbeService = ordsProbeService;
|
this.ordsProbeService = ordsProbeService;
|
||||||
this.toolRegistry = toolRegistry;
|
this.toolRegistry = toolRegistry;
|
||||||
this.aiClient = aiClient;
|
this.aiClient = aiClient;
|
||||||
@@ -38,11 +36,10 @@ public class McpReasoningService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public McpReasoningResult reason(McpReasoningCommand command) {
|
public McpReasoningResult reason(McpReasoningCommand command) {
|
||||||
ProtectedObject object = protectedObjectService.assertEnabled(command.objectId());
|
McpToolView tool = selectTool(command.question());
|
||||||
McpToolView tool = toolRegistry.toolFor(object);
|
|
||||||
ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(
|
ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(
|
||||||
command.tokenKeyId(),
|
command.tokenKeyId(),
|
||||||
command.objectId(),
|
tool.objectId(),
|
||||||
command.bearerToken(),
|
command.bearerToken(),
|
||||||
normalizeLimit(command.limit())
|
normalizeLimit(command.limit())
|
||||||
));
|
));
|
||||||
@@ -75,6 +72,28 @@ public class McpReasoningService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private McpToolView selectTool(String question) {
|
||||||
|
List<McpToolView> 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) {
|
private int normalizeLimit(int limit) {
|
||||||
if (limit < 1) {
|
if (limit < 1) {
|
||||||
return 50;
|
return 50;
|
||||||
|
|||||||
@@ -73,14 +73,14 @@ public class McpSseService {
|
|||||||
ObjectNode item = objectMapper.createObjectNode();
|
ObjectNode item = objectMapper.createObjectNode();
|
||||||
item.put("name", tool.name());
|
item.put("name", tool.name());
|
||||||
item.put("description", tool.description());
|
item.put("description", tool.description());
|
||||||
item.set("inputSchema", inputSchema());
|
item.set("inputSchema", inputSchema(tool));
|
||||||
tools.add(item);
|
tools.add(item);
|
||||||
}
|
}
|
||||||
result.set("tools", tools);
|
result.set("tools", tools);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode inputSchema() {
|
private ObjectNode inputSchema(McpToolView tool) {
|
||||||
ObjectNode schema = objectMapper.createObjectNode();
|
ObjectNode schema = objectMapper.createObjectNode();
|
||||||
schema.put("type", "object");
|
schema.put("type", "object");
|
||||||
ObjectNode properties = objectMapper.createObjectNode();
|
ObjectNode properties = objectMapper.createObjectNode();
|
||||||
@@ -100,6 +100,17 @@ public class McpSseService {
|
|||||||
schema.set("properties", properties);
|
schema.set("properties", properties);
|
||||||
ArrayNode required = objectMapper.createArrayNode();
|
ArrayNode required = objectMapper.createArrayNode();
|
||||||
required.add("bearerToken");
|
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.set("required", required);
|
||||||
schema.put("additionalProperties", false);
|
schema.put("additionalProperties", false);
|
||||||
return schema;
|
return schema;
|
||||||
@@ -111,7 +122,18 @@ public class McpSseService {
|
|||||||
McpToolView tool = findTool(toolName);
|
McpToolView tool = findTool(toolName);
|
||||||
String bearerToken = arguments.path("bearerToken").asText("");
|
String bearerToken = arguments.path("bearerToken").asText("");
|
||||||
int limit = normalizeLimit(arguments.path("limit").asInt(50));
|
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();
|
ObjectNode payload = objectMapper.createObjectNode();
|
||||||
payload.put("toolName", tool.name());
|
payload.put("toolName", tool.name());
|
||||||
@@ -149,6 +171,10 @@ public class McpSseService {
|
|||||||
.orElseThrow(() -> new AppException("MCP tool을 찾을 수 없습니다: " + toolName));
|
.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) {
|
private int normalizeLimit(int limit) {
|
||||||
if (limit < 1) {
|
if (limit < 1) {
|
||||||
return 50;
|
return 50;
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ public class McpToolRegistry {
|
|||||||
|
|
||||||
private McpToolView toTool(ProtectedObject object) {
|
private McpToolView toTool(ProtectedObject object) {
|
||||||
String name = "ords.query." + safeName(object.owner()) + "." + safeName(object.objectName());
|
String name = "ords.query." + safeName(object.owner()) + "." + safeName(object.objectName());
|
||||||
|
String vectorHint = "CB_VECTOR_SEARCH_DOCUMENTS".equalsIgnoreCase(object.objectName())
|
||||||
|
? " embedding[]을 입력으로 받는 전용 벡터 검색 Handler입니다."
|
||||||
|
: "";
|
||||||
return new McpToolView(
|
return new McpToolView(
|
||||||
name,
|
name,
|
||||||
object.displayName() + " 보호 객체를 Bearer Token으로 ORDS 호출해 VPD/Redaction 적용 결과를 조회합니다.",
|
object.descriptionOrDefault() + " · "
|
||||||
|
+ object.displayName() + "을 Bearer Token으로 ORDS 호출해 VPD/표시 보호 결과를 조회합니다." + vectorHint,
|
||||||
object.objectId(),
|
object.objectId(),
|
||||||
object.displayName(),
|
object.displayName(),
|
||||||
object.ordsPath()
|
object.ordsPath()
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
public class OrdsMetadataService {
|
public class OrdsMetadataService {
|
||||||
|
|
||||||
|
private static final String VECTOR_SEARCH_OBJECT = "CB_VECTOR_SEARCH_DOCUMENTS";
|
||||||
|
|
||||||
private final OrdsMetadataMapper mapper;
|
private final OrdsMetadataMapper mapper;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final JdbcTemplate ordsMetadataJdbcTemplate;
|
private final JdbcTemplate ordsMetadataJdbcTemplate;
|
||||||
@@ -77,6 +79,7 @@ public class OrdsMetadataService {
|
|||||||
|
|
||||||
public String objectQueryHandlerSource(long objectId) {
|
public String objectQueryHandlerSource(long objectId) {
|
||||||
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
|
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
|
||||||
|
rejectGenericVectorHandler(object);
|
||||||
List<String> columns = protectedObjectService.findColumns(objectId).stream()
|
List<String> columns = protectedObjectService.findColumns(objectId).stream()
|
||||||
.map(ProtectedColumn::columnName)
|
.map(ProtectedColumn::columnName)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -138,6 +141,7 @@ public class OrdsMetadataService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public OrdsObjectHandlerResult createObjectQueryHandler(long objectId) {
|
public OrdsObjectHandlerResult createObjectQueryHandler(long objectId) {
|
||||||
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
|
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
|
||||||
|
rejectGenericVectorHandler(object);
|
||||||
List<String> columns = protectedObjectService.findColumns(objectId).stream()
|
List<String> columns = protectedObjectService.findColumns(objectId).stream()
|
||||||
.map(ProtectedColumn::columnName)
|
.map(ProtectedColumn::columnName)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -263,6 +267,14 @@ public class OrdsMetadataService {
|
|||||||
""".formatted(selectColumns, object.owner(), object.objectName());
|
""".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) {
|
private String handlerSourceTypeCode(String sourceType) {
|
||||||
String normalized = sourceType == null ? "" : sourceType.toLowerCase(Locale.ROOT);
|
String normalized = sourceType == null ? "" : sourceType.toLowerCase(Locale.ROOT);
|
||||||
if (normalized.contains("query")) {
|
if (normalized.contains("query")) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import java.util.Set;
|
|||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.HttpStatusCodeException;
|
import org.springframework.web.client.HttpStatusCodeException;
|
||||||
@@ -114,10 +115,14 @@ public class OrdsProbeService {
|
|||||||
URI uri = buildUri(ordsBaseUrl, object.ordsPath(), command.limit());
|
URI uri = buildUri(ordsBaseUrl, object.ordsPath(), command.limit());
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setBearerAuth(command.bearerToken());
|
headers.setBearerAuth(command.bearerToken());
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
String requestBody = command.requestBody() == null || command.requestBody().isBlank()
|
||||||
|
? "{}"
|
||||||
|
: command.requestBody().trim();
|
||||||
requestHeaders = prettyHeaders(maskedRequestHeaders(headers));
|
requestHeaders = prettyHeaders(maskedRequestHeaders(headers));
|
||||||
requestPayload = prettyJson("{}");
|
requestPayload = prettyJson(requestBody);
|
||||||
ResponseEntity<String> response = ordsRestTemplate.exchange(
|
ResponseEntity<String> response = ordsRestTemplate.exchange(
|
||||||
uri, HttpMethod.POST, new HttpEntity<>(headers), String.class);
|
uri, HttpMethod.POST, new HttpEntity<>(requestBody, headers), String.class);
|
||||||
ProbeResult result = parseSuccess(
|
ProbeResult result = parseSuccess(
|
||||||
response.getBody(),
|
response.getBody(),
|
||||||
object.objectId(),
|
object.objectId(),
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
public class PermissionService {
|
public class PermissionService {
|
||||||
|
|
||||||
private static final Set<String> RULE_TYPES = Set.of("ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO");
|
private static final Set<String> RULE_TYPES = Set.of(
|
||||||
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of("=", "!=", "DEPT", "EMP_NO");
|
"ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG");
|
||||||
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of("MY_DEPT", "SELF", "DEPT", "EMP_NO");
|
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of(
|
||||||
|
"=", "!=", "DEPT", "EMP_NO", "TAG");
|
||||||
|
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of(
|
||||||
|
"MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG");
|
||||||
private static final Set<String> PERMISSION_EFFECTS = Set.of("ALLOW", "DENY");
|
private static final Set<String> PERMISSION_EFFECTS = Set.of("ALLOW", "DENY");
|
||||||
private static final Set<String> SENSITIVITY_LEVELS = Set.of(
|
private static final Set<String> SENSITIVITY_LEVELS = Set.of(
|
||||||
"PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED");
|
"PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED");
|
||||||
@@ -124,12 +127,13 @@ public class PermissionService {
|
|||||||
|
|
||||||
permissionMapper.deleteRules(permissionId);
|
permissionMapper.deleteRules(permissionId);
|
||||||
for (RuleCommand rule : command.rules()) {
|
for (RuleCommand rule : command.rules()) {
|
||||||
|
String type = normalize(rule.ruleType());
|
||||||
permissionMapper.insertRule(new PermissionRule(
|
permissionMapper.insertRule(new PermissionRule(
|
||||||
permissionMapper.nextRuleId(),
|
permissionMapper.nextRuleId(),
|
||||||
permissionId,
|
permissionId,
|
||||||
normalizeNullable(rule.ruleColumn()),
|
normalizeNullable(rule.ruleColumn()),
|
||||||
normalize(rule.ruleType()),
|
type,
|
||||||
clean(rule.ruleValue())
|
normalizeRuleValue(type, rule.ruleValue())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,16 +195,23 @@ public class PermissionService {
|
|||||||
if (column != null && !allowedColumns.contains(column)) {
|
if (column != null && !allowedColumns.contains(column)) {
|
||||||
throw new AppException("행 규칙 컬럼은 보호 객체 컬럼이어야 합니다: " + 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("중복된 행 규칙이 있습니다.");
|
throw new AppException("중복된 행 규칙이 있습니다.");
|
||||||
}
|
}
|
||||||
hasAll = hasAll || "ALL".equals(type);
|
hasAll = hasAll || "ALL".equals(type);
|
||||||
if (!"ALL".equals(type) && column == null && !DEFAULT_COLUMN_RULE_TYPES.contains(type)) {
|
if (!"ALL".equals(type) && column == null && !DEFAULT_COLUMN_RULE_TYPES.contains(type)) {
|
||||||
throw new AppException(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 + " 규칙에는 값이 필요합니다.");
|
throw new AppException(type + " 규칙에는 값이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
if ("TAG".equals(type) && !ruleValue.matches("[A-Z0-9_-]+")) {
|
||||||
|
throw new AppException("TAG 값은 영문 대문자, 숫자, '_' 또는 '-'만 사용할 수 있습니다.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (hasAll && rules.size() > 1) {
|
if (hasAll && rules.size() > 1) {
|
||||||
throw new AppException("ALL 규칙은 다른 규칙과 함께 저장할 수 없습니다.");
|
throw new AppException("ALL 규칙은 다른 규칙과 함께 저장할 수 없습니다.");
|
||||||
@@ -255,6 +266,11 @@ public class PermissionService {
|
|||||||
return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT);
|
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) {
|
private String clean(String value) {
|
||||||
return value == null ? "" : value.trim();
|
return value == null ? "" : value.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ public class ProtectedObjectService {
|
|||||||
normalizedObjectName,
|
normalizedObjectName,
|
||||||
defaultOrdsPath(normalizedOwner, normalizedObjectName),
|
defaultOrdsPath(normalizedOwner, normalizedObjectName),
|
||||||
String.join(",", columns),
|
String.join(",", columns),
|
||||||
""
|
"",
|
||||||
|
"DB 객체를 한 개의 ORDS 조회 Handler로 노출하는 예제 대상"
|
||||||
);
|
);
|
||||||
long objectId = mapper.nextObjectId();
|
long objectId = mapper.nextObjectId();
|
||||||
mapper.insertObject(objectId, command);
|
mapper.insertObject(objectId, command);
|
||||||
@@ -170,13 +171,20 @@ public class ProtectedObjectService {
|
|||||||
String objectName = command.objectName().trim().toUpperCase(Locale.ROOT);
|
String objectName = command.objectName().trim().toUpperCase(Locale.ROOT);
|
||||||
String ordsPath = command.ordsPath();
|
String ordsPath = command.ordsPath();
|
||||||
if (ordsPath == null || ordsPath.isBlank()) {
|
if (ordsPath == null || ordsPath.isBlank()) {
|
||||||
throw new AppException("ORDS Path는 실제 ORDS module/template 경로를 입력해야 합니다.");
|
ordsPath = defaultOrdsPath(owner, objectName);
|
||||||
}
|
}
|
||||||
String columns = command.columns();
|
String columns = command.columns();
|
||||||
if (columns == null || columns.isBlank()) {
|
if (columns == null || columns.isBlank()) {
|
||||||
columns = String.join(",", mapper.findDatabaseColumns(owner, objectName));
|
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
|
@Transactional
|
||||||
@@ -192,6 +200,21 @@ public class ProtectedObjectService {
|
|||||||
ordsPath.trim()));
|
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
|
@Transactional
|
||||||
public void updateColumnPolicy(long columnId, String sensitivityLevel, String redactionMethod) {
|
public void updateColumnPolicy(long columnId, String sensitivityLevel, String redactionMethod) {
|
||||||
String normalizedLevel = normalizeOption(sensitivityLevel, "PUBLIC", SENSITIVITY_LEVELS, "민감도 등급");
|
String normalizedLevel = normalizeOption(sensitivityLevel, "PUBLIC", SENSITIVITY_LEVELS, "민감도 등급");
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import java.util.Locale;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -32,6 +34,9 @@ public class VpdPolicyService {
|
|||||||
private static final long CATALOG_CACHE_MILLIS = 60_000L;
|
private static final long CATALOG_CACHE_MILLIS = 60_000L;
|
||||||
private static final String COMMON_POLICY_NAME = "CB_PERMISSION_SELECT_POLICY";
|
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 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 VpdPolicyMapper mapper;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
@@ -94,6 +99,10 @@ public class VpdPolicyService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String currentUser() {
|
||||||
|
return jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) {
|
public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) {
|
||||||
String functionName = requiredIdentifier(functionNameValue, "Function name");
|
String functionName = requiredIdentifier(functionNameValue, "Function name");
|
||||||
@@ -275,6 +284,75 @@ public class VpdPolicyService {
|
|||||||
return new VpdPolicyDetail(policy, buildAddPolicyBlock(policy));
|
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) {
|
public VpdObjectFilterDetail findObjectFilterDetail(String objectOwner, String objectName) {
|
||||||
String normalizedOwner = requiredIdentifier(objectOwner, "Object owner");
|
String normalizedOwner = requiredIdentifier(objectOwner, "Object owner");
|
||||||
String normalizedObject = requiredIdentifier(objectName, "Object name");
|
String normalizedObject = requiredIdentifier(objectName, "Object name");
|
||||||
@@ -611,6 +689,17 @@ public class VpdPolicyService {
|
|||||||
return String.join(",", statements);
|
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) {
|
private String escapeSqlLiteral(String value) {
|
||||||
return value.replace("'", "''");
|
return value.replace("'", "''");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.web;
|
package com.cloudhandson.vpdbackoffice.web;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.service.McpChatbotService;
|
import com.cloudhandson.vpdbackoffice.service.McpChatbotService;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||||
|
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -13,13 +17,27 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
|||||||
public class McpChatbotController {
|
public class McpChatbotController {
|
||||||
|
|
||||||
private final McpChatbotService chatbotService;
|
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.chatbotService = chatbotService;
|
||||||
|
this.tokenService = tokenService;
|
||||||
|
this.userMapper = userMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/mcp-chatbot")
|
@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";
|
return "mcp-chatbot";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +46,7 @@ public class McpChatbotController {
|
|||||||
@RequestParam(defaultValue = "vpd-live") String contextPath,
|
@RequestParam(defaultValue = "vpd-live") String contextPath,
|
||||||
@RequestParam(defaultValue = "") String question,
|
@RequestParam(defaultValue = "") String question,
|
||||||
@RequestParam(defaultValue = "") String bearerToken,
|
@RequestParam(defaultValue = "") String bearerToken,
|
||||||
|
@RequestParam(required = false) Long tempUserId,
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
Model model
|
Model model
|
||||||
@@ -38,7 +57,20 @@ public class McpChatbotController {
|
|||||||
.replaceQuery(null)
|
.replaceQuery(null)
|
||||||
.build()
|
.build()
|
||||||
.toUriString();
|
.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) {
|
} catch (Exception e) {
|
||||||
model.addAttribute("errorMessage", e.getMessage());
|
model.addAttribute("errorMessage", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Controller;
|
|||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@@ -31,10 +30,6 @@ public class McpClientDemoController {
|
|||||||
|
|
||||||
@PostMapping("/mcp-client-demo")
|
@PostMapping("/mcp-client-demo")
|
||||||
public String run(
|
public String run(
|
||||||
@RequestParam(defaultValue = "vpd-live") String contextPath,
|
|
||||||
@RequestParam(defaultValue = "") String toolName,
|
|
||||||
@RequestParam(defaultValue = "") String bearerToken,
|
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
Model model
|
Model model
|
||||||
) {
|
) {
|
||||||
@@ -45,7 +40,7 @@ public class McpClientDemoController {
|
|||||||
.replaceQuery(null)
|
.replaceQuery(null)
|
||||||
.build()
|
.build()
|
||||||
.toUriString();
|
.toUriString();
|
||||||
model.addAttribute("result", demoService.run(serverOrigin, contextPath, toolName, bearerToken, limit));
|
model.addAttribute("result", demoService.run(serverOrigin, "default", "", "", 50));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
model.addAttribute("errorMessage", e.getMessage());
|
model.addAttribute("errorMessage", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.web;
|
package com.cloudhandson.vpdbackoffice.web;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand;
|
import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand;
|
||||||
|
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||||
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||||
import com.cloudhandson.vpdbackoffice.service.McpReasoningService;
|
import com.cloudhandson.vpdbackoffice.service.McpReasoningService;
|
||||||
import com.cloudhandson.vpdbackoffice.service.McpToolRegistry;
|
import com.cloudhandson.vpdbackoffice.service.McpToolRegistry;
|
||||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -21,32 +21,30 @@ public class McpReasoningController {
|
|||||||
|
|
||||||
private final McpToolRegistry toolRegistry;
|
private final McpToolRegistry toolRegistry;
|
||||||
private final McpReasoningService reasoningService;
|
private final McpReasoningService reasoningService;
|
||||||
private final ProtectedObjectService protectedObjectService;
|
|
||||||
private final BearerTokenService tokenService;
|
private final BearerTokenService tokenService;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
|
||||||
public McpReasoningController(
|
public McpReasoningController(
|
||||||
McpToolRegistry toolRegistry,
|
McpToolRegistry toolRegistry,
|
||||||
McpReasoningService reasoningService,
|
McpReasoningService reasoningService,
|
||||||
ProtectedObjectService protectedObjectService,
|
BearerTokenService tokenService,
|
||||||
BearerTokenService tokenService
|
UserMapper userMapper
|
||||||
) {
|
) {
|
||||||
this.toolRegistry = toolRegistry;
|
this.toolRegistry = toolRegistry;
|
||||||
this.reasoningService = reasoningService;
|
this.reasoningService = reasoningService;
|
||||||
this.protectedObjectService = protectedObjectService;
|
|
||||||
this.tokenService = tokenService;
|
this.tokenService = tokenService;
|
||||||
|
this.userMapper = userMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/mcp-reasoning")
|
@GetMapping("/mcp-reasoning")
|
||||||
public String page(Model model) {
|
public String page(Model model) {
|
||||||
try {
|
try {
|
||||||
model.addAttribute("objects", protectedObjectService.findEnabled());
|
|
||||||
model.addAttribute("tools", toolRegistry.listTools());
|
model.addAttribute("tools", toolRegistry.listTools());
|
||||||
model.addAttribute("tokens", tokenService.findTokenContextOptions());
|
model.addAttribute("users", userMapper.findAll());
|
||||||
} catch (DataAccessException e) {
|
} catch (DataAccessException e) {
|
||||||
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
|
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
|
||||||
model.addAttribute("objects", List.of());
|
|
||||||
model.addAttribute("tools", List.of());
|
model.addAttribute("tools", List.of());
|
||||||
model.addAttribute("tokens", List.of());
|
model.addAttribute("users", List.of());
|
||||||
model.addAttribute("runtimeError", message);
|
model.addAttribute("runtimeError", message);
|
||||||
}
|
}
|
||||||
return "mcp-reasoning";
|
return "mcp-reasoning";
|
||||||
@@ -82,15 +80,29 @@ public class McpReasoningController {
|
|||||||
|
|
||||||
@PostMapping("/mcp-reasoning")
|
@PostMapping("/mcp-reasoning")
|
||||||
public String reason(
|
public String reason(
|
||||||
@RequestParam(required = false) Long tokenKeyId,
|
@RequestParam(defaultValue = "") String bearerToken,
|
||||||
@RequestParam long objectId,
|
@RequestParam(required = false) Long tempUserId,
|
||||||
@RequestParam String bearerToken,
|
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
@RequestParam(defaultValue = "") String question,
|
@RequestParam(defaultValue = "") String question,
|
||||||
Model model
|
Model model
|
||||||
) {
|
) {
|
||||||
|
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(
|
model.addAttribute("result", reasoningService.reason(
|
||||||
new McpReasoningCommand(tokenKeyId, objectId, bearerToken, limit, question)));
|
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";
|
return "fragments/mcp-reasoning-result :: result";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
|||||||
import com.cloudhandson.vpdbackoffice.service.OrdsProbeService;
|
import com.cloudhandson.vpdbackoffice.service.OrdsProbeService;
|
||||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||||
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
|
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
|
||||||
|
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -23,17 +24,20 @@ public class ProbeController {
|
|||||||
private final ProtectedObjectService protectedObjectService;
|
private final ProtectedObjectService protectedObjectService;
|
||||||
private final BearerTokenService tokenService;
|
private final BearerTokenService tokenService;
|
||||||
private final VpdPolicyService vpdPolicyService;
|
private final VpdPolicyService vpdPolicyService;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
|
||||||
public ProbeController(
|
public ProbeController(
|
||||||
OrdsProbeService probeService,
|
OrdsProbeService probeService,
|
||||||
ProtectedObjectService protectedObjectService,
|
ProtectedObjectService protectedObjectService,
|
||||||
BearerTokenService tokenService,
|
BearerTokenService tokenService,
|
||||||
VpdPolicyService vpdPolicyService
|
VpdPolicyService vpdPolicyService,
|
||||||
|
UserMapper userMapper
|
||||||
) {
|
) {
|
||||||
this.probeService = probeService;
|
this.probeService = probeService;
|
||||||
this.protectedObjectService = protectedObjectService;
|
this.protectedObjectService = protectedObjectService;
|
||||||
this.tokenService = tokenService;
|
this.tokenService = tokenService;
|
||||||
this.vpdPolicyService = vpdPolicyService;
|
this.vpdPolicyService = vpdPolicyService;
|
||||||
|
this.userMapper = userMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/probe")
|
@GetMapping("/probe")
|
||||||
@@ -48,19 +52,35 @@ public class ProbeController {
|
|||||||
.toList();
|
.toList();
|
||||||
model.addAttribute("objects", objects);
|
model.addAttribute("objects", objects);
|
||||||
model.addAttribute("defaultObjectKeys", defaultObjectKeys);
|
model.addAttribute("defaultObjectKeys", defaultObjectKeys);
|
||||||
|
model.addAttribute("users", userMapper.findAll());
|
||||||
return "probe";
|
return "probe";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/probe")
|
@PostMapping("/probe")
|
||||||
public String run(
|
public String run(
|
||||||
@RequestParam long objectId,
|
@RequestParam long objectId,
|
||||||
@RequestParam String bearerToken,
|
@RequestParam(defaultValue = "") String bearerToken,
|
||||||
|
@RequestParam(required = false) Long tempUserId,
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
|
@RequestParam(required = false) String requestBody,
|
||||||
Model model
|
Model model
|
||||||
) {
|
) {
|
||||||
String normalizedToken = bearerToken == null ? "" : bearerToken.trim();
|
String normalizedToken = bearerToken == null ? "" : bearerToken.trim();
|
||||||
model.addAttribute("result", probeService.runProbe(new ProbeCommand(null, objectId, normalizedToken, limit)));
|
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));
|
model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken));
|
||||||
|
} finally {
|
||||||
|
if (temporaryKeyId != null) {
|
||||||
|
tokenService.revokeToken(temporaryKeyId, "temporary probe completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
model.addAttribute("selectedObject", protectedObjectService.findEnabled().stream()
|
model.addAttribute("selectedObject", protectedObjectService.findEnabled().stream()
|
||||||
.filter(object -> object.objectId() == objectId)
|
.filter(object -> object.objectId() == objectId)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
|
|||||||
@@ -44,12 +44,13 @@ public class ProtectedObjectController {
|
|||||||
public String create(
|
public String create(
|
||||||
@RequestParam String owner,
|
@RequestParam String owner,
|
||||||
@RequestParam String objectName,
|
@RequestParam String objectName,
|
||||||
@RequestParam String ordsPath,
|
@RequestParam(required = false) String ordsPath,
|
||||||
|
@RequestParam(required = false) String description,
|
||||||
RedirectAttributes redirectAttributes
|
RedirectAttributes redirectAttributes
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
protectedObjectService.createObject(
|
protectedObjectService.createObject(
|
||||||
new ProtectedObjectCreateCommand(owner, objectName, ordsPath, null, null));
|
new ProtectedObjectCreateCommand(owner, objectName, ordsPath, null, null, description));
|
||||||
redirectAttributes.addFlashAttribute("message", "ORDS 조회 Handler 대상을 추가했습니다.");
|
redirectAttributes.addFlashAttribute("message", "ORDS 조회 Handler 대상을 추가했습니다.");
|
||||||
} catch (AppException exception) {
|
} catch (AppException exception) {
|
||||||
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||||
@@ -57,6 +58,21 @@ public class ProtectedObjectController {
|
|||||||
return "redirect:/objects";
|
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")
|
@PostMapping("/objects/ords-path")
|
||||||
public String updateOrdsPath(
|
public String updateOrdsPath(
|
||||||
@RequestParam long objectId,
|
@RequestParam long objectId,
|
||||||
@@ -81,7 +97,7 @@ public class ProtectedObjectController {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
protectedObjectService.updateColumnPolicy(columnId, sensitivityLevel, redactionMethod);
|
protectedObjectService.updateColumnPolicy(columnId, sensitivityLevel, redactionMethod);
|
||||||
redirectAttributes.addFlashAttribute("message", "컬럼 민감도/마스킹 정책을 수정했습니다.");
|
redirectAttributes.addFlashAttribute("message", "컬럼 표시 보호 정책을 수정했습니다. 행 접근 권한은 변경되지 않습니다.");
|
||||||
} catch (AppException exception) {
|
} catch (AppException exception) {
|
||||||
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ public class TokenController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/tokens")
|
@GetMapping("/tokens")
|
||||||
public String tokens(Model model) {
|
public String tokens(
|
||||||
model.addAttribute("tokens", tokenService.findAll());
|
@RequestParam(defaultValue = "false") boolean includeInactive,
|
||||||
|
Model model
|
||||||
|
) {
|
||||||
|
model.addAttribute("tokens", tokenService.findAll(includeInactive));
|
||||||
|
model.addAttribute("includeInactive", includeInactive);
|
||||||
model.addAttribute("users", userMapper.findAll());
|
model.addAttribute("users", userMapper.findAll());
|
||||||
model.addAttribute("defaultExpiresAt", defaultExpiresAt());
|
model.addAttribute("defaultExpiresAt", defaultExpiresAt());
|
||||||
return "tokens";
|
return "tokens";
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
|
|||||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||||
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
|
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -42,10 +44,34 @@ public class VpdPolicyController {
|
|||||||
private void populatePolicyModel(String schemaOwner, Model model) {
|
private void populatePolicyModel(String schemaOwner, Model model) {
|
||||||
try {
|
try {
|
||||||
String selectedSchemaOwner = schemaOwner == null ? "" : schemaOwner.trim().toUpperCase();
|
String selectedSchemaOwner = schemaOwner == null ? "" : schemaOwner.trim().toUpperCase();
|
||||||
model.addAttribute("policies", vpdPolicyService.findPolicies());
|
List<com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView> policies = vpdPolicyService.findPolicies();
|
||||||
|
Map<String, String> 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("vpdTargets", vpdPolicyService.findVpdTargets(selectedSchemaOwner));
|
||||||
model.addAttribute("selectedSchemaOwner", selectedSchemaOwner);
|
model.addAttribute("selectedSchemaOwner", selectedSchemaOwner);
|
||||||
model.addAttribute("formOptions", vpdPolicyService.formOptions());
|
var formOptions = vpdPolicyService.formOptions();
|
||||||
|
Map<String, String> 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<String, String> 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) {
|
} catch (DataAccessException exception) {
|
||||||
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
|
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
|
||||||
model.addAttribute("runtimeError", message);
|
model.addAttribute("runtimeError", message);
|
||||||
@@ -53,12 +79,18 @@ public class VpdPolicyController {
|
|||||||
model.addAttribute("vpdTargets", List.of());
|
model.addAttribute("vpdTargets", List.of());
|
||||||
model.addAttribute("selectedSchemaOwner", "");
|
model.addAttribute("selectedSchemaOwner", "");
|
||||||
model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions());
|
model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions());
|
||||||
|
model.addAttribute("policyDescriptions", Map.of());
|
||||||
|
model.addAttribute("filterDescriptions", Map.of());
|
||||||
|
model.addAttribute("filterPredicates", Map.of());
|
||||||
} catch (AppException exception) {
|
} catch (AppException exception) {
|
||||||
model.addAttribute("errorMessage", exception.getMessage());
|
model.addAttribute("errorMessage", exception.getMessage());
|
||||||
model.addAttribute("policies", List.of());
|
model.addAttribute("policies", List.of());
|
||||||
model.addAttribute("vpdTargets", List.of());
|
model.addAttribute("vpdTargets", List.of());
|
||||||
model.addAttribute("selectedSchemaOwner", "");
|
model.addAttribute("selectedSchemaOwner", "");
|
||||||
model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions());
|
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(required = false) String functionOwner,
|
||||||
@RequestParam String functionName,
|
@RequestParam String functionName,
|
||||||
@RequestParam String filterPredicate,
|
@RequestParam String filterPredicate,
|
||||||
|
@RequestParam(defaultValue = "Filter function이 반환하는 predicate로 조회 행을 제한합니다.") String description,
|
||||||
RedirectAttributes redirectAttributes
|
RedirectAttributes redirectAttributes
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
vpdPolicyService.saveFilterFunction(functionOwner, functionName, filterPredicate);
|
vpdPolicyService.saveFilterFunction(functionOwner, functionName, filterPredicate);
|
||||||
|
vpdPolicyService.saveFilterDescription(
|
||||||
|
functionOwner == null || functionOwner.isBlank()
|
||||||
|
? vpdPolicyService.currentUser()
|
||||||
|
: functionOwner,
|
||||||
|
functionName,
|
||||||
|
description
|
||||||
|
);
|
||||||
redirectAttributes.addFlashAttribute("successMessage", "Filter function을 저장했습니다: " + functionName);
|
redirectAttributes.addFlashAttribute("successMessage", "Filter function을 저장했습니다: " + functionName);
|
||||||
} catch (AppException exception) {
|
} catch (AppException exception) {
|
||||||
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||||
@@ -119,6 +159,23 @@ public class VpdPolicyController {
|
|||||||
return "redirect:/vpd-filter-policies";
|
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")
|
@PostMapping("/vpd-filter-policies/replace")
|
||||||
public String replaceFilterPolicy(
|
public String replaceFilterPolicy(
|
||||||
@RequestParam String oldObjectKey,
|
@RequestParam String oldObjectKey,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
k.expires_at, k.revoked_at, k.description
|
k.expires_at, k.revoked_at, k.description
|
||||||
FROM cb_agent_bearer_key k
|
FROM cb_agent_bearer_key k
|
||||||
JOIN cb_app_user u ON u.user_id = k.user_id
|
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
|
ORDER BY k.key_id DESC
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '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 = '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 = '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
|
ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value
|
||||||
END,
|
END,
|
||||||
CHR(10) || 'AND '
|
CHR(10) || 'AND '
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper">
|
||||||
<select id="findEnabled" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
|
<select id="findEnabled" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
|
||||||
SELECT object_id, owner, object_name, ords_path, enabled_yn
|
SELECT object_id, owner, object_name, ords_path, enabled_yn, description
|
||||||
FROM cb_protected_object
|
FROM cb_protected_object
|
||||||
WHERE enabled_yn = 'Y'
|
WHERE enabled_yn = 'Y'
|
||||||
ORDER BY owner, object_name
|
ORDER BY owner, object_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findEnabledWithPermissions" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
|
<select id="findEnabledWithPermissions" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
|
||||||
SELECT DISTINCT po.object_id, po.owner, po.object_name, po.ords_path, po.enabled_yn
|
SELECT DISTINCT po.object_id, po.owner, po.object_name, po.ords_path, po.enabled_yn, po.description
|
||||||
FROM cb_protected_object po
|
FROM cb_protected_object po
|
||||||
JOIN cb_permission p ON p.target_name = po.object_name
|
JOIN cb_permission p ON p.target_name = po.object_name
|
||||||
WHERE po.enabled_yn = 'Y'
|
WHERE po.enabled_yn = 'Y'
|
||||||
@@ -18,13 +18,13 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
|
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
|
||||||
SELECT object_id, owner, object_name, ords_path, enabled_yn
|
SELECT object_id, owner, object_name, ords_path, enabled_yn, description
|
||||||
FROM cb_protected_object
|
FROM cb_protected_object
|
||||||
WHERE object_id = #{objectId}
|
WHERE object_id = #{objectId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findByOwnerAndName" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
|
<select id="findByOwnerAndName" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
|
||||||
SELECT object_id, owner, object_name, ords_path, enabled_yn
|
SELECT object_id, owner, object_name, ords_path, enabled_yn, description
|
||||||
FROM cb_protected_object
|
FROM cb_protected_object
|
||||||
WHERE owner = UPPER(#{owner,jdbcType=VARCHAR})
|
WHERE owner = UPPER(#{owner,jdbcType=VARCHAR})
|
||||||
AND object_name = UPPER(#{objectName,jdbcType=VARCHAR})
|
AND object_name = UPPER(#{objectName,jdbcType=VARCHAR})
|
||||||
@@ -78,13 +78,14 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insertObject">
|
<insert id="insertObject">
|
||||||
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 (
|
VALUES (
|
||||||
#{objectId},
|
#{objectId},
|
||||||
UPPER(#{command.owner}),
|
UPPER(#{command.owner}),
|
||||||
UPPER(#{command.objectName}),
|
UPPER(#{command.objectName}),
|
||||||
#{command.ordsPath},
|
#{command.ordsPath},
|
||||||
'Y'
|
'Y',
|
||||||
|
#{command.description,jdbcType=VARCHAR}
|
||||||
)
|
)
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
@@ -115,6 +116,12 @@
|
|||||||
WHERE object_id = #{objectId,jdbcType=NUMERIC}
|
WHERE object_id = #{objectId,jdbcType=NUMERIC}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updateDescription">
|
||||||
|
UPDATE cb_protected_object
|
||||||
|
SET description = #{description,jdbcType=VARCHAR}
|
||||||
|
WHERE object_id = #{objectId,jdbcType=NUMERIC}
|
||||||
|
</update>
|
||||||
|
|
||||||
<update id="enableObject">
|
<update id="enableObject">
|
||||||
UPDATE cb_protected_object
|
UPDATE cb_protected_object
|
||||||
SET enabled_yn = 'Y'
|
SET enabled_yn = 'Y'
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
o.object_type,
|
o.object_type,
|
||||||
CASE WHEN po.object_id IS NULL THEN 'N' ELSE po.enabled_yn END AS protected_yn,
|
CASE WHEN po.object_id IS NULL THEN 'N' ELSE po.enabled_yn END AS protected_yn,
|
||||||
po.ords_path,
|
po.ords_path,
|
||||||
|
po.description,
|
||||||
COUNT(p.policy_name) AS policy_count,
|
COUNT(p.policy_name) AS policy_count,
|
||||||
LISTAGG(p.policy_name, ', ') WITHIN GROUP (ORDER BY p.policy_name) AS policy_names,
|
LISTAGG(p.policy_name, ', ') WITHIN GROUP (ORDER BY p.policy_name) AS policy_names,
|
||||||
LISTAGG(
|
LISTAGG(
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
LEFT JOIN all_policies p
|
LEFT JOIN all_policies p
|
||||||
ON p.object_owner = o.owner
|
ON p.object_owner = o.owner
|
||||||
AND p.object_name = o.object_name
|
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,
|
ORDER BY CASE WHEN COUNT(p.policy_name) > 0 THEN 0 ELSE 1 END,
|
||||||
o.owner,
|
o.owner,
|
||||||
o.object_type,
|
o.object_type,
|
||||||
@@ -278,4 +279,50 @@
|
|||||||
AND name = UPPER(#{objectName,jdbcType=VARCHAR})
|
AND name = UPPER(#{objectName,jdbcType=VARCHAR})
|
||||||
AND type = UPPER(#{objectType,jdbcType=VARCHAR})
|
AND type = UPPER(#{objectType,jdbcType=VARCHAR})
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select id="findPolicyDescription" resultType="string">
|
||||||
|
SELECT description
|
||||||
|
FROM cb_vpd_policy_note
|
||||||
|
WHERE object_owner = UPPER(#{objectOwner,jdbcType=VARCHAR})
|
||||||
|
AND object_name = UPPER(#{objectName,jdbcType=VARCHAR})
|
||||||
|
AND policy_name = UPPER(#{policyName,jdbcType=VARCHAR})
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findFilterDescription" resultType="string">
|
||||||
|
SELECT description
|
||||||
|
FROM cb_vpd_filter_note
|
||||||
|
WHERE function_owner = UPPER(#{functionOwner,jdbcType=VARCHAR})
|
||||||
|
AND function_name = UPPER(#{functionName,jdbcType=VARCHAR})
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="upsertPolicyDescription">
|
||||||
|
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)
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="upsertFilterDescription">
|
||||||
|
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)
|
||||||
|
</update>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -1802,6 +1802,36 @@ body {
|
|||||||
min-width: 520px;
|
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) {
|
@media (max-width: 920px) {
|
||||||
.journey-grid,
|
.journey-grid,
|
||||||
.macro-micro-grid,
|
.macro-micro-grid,
|
||||||
|
|||||||
@@ -357,6 +357,28 @@ function renderMaskableColumnOptions(option) {
|
|||||||
current.push(column);
|
current.push(column);
|
||||||
}
|
}
|
||||||
input.value = current.join(', ');
|
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) => (
|
||||||
|
`<button type="button" class="badge text-bg-secondary border-0 me-1 mb-1" data-remove-visible-column="${column}">`
|
||||||
|
+ `${escapeHtml(column)} ×</button>`
|
||||||
|
)).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);
|
updatePermissionWizardPreview(document);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -372,14 +394,15 @@ function syncRuleTypeHints(root = document) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const type = typeSelect.value;
|
const type = typeSelect.value;
|
||||||
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO'].includes(type);
|
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG'].includes(type);
|
||||||
const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO'].includes(type);
|
const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG'].includes(type);
|
||||||
const placeholderByType = {
|
const placeholderByType = {
|
||||||
ALL: '값 불필요',
|
ALL: '값 불필요',
|
||||||
MY_DEPT: '값 불필요',
|
MY_DEPT: '값 불필요',
|
||||||
SELF: '값 불필요',
|
SELF: '값 불필요',
|
||||||
DEPT: '예: HR',
|
DEPT: '예: HR',
|
||||||
EMP_NO: '예: E2001',
|
EMP_NO: '예: E2001',
|
||||||
|
TAG: '예: SPRING_BOOT',
|
||||||
'=': '비교 값',
|
'=': '비교 값',
|
||||||
'!=': '비교 값'
|
'!=': '비교 값'
|
||||||
};
|
};
|
||||||
@@ -441,7 +464,59 @@ async function syncObjectCatalogSelection() {
|
|||||||
const owner = (selected.dataset.owner || '').toLowerCase();
|
const owner = (selected.dataset.owner || '').toLowerCase();
|
||||||
const objectName = (selected.dataset.objectName || '').toLowerCase();
|
const objectName = (selected.dataset.objectName || '').toLowerCase();
|
||||||
ordsPathInput.value = owner && objectName ? `cb-ords/cb-object-query/${owner}/${objectName}` : '';
|
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) {
|
function selectedText(select) {
|
||||||
@@ -570,6 +645,7 @@ function collectWizardRules(root) {
|
|||||||
SELF: 'OWNER_EMP_NO',
|
SELF: 'OWNER_EMP_NO',
|
||||||
DEPT: 'DEPT_CODE',
|
DEPT: 'DEPT_CODE',
|
||||||
EMP_NO: 'OWNER_EMP_NO',
|
EMP_NO: 'OWNER_EMP_NO',
|
||||||
|
TAG: 'TECH_TAG',
|
||||||
ALL: ''
|
ALL: ''
|
||||||
}[type] || '';
|
}[type] || '';
|
||||||
if (type === 'ALL') {
|
if (type === 'ALL') {
|
||||||
@@ -578,6 +654,9 @@ function collectWizardRules(root) {
|
|||||||
if (['MY_DEPT', 'SELF'].includes(type)) {
|
if (['MY_DEPT', 'SELF'].includes(type)) {
|
||||||
return `${displayColumn} ${type}`;
|
return `${displayColumn} ${type}`;
|
||||||
}
|
}
|
||||||
|
if (type === 'TAG') {
|
||||||
|
return `${displayColumn} TAG ${value.toUpperCase()}`;
|
||||||
|
}
|
||||||
return [displayColumn, type, value].filter(Boolean).join(' ');
|
return [displayColumn, type, value].filter(Boolean).join(' ');
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -592,6 +671,7 @@ function collectWizardPredicates(root) {
|
|||||||
SELF: 'OWNER_EMP_NO',
|
SELF: 'OWNER_EMP_NO',
|
||||||
DEPT: 'DEPT_CODE',
|
DEPT: 'DEPT_CODE',
|
||||||
EMP_NO: 'OWNER_EMP_NO',
|
EMP_NO: 'OWNER_EMP_NO',
|
||||||
|
TAG: 'TECH_TAG',
|
||||||
ALL: ''
|
ALL: ''
|
||||||
}[type] || '';
|
}[type] || '';
|
||||||
if (type === 'ALL') {
|
if (type === 'ALL') {
|
||||||
@@ -606,6 +686,9 @@ function collectWizardPredicates(root) {
|
|||||||
if (type === 'DEPT' || type === 'EMP_NO') {
|
if (type === 'DEPT' || type === 'EMP_NO') {
|
||||||
return `${displayColumn} = ${sqlLiteral(value)}`;
|
return `${displayColumn} = ${sqlLiteral(value)}`;
|
||||||
}
|
}
|
||||||
|
if (type === 'TAG') {
|
||||||
|
return `REGEXP_LIKE(UPPER(${displayColumn}), ${sqlLiteral(`(^|,)${value.toUpperCase()}(,|$)`)})`;
|
||||||
|
}
|
||||||
if (type === '=') {
|
if (type === '=') {
|
||||||
return `TO_CHAR(${displayColumn}) = ${sqlLiteral(value)}`;
|
return `TO_CHAR(${displayColumn}) = ${sqlLiteral(value)}`;
|
||||||
}
|
}
|
||||||
@@ -880,10 +963,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
syncRuleTypeHints();
|
syncRuleTypeHints();
|
||||||
|
document.querySelector('input[name="visibleColumns"]')?.addEventListener('input', renderSelectedVisibleColumns);
|
||||||
|
renderSelectedVisibleColumns();
|
||||||
const catalog = document.getElementById('objectCatalogSelect');
|
const catalog = document.getElementById('objectCatalogSelect');
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
catalog.addEventListener('change', syncObjectCatalogSelection);
|
catalog.addEventListener('change', syncObjectCatalogSelection);
|
||||||
|
syncObjectCatalogSelection();
|
||||||
}
|
}
|
||||||
|
initOrdsPathEditToggle();
|
||||||
|
initProbeObjectDescription();
|
||||||
|
initProbeVectorInput();
|
||||||
document.querySelectorAll('[data-policy-template-select]').forEach((select) => {
|
document.querySelectorAll('[data-policy-template-select]').forEach((select) => {
|
||||||
select.addEventListener('change', () => syncPolicyTemplate(select));
|
select.addEventListener('change', () => syncPolicyTemplate(select));
|
||||||
syncPolicyTemplate(select);
|
syncPolicyTemplate(select);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
<body>
|
<body>
|
||||||
<div th:fragment="result">
|
<div th:fragment="result">
|
||||||
|
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}">Reasoning 실행에 실패했습니다.</div>
|
||||||
|
<div th:if="${result}">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2>Reasoning 결과</h2>
|
<h2>Reasoning 결과</h2>
|
||||||
<span class="badge"
|
<span class="badge"
|
||||||
@@ -60,5 +62,6 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<span th:text="${object.owner() + '.' + object.objectName()}">ADMIN.TABLE_NAME</span>
|
<span th:text="${object.owner() + '.' + object.objectName()}">ADMIN.TABLE_NAME</span>
|
||||||
<span class="text-muted"> / 기본 ORDS Handler PL/SQL</span>
|
<span class="text-muted"> / 기본 ORDS Handler PL/SQL</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
<p class="form-hint">이 소스는 VPD context를 설정한 뒤 한 테이블을 SELECT하는 시작 예제입니다. 실제 수정·저장은 <a href="/ords-handlers">ORDS 핸들러</a> 화면에서 parsing schema 권한으로 진행하세요.</p>
|
||||||
<pre th:text="${source}">source</pre>
|
<pre th:text="${source}">source</pre>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,15 +4,20 @@
|
|||||||
<body>
|
<body>
|
||||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
<main class="container page-shell">
|
<main class="container page-shell">
|
||||||
<section class="page-heading">
|
<section class="page-heading compact-heading">
|
||||||
<h1>MCP Chatbot</h1>
|
<h1 class="h3">MCP Chatbot</h1>
|
||||||
<p>질문을 MCP tool로 라우팅하고 ORDS/VPD 조회 결과를 답변으로 정리합니다.</p>
|
<p>질문을 MCP tool로 라우팅하고, 권한 태그·VPD 행 제한·ORDS 결과를 답변으로 정리합니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section th:if="${runtimeError}" class="alert alert-warning">
|
||||||
|
<strong th:text="${runtimeError.title()}">DB 연결 설정이 필요합니다.</strong>
|
||||||
|
<span th:text="${runtimeError.message()}">message</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="content-band chat-shell">
|
<section class="content-band chat-shell">
|
||||||
<div class="chat-message assistant-message">
|
<div class="chat-message assistant-message">
|
||||||
<strong>질문 실행</strong>
|
<strong>질문 실행</strong>
|
||||||
<p>아래 예시를 누르거나 직접 질문을 입력하세요. Bearer Token을 입력하면 실제 ORDS/VPD 결과까지 조회합니다.</p>
|
<p>아래 예시를 누르거나 직접 질문을 입력하세요. Bearer Token 원문을 붙여 넣거나 사용자를 선택해 10분 임시 토큰으로 실제 ORDS/VPD 결과를 조회할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mcp-chatbot-result" class="chat-thread">
|
<div id="mcp-chatbot-result" class="chat-thread">
|
||||||
@@ -33,6 +38,14 @@
|
|||||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off"
|
<input class="form-control" name="bearerToken" type="password" autocomplete="off"
|
||||||
placeholder="비우면 라우팅만 확인합니다.">
|
placeholder="비우면 라우팅만 확인합니다.">
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
임시 토큰 사용자 (선택)
|
||||||
|
<select class="form-select" name="tempUserId">
|
||||||
|
<option value="">원문 토큰 사용</option>
|
||||||
|
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
|
||||||
|
</select>
|
||||||
|
<span class="form-hint">선택하면 질문 처리 중에만 임시 토큰을 발행하고 완료 후 즉시 회수합니다.</span>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
질문
|
질문
|
||||||
<textarea class="form-control" id="mcp-chat-question" name="question" rows="3"
|
<textarea class="form-control" id="mcp-chat-question" name="question" rows="3"
|
||||||
@@ -40,19 +53,19 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="question-presets" aria-label="질문 예시">
|
<div class="question-presets" aria-label="질문 예시">
|
||||||
<button class="btn rw-btn-secondary question-preset" type="button"
|
<button class="btn rw-btn-secondary question-preset" type="button"
|
||||||
data-question="CB_V_SEARCH_DOCUMENTS에서 이 토큰으로 보이는 문서 행 수와 주요 컬럼을 요약해줘.">
|
data-question="기술 태그 권한에 따라 CB_V_SEARCH_DOCUMENTS에서 이 토큰으로 보이는 문서 행 수와 주요 컬럼을 요약해줘.">
|
||||||
문서 조회 요약
|
문서 조회 요약
|
||||||
</button>
|
</button>
|
||||||
<button class="btn rw-btn-secondary question-preset" type="button"
|
<button class="btn rw-btn-secondary question-preset" type="button"
|
||||||
data-question="CB_V_SEARCH_DOCUMENTS에서 contents 컬럼이 NULL 처리되는지 확인하고, 보이는 행의 dept_code와 owner_emp_no를 정리해줘.">
|
data-question="CB_V_SEARCH_DOCUMENTS에서 VPD가 제외한 행과 contents 표시 보호 여부를 구분하고, 보이는 dept_code와 owner_emp_no를 정리해줘.">
|
||||||
민감 컬럼 확인
|
민감 컬럼 확인
|
||||||
</button>
|
</button>
|
||||||
<button class="btn rw-btn-secondary question-preset" type="button"
|
<button class="btn rw-btn-secondary question-preset" type="button"
|
||||||
data-question="BOARD_POSTS에서 이 토큰으로 조회 가능한 게시글 행과 VPD 적용 결과를 요약해줘.">
|
data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 SPRING_BOOT 또는 ORACLE_VPD 태그 권한으로 검색 가능한 청크와 VPD 결과를 요약해줘.">
|
||||||
BOARD_POSTS 조회
|
태그 벡터 검색
|
||||||
</button>
|
</button>
|
||||||
<button class="btn rw-btn-secondary question-preset" type="button"
|
<button class="btn rw-btn-secondary question-preset" type="button"
|
||||||
data-question="등록된 보호 객체 중 질문과 가장 가까운 ORDS/VPD MCP tool을 선택하고, 토큰 없이 라우팅 결과만 보여줘.">
|
data-question="질문과 가장 가까운 ORDS/VPD MCP tool을 선택하고, 선택 근거와 필요한 bearer token 흐름만 보여줘.">
|
||||||
라우팅만 확인
|
라우팅만 확인
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<main class="container page-shell">
|
<main class="container page-shell">
|
||||||
<section class="page-heading">
|
<section class="page-heading">
|
||||||
<h1>MCP Client Demo</h1>
|
<h1>MCP Client Demo</h1>
|
||||||
<p>백오피스 Java client가 MCP message endpoint를 호출해 initialize, tools/list, tools/call 결과를 확인합니다.</p>
|
<p>백오피스 Java client가 MCP message endpoint의 initialize/tools/list 계약을 확인합니다. 실제 tool 선택과 호출은 reasoning 결과를 받은 MCP client가 담당합니다.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section th:if="${runtimeError}" class="alert alert-warning">
|
<section th:if="${runtimeError}" class="alert alert-warning">
|
||||||
@@ -18,29 +18,10 @@
|
|||||||
<h2>Java Client 호출</h2>
|
<h2>Java Client 호출</h2>
|
||||||
<form hx-post="/mcp-client-demo" hx-target="#mcp-client-demo-result" hx-swap="innerHTML" class="form-grid">
|
<form hx-post="/mcp-client-demo" hx-target="#mcp-client-demo-result" hx-swap="innerHTML" class="form-grid">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<label>
|
<div class="alert alert-info span-2 mb-0">
|
||||||
Context Path
|
이 화면은 고정된 기본 MCP endpoint에서 initialize와 tools/list만 확인합니다. 질문 기반 tool 선택·Bearer Token 전달·tools/call은 MCP Reasoning 또는 외부 MCP client 흐름에서 수행합니다.
|
||||||
<input class="form-control" name="contextPath" value="vpd-live" pattern="[A-Za-z0-9][A-Za-z0-9_-]{0,63}" required>
|
</div>
|
||||||
</label>
|
<button class="btn rw-btn-primary" type="submit">MCP 계약 확인</button>
|
||||||
<label>
|
|
||||||
Tool
|
|
||||||
<select class="form-select" name="toolName">
|
|
||||||
<option value="">tools/list만 실행</option>
|
|
||||||
<option th:each="tool : ${tools}"
|
|
||||||
th:value="${tool.name()}"
|
|
||||||
th:text="${tool.name() + ' / ' + tool.displayName()}"></option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Limit
|
|
||||||
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
|
|
||||||
</label>
|
|
||||||
<label class="span-2">
|
|
||||||
Bearer Token 원문
|
|
||||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off"
|
|
||||||
placeholder="비우면 tools/call은 실행하지 않습니다.">
|
|
||||||
</label>
|
|
||||||
<button class="btn rw-btn-primary" type="submit">Java Client 실행</button>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -53,17 +34,17 @@
|
|||||||
<div class="mcp-service-item">
|
<div class="mcp-service-item">
|
||||||
<span>1</span>
|
<span>1</span>
|
||||||
<strong><code>initialize</code></strong>
|
<strong><code>initialize</code></strong>
|
||||||
<small>context path가 반영된 MCP serverInfo와 tools capability를 확인합니다.</small>
|
<small>기본 MCP serverInfo와 tools capability를 확인합니다.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="mcp-service-item">
|
<div class="mcp-service-item">
|
||||||
<span>2</span>
|
<span>2</span>
|
||||||
<strong><code>tools/list</code></strong>
|
<strong><code>tools/list</code></strong>
|
||||||
<small>현재 보호 객체에서 생성된 ORDS query tool 목록을 조회합니다.</small>
|
<small>현재 보호 객체에서 생성된 ORDS query tool과 설명/schema를 조회합니다.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="mcp-service-item">
|
<div class="mcp-service-item">
|
||||||
<span>3</span>
|
<span>3</span>
|
||||||
<strong><code>tools/call</code></strong>
|
<strong><code>tools/call</code></strong>
|
||||||
<small>Bearer Token이 입력된 경우 선택한 tool을 호출해 ORDS/VPD 결과를 받습니다.</small>
|
<small>tool 선택은 reasoning 이후 MCP client가 수행하므로 이 화면에서는 호출하지 않습니다.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<h1>MCP Reasoning</h1>
|
<h1>MCP Reasoning</h1>
|
||||||
<p>Bearer Token으로 보호 객체를 조회한 뒤, 반환된 행과 NULL 처리 결과를 모델이 설명합니다.</p>
|
<p>질문을 근거로 MCP tool을 고르고, Bearer Token으로 VPD/ORDS 결과를 조회한 뒤 모델이 설명합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning" th:if="${runtimeError}">
|
<div class="alert alert-warning" th:if="${runtimeError}">
|
||||||
@@ -24,68 +24,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<form hx-post="/mcp-reasoning" hx-target="#mcp-result" hx-swap="innerHTML" class="form-grid token-form">
|
<form hx-post="/mcp-reasoning" hx-target="#mcp-result" hx-swap="innerHTML" class="form-grid token-form">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<label>
|
|
||||||
등록 토큰
|
|
||||||
<select class="form-select" name="tokenKeyId" data-token-context-select>
|
|
||||||
<option value="">원문 직접 입력</option>
|
|
||||||
<option th:each="token : ${tokens}"
|
|
||||||
th:value="${token.keyId()}"
|
|
||||||
th:text="${token.displayLabel()}"
|
|
||||||
th:attr="data-username=${token.username()},
|
|
||||||
data-prefix=${token.maskedToken()},
|
|
||||||
data-status=${token.statusLabel()},
|
|
||||||
data-expires-at=${token.expiresAt()},
|
|
||||||
data-description=${token.description()},
|
|
||||||
data-direct-roles=${#strings.listJoin(token.directRoles(), '|')},
|
|
||||||
data-groups=${#strings.listJoin(token.groups(), '|')},
|
|
||||||
data-inherited-roles=${#strings.listJoin(token.inheritedRoles(), '|')}"></option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
Bearer Token 원문
|
Bearer Token 원문
|
||||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
|
<input class="form-control" name="bearerToken" type="password" autocomplete="off">
|
||||||
<span class="form-hint">선택 토큰은 컨텍스트 확인용입니다. ORDS 호출에는 원문 입력이 필요합니다.</span>
|
<span class="form-hint">등록 토큰을 선택하지 않습니다. 원문을 붙여 넣거나 아래에서 임시 토큰 사용자를 선택하세요.</span>
|
||||||
</label>
|
</label>
|
||||||
<aside class="token-context-preview effective-preview span-2" data-token-context-preview>
|
|
||||||
<div class="section-heading compact-heading">
|
|
||||||
<h3>선택 토큰 컨텍스트</h3>
|
|
||||||
<span class="badge text-bg-secondary" data-token-preview="status">미선택</span>
|
|
||||||
</div>
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>사용자</dt>
|
|
||||||
<dd data-token-preview="username">원문 직접 입력</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Prefix</dt>
|
|
||||||
<dd><code data-token-preview="prefix">-</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>만료</dt>
|
|
||||||
<dd data-token-preview="expiresAt">-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>직접 역할</dt>
|
|
||||||
<dd data-token-preview="directRoles">-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>그룹</dt>
|
|
||||||
<dd data-token-preview="groups">-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>그룹 상속 역할</dt>
|
|
||||||
<dd data-token-preview="inheritedRoles">-</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<p class="form-hint" data-token-preview="description">질문 전에 이 토큰이 어느 사용자/역할 컨텍스트인지 확인합니다.</p>
|
|
||||||
</aside>
|
|
||||||
<label>
|
<label>
|
||||||
조회 대상
|
임시 토큰 사용자 (선택)
|
||||||
<select class="form-select" id="mcp-reasoning-object" name="objectId" required>
|
<select class="form-select" name="tempUserId">
|
||||||
<option th:each="tool : ${tools}"
|
<option value="">원문 토큰 사용</option>
|
||||||
th:value="${tool.objectId()}"
|
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
|
||||||
th:text="${tool.displayName() + ' / ' + tool.ordsPath()}"></option>
|
|
||||||
</select>
|
</select>
|
||||||
|
<span class="form-hint">선택하면 reasoning 실행 중 10분 토큰을 발행하고 완료 즉시 회수합니다.</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Limit
|
Limit
|
||||||
@@ -98,19 +48,19 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="question-presets span-2" aria-label="질문 예시">
|
<div class="question-presets span-2" aria-label="질문 예시">
|
||||||
<button class="btn rw-btn-secondary question-preset" type="button"
|
<button class="btn rw-btn-secondary question-preset" type="button"
|
||||||
data-question="요약부터 작성해줘. 이 토큰으로 조회 가능한 행 수, 주요 식별자, NULL 처리 여부, 권한 범위를 표로 정리하고 상세 근거를 이어서 설명해줘.">
|
data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 이 토큰이 볼 수 있는 기술 태그 청크 수와 VPD가 제외한 범위를 요약해줘.">
|
||||||
기본 분석
|
기본 분석
|
||||||
</button>
|
</button>
|
||||||
<button class="btn rw-btn-secondary question-preset" type="button"
|
<button class="btn rw-btn-secondary question-preset" type="button"
|
||||||
data-question="반환된 컬럼과 maskedColumns를 기준으로 민감 컬럼이 NULL 처리 또는 미노출됐는지 먼저 요약하고, role/permission 설정에서 확인할 항목을 정리해줘.">
|
data-question="SPRING_BOOT와 ORACLE_VPD 태그를 여러 개 허용한 권한이 OR로 적용됐는지, 컬럼 표시 보호와 구분해 설명해줘.">
|
||||||
민감 컬럼 점검
|
민감 컬럼 점검
|
||||||
</button>
|
</button>
|
||||||
<button class="btn rw-btn-secondary question-preset" type="button"
|
<button class="btn rw-btn-secondary question-preset" type="button"
|
||||||
data-question="반환된 행 수와 행의 부서/사번 값을 근거로 이 토큰이 전체 조회, 부서 제한, 본인 제한, 조건 제한 중 어디에 가까운지 판단해줘. 추정이면 추정이라고 표시해줘.">
|
data-question="반환된 벡터 청크의 기술 태그와 권한 규칙을 근거로 어떤 지식 범위를 조회할 수 있는지 판단해줘. 추정이면 추정이라고 표시해줘.">
|
||||||
권한 범위 판단
|
태그 권한 범위 판단
|
||||||
</button>
|
</button>
|
||||||
<button class="btn rw-btn-secondary question-preset" type="button"
|
<button class="btn rw-btn-secondary question-preset" type="button"
|
||||||
data-question="운영자가 확인해야 할 이상 징후를 먼저 bullet로 요약하고, ORDS path, VPD policy, permission rule, token 상태 중 어디를 봐야 하는지 제시해줘.">
|
data-question="ORDS path, VPD policy, TAG permission, token 상태 중 이상 징후를 먼저 bullet로 요약하고 다음 확인 순서를 제시해줘.">
|
||||||
운영 점검 요약
|
운영 점검 요약
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,34 +70,26 @@
|
|||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2>조회 가능 대상</h2>
|
<h2>사용 가능한 MCP Tool (사전 선택 없음)</h2>
|
||||||
<span class="badge text-bg-secondary" th:text="${#lists.size(tools)}">0</span>
|
<span class="badge text-bg-secondary" th:text="${#lists.size(tools)}">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-middle">
|
<table class="table table-sm align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>선택</th>
|
|
||||||
<th>Object</th>
|
|
||||||
<th>ORDS Path</th>
|
|
||||||
<th>MCP Tool</th>
|
<th>MCP Tool</th>
|
||||||
|
<th>ORDS Path</th>
|
||||||
|
<th>설명</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="tool : ${tools}">
|
<tr th:each="tool : ${tools}">
|
||||||
<td>
|
<td><code th:text="${tool.name()}">ords.query.admin.board_posts</code></td>
|
||||||
<button class="btn btn-sm rw-btn-secondary reasoning-object-preset"
|
|
||||||
type="button"
|
|
||||||
th:attr="data-object-id=${tool.objectId()},data-question=${tool.displayName() + '에서 이 토큰으로 조회 가능한 행, NULL 처리 컬럼, 권한 범위를 요약 먼저 표로 정리해줘.'}">
|
|
||||||
선택
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td th:text="${tool.displayName()}">ADMIN.TABLE</td>
|
|
||||||
<td><code th:text="${tool.ordsPath()}">path</code></td>
|
<td><code th:text="${tool.ordsPath()}">path</code></td>
|
||||||
<td><code th:text="${tool.name()}">tool</code></td>
|
<td th:text="${tool.description()}">VPD/ORDS 조회 도구</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr th:if="${#lists.isEmpty(tools)}">
|
<tr th:if="${#lists.isEmpty(tools)}">
|
||||||
<td colspan="4" class="text-muted">등록된 보호 객체 도구가 없습니다.</td>
|
<td colspan="3" class="text-muted">등록된 보호 객체 도구가 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -167,15 +109,6 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.reasoning-object-preset').forEach((button) => {
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
const objectSelect = document.getElementById('mcp-reasoning-object');
|
|
||||||
const question = document.getElementById('mcp-reasoning-question');
|
|
||||||
objectSelect.value = button.dataset.objectId || objectSelect.value;
|
|
||||||
question.value = button.dataset.question || question.value;
|
|
||||||
question.focus();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Object</th>
|
<th>Object</th>
|
||||||
<th>ORDS Path</th>
|
<th>ORDS Path</th>
|
||||||
|
<th>Instruction / parameter mapping</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -89,9 +90,18 @@
|
|||||||
<td><code th:text="${tool.name()}">ords.query.admin.board_posts</code></td>
|
<td><code th:text="${tool.name()}">ords.query.admin.board_posts</code></td>
|
||||||
<td th:text="${tool.displayName()}">ADMIN.BOARD_POSTS</td>
|
<td th:text="${tool.displayName()}">ADMIN.BOARD_POSTS</td>
|
||||||
<td><code th:text="${tool.ordsPath()}">cb-ords/cb-object-query/admin/board_posts</code></td>
|
<td><code th:text="${tool.ordsPath()}">cb-ords/cb-object-query/admin/board_posts</code></td>
|
||||||
|
<td>
|
||||||
|
<div th:text="${tool.description()}">VPD/ORDS 조회 도구 설명</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
<code>bearerToken</code> → Authorization · <code>limit</code> → ORDS limit · object → 이 도구에 고정
|
||||||
|
<span th:if="${tool.displayName().toUpperCase().endsWith('CB_VECTOR_SEARCH_DOCUMENTS')}">
|
||||||
|
· <code>embedding[]</code> → 검색 JSON 본문
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr th:if="${#lists.isEmpty(tools)}">
|
<tr th:if="${#lists.isEmpty(tools)}">
|
||||||
<td colspan="3" class="text-muted">등록된 MCP tool이 없습니다.</td>
|
<td colspan="4" class="text-muted">등록된 MCP tool이 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -104,6 +114,7 @@
|
|||||||
"bearerToken": "vpd_live_xxx",
|
"bearerToken": "vpd_live_xxx",
|
||||||
"limit": 50
|
"limit": 50
|
||||||
}</pre>
|
}</pre>
|
||||||
|
<p class="form-hint">벡터 검색 tool인 <code>CB_VECTOR_SEARCH_DOCUMENTS</code>는 여기에 외부 임베딩 모델이 만든 <code>embedding</code> 숫자 배열을 추가합니다. 토큰·limit은 모든 tool에 공통이고, embedding은 벡터 tool에만 필요합니다.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
|
|||||||
@@ -5,8 +5,15 @@
|
|||||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<h1>ORDS 조회 Handler 생성</h1>
|
<h1>ORDS 조회 대상 등록</h1>
|
||||||
<p>VPD가 적용된 TABLE/VIEW를 ORDS HTTP 경로로 서빙하기 위한 조회 Handler를 등록합니다.</p>
|
<p>이 화면은 DB 객체를 HTTP로 연결할 경로만 등록합니다. 누가 어떤 행을 볼 수 있는지는 권한 규칙과 VPD에서 결정합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>벡터 지식자료 검색 흐름:</strong>
|
||||||
|
문서를 청크로 나누고 기술 태그를 붙인 뒤 전용 ORDS 검색을 등록합니다.
|
||||||
|
그 다음 <a href="/permissions">권한 관리</a>에서 <code>특정 기술 태그</code>를 여러 개 추가하면
|
||||||
|
태그 중 하나라도 맞는 청크만 검색됩니다.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section>
|
<section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section>
|
||||||
@@ -15,7 +22,8 @@
|
|||||||
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<h2>조회 Handler 대상 추가</h2>
|
<h2>조회 대상 추가 (예제 Handler)</h2>
|
||||||
|
<p class="section-subtitle">DB 객체 하나를 하나의 조회 Handler로 연결하는 예제입니다. 대상 추가 후 Handler를 생성하면 VPD context 설정과 기본 SELECT가 포함된 PL/SQL이 만들어지며, 소스 보기에서 실제 업무에 맞게 수정할 수 있습니다.</p>
|
||||||
<form method="post" action="/objects" class="form-grid">
|
<form method="post" action="/objects" class="form-grid">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<input type="hidden" name="owner" required>
|
<input type="hidden" name="owner" required>
|
||||||
@@ -32,15 +40,40 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
ORDS Path
|
ORDS Path (자동)
|
||||||
<input class="form-control" name="ordsPath" placeholder="DB 객체 선택 시 자동 입력" required>
|
<input class="form-control" name="ordsPath" data-ords-path-input
|
||||||
|
placeholder="DB 객체 선택 시 자동 입력" readonly required>
|
||||||
|
<span class="form-hint">
|
||||||
|
객체를 선택하면 예제용 module/template 경로가 자동으로 입력됩니다. 비워 제출해도 서버가 같은 규칙으로 채웁니다.
|
||||||
|
<label class="form-check form-check-inline ms-2">
|
||||||
|
<input class="form-check-input" type="checkbox" data-ords-path-edit>
|
||||||
|
<span class="form-check-label">고급: 직접 수정</span>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
조회 대상 설명
|
||||||
|
<input class="form-control" name="description" maxlength="200"
|
||||||
|
placeholder="예: 기술 태그 권한이 적용된 지식자료 검색">
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-primary" type="submit">대상 추가</button>
|
<button class="btn btn-primary" type="submit">대상 추가</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="alert alert-info mt-3 mb-0">
|
||||||
|
<strong>생성되는 예제 흐름:</strong>
|
||||||
|
<code>cb_ords_handler_pkg.set_vpd_context(:auth_header)</code>로 토큰의 사용자·역할 컨텍스트를 넣고,
|
||||||
|
선택한 한 테이블에 <code>SELECT ... FROM OWNER.TABLE</code>을 실행한 뒤 JSON으로 반환합니다.
|
||||||
|
이것은 유일한 사용 방식이 아니라 시작점이며, Handler 소스와 ORDS 메타데이터에서 수정할 수 있습니다.
|
||||||
|
컬럼 민감도·마스킹은 이 화면에서 다루지 않고 <a href="/permissions">권한 관리의 원문 표시 허용 컬럼</a>에서 별도로 설정합니다.
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<h2>조회 Handler 대상 목록</h2>
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>조회 대상 목록</h2>
|
||||||
|
<p class="section-subtitle">ORDS Path와 Handler 상태를 관리합니다. 접근 권한은 이 표에서 만들지 않습니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-middle">
|
<table class="table table-sm align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -48,6 +81,7 @@
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Owner</th>
|
<th>Owner</th>
|
||||||
<th>Object</th>
|
<th>Object</th>
|
||||||
|
<th>설명</th>
|
||||||
<th>ORDS Path</th>
|
<th>ORDS Path</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -58,6 +92,16 @@
|
|||||||
<td th:text="${object.objectId()}">1</td>
|
<td th:text="${object.objectId()}">1</td>
|
||||||
<td th:text="${object.owner()}">ADMIN</td>
|
<td th:text="${object.owner()}">ADMIN</td>
|
||||||
<td th:text="${object.objectName()}">CB_V_SEARCH_DOCUMENTS</td>
|
<td th:text="${object.objectName()}">CB_V_SEARCH_DOCUMENTS</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/objects/description" class="inline-form">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<input type="hidden" name="objectId" th:value="${object.objectId()}">
|
||||||
|
<input class="form-control form-control-sm" name="description" maxlength="200"
|
||||||
|
th:value="${object.description()}" placeholder="짧은 설명">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="submit">저장</button>
|
||||||
|
</form>
|
||||||
|
<small class="text-muted" th:text="${object.descriptionOrDefault()}">조회 대상 설명</small>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/objects/ords-path" class="inline-form">
|
<form method="post" action="/objects/ords-path" class="inline-form">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
@@ -67,7 +111,10 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="action-stack">
|
<div class="action-stack" th:if="${object.objectName() == 'CB_VECTOR_SEARCH_DOCUMENTS'}">
|
||||||
|
<span class="text-info small">전용 벡터 Handler 사용<br>(SQL 29, EMBEDDING 미노출)</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-stack" th:unless="${object.objectName() == 'CB_VECTOR_SEARCH_DOCUMENTS'}">
|
||||||
<form method="post" action="/objects/ords-handler" class="inline-form">
|
<form method="post" action="/objects/ords-handler" class="inline-form">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<input type="hidden" name="objectId" th:value="${object.objectId()}">
|
<input type="hidden" name="objectId" th:value="${object.objectId()}">
|
||||||
@@ -87,74 +134,24 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="policy-source-cell">
|
<td colspan="6" class="policy-source-cell">
|
||||||
<details class="column-policy-panel">
|
<div class="object-handler-explainer">
|
||||||
<summary>기본 컬럼 민감도/마스킹 정책</summary>
|
<strong>이 대상에서 자동으로 연결되는 것</strong>
|
||||||
<div class="table-responsive">
|
<span>토큰 헤더 → VPD context 설정 → 선택한 단일 테이블의 기본 SELECT → JSON 응답</span>
|
||||||
<table class="table table-sm align-middle">
|
<small>행 접근은 권한 규칙/VPD가 담당합니다. 원문 표시 예외는 권한 관리 Step 4에서 여러 컬럼을 등록하세요.</small>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Column</th>
|
|
||||||
<th>민감도</th>
|
|
||||||
<th>마스킹</th>
|
|
||||||
<th>상태</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr th:each="column : ${columnsByObject[object.objectId()]}">
|
|
||||||
<td>
|
|
||||||
<span th:text="${column.columnName()}">CONTENTS</span>
|
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"
|
|
||||||
th:form="${'column-policy-form-' + column.columnId()}">
|
|
||||||
<input type="hidden" name="columnId" th:value="${column.columnId()}"
|
|
||||||
th:form="${'column-policy-form-' + column.columnId()}">
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select class="form-select form-select-sm" name="sensitivityLevel"
|
|
||||||
th:form="${'column-policy-form-' + column.columnId()}">
|
|
||||||
<option value="PUBLIC" th:selected="${column.sensitivityLevel() == 'PUBLIC'}">PUBLIC</option>
|
|
||||||
<option value="INTERNAL" th:selected="${column.sensitivityLevel() == 'INTERNAL'}">INTERNAL</option>
|
|
||||||
<option value="CONFIDENTIAL" th:selected="${column.sensitivityLevel() == 'CONFIDENTIAL'}">CONFIDENTIAL</option>
|
|
||||||
<option value="RESTRICTED" th:selected="${column.sensitivityLevel() == 'RESTRICTED'}">RESTRICTED</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select class="form-select form-select-sm" name="redactionMethod"
|
|
||||||
th:form="${'column-policy-form-' + column.columnId()}">
|
|
||||||
<option value="NONE" th:selected="${column.redactionMethod() == 'NONE'}">NONE</option>
|
|
||||||
<option value="NULLIFY" th:selected="${column.redactionMethod() == 'NULLIFY'}">NULLIFY</option>
|
|
||||||
<option value="PARTIAL" th:selected="${column.redactionMethod() == 'PARTIAL'}">PARTIAL</option>
|
|
||||||
<option value="FULL" th:selected="${column.redactionMethod() == 'FULL'}">FULL</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge"
|
|
||||||
th:classappend="${column.sensitive()} ? ' text-bg-warning' : ' text-bg-secondary'"
|
|
||||||
th:text="${column.policyLabel()}">PUBLIC/NONE</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<form method="post" action="/objects/column-policy"
|
|
||||||
th:id="${'column-policy-form-' + column.columnId()}"></form>
|
|
||||||
<button class="btn btn-sm btn-outline-primary" type="submit"
|
|
||||||
th:form="${'column-policy-form-' + column.columnId()}">저장</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr th:if="${#lists.isEmpty(columnsByObject[object.objectId()])}">
|
|
||||||
<td colspan="5" class="text-muted">등록된 컬럼 정책이 없습니다.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
<div th:if="${object.objectName() != 'CB_VECTOR_SEARCH_DOCUMENTS'}"
|
||||||
<div th:id="${'handler-source-' + object.objectId()}" class="text-muted small">
|
th:id="${'handler-source-' + object.objectId()}" class="text-muted small">
|
||||||
소스 보기를 누르면 기본 Handler PL/SQL이 표시됩니다.
|
소스 보기를 누르면 기본 Handler PL/SQL이 표시됩니다.
|
||||||
</div>
|
</div>
|
||||||
|
<div th:if="${object.objectName() == 'CB_VECTOR_SEARCH_DOCUMENTS'}" class="text-muted small">
|
||||||
|
전용 벡터 검색 Handler는 SQL 29에서 등록합니다.
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</th:block>
|
</th:block>
|
||||||
<tr th:if="${#lists.isEmpty(objects)}">
|
<tr th:if="${#lists.isEmpty(objects)}">
|
||||||
<td colspan="5" class="text-muted">등록된 조회 Handler 대상이 없습니다.</td>
|
<td colspan="6" class="text-muted">등록된 조회 Handler 대상이 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
data-group-users=${#strings.listJoin(groupUsersByRole[role.roleId()], '|')}"></option>
|
data-group-users=${#strings.listJoin(groupUsersByRole[role.roleId()], '|')}"></option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<p class="wizard-hint">선택한 역할의 민감도 허용 상한도 preview에 반영됩니다.</p>
|
<p class="wizard-hint">선택한 역할의 표시 보호 등급 상한(참고)도 preview에 반영됩니다. 행을 볼 수 있는 권한과는 별개입니다.</p>
|
||||||
<div class="effective-preview">
|
<div class="effective-preview">
|
||||||
<div class="field-block-title">선택 역할 영향도</div>
|
<div class="field-block-title">선택 역할 영향도</div>
|
||||||
<dl>
|
<dl>
|
||||||
@@ -107,10 +107,10 @@
|
|||||||
</label>
|
</label>
|
||||||
<p class="wizard-hint">DB 스키마 객체를 선택하면 저장 시 보호 객체가 자동 등록됩니다.</p>
|
<p class="wizard-hint">DB 스키마 객체를 선택하면 저장 시 보호 객체가 자동 등록됩니다.</p>
|
||||||
<div class="effective-preview">
|
<div class="effective-preview">
|
||||||
<div class="field-block-title">선택 객체 컬럼</div>
|
<div class="field-block-title">선택 객체 컬럼 (표시 보호 참고)</div>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>전체 컬럼</dt><dd data-preview="objectColumns">-</dd></div>
|
<div><dt>전체 컬럼</dt><dd data-preview="objectColumns">-</dd></div>
|
||||||
<div><dt>마스킹 대상</dt><dd data-preview="maskableColumns">-</dd></div>
|
<div><dt>표시 보호 대상</dt><dd data-preview="maskableColumns">-</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,6 +147,7 @@
|
|||||||
<option value="SELF">현재 사용자 사번</option>
|
<option value="SELF">현재 사용자 사번</option>
|
||||||
<option value="DEPT">지정 부서</option>
|
<option value="DEPT">지정 부서</option>
|
||||||
<option value="EMP_NO">지정 사번</option>
|
<option value="EMP_NO">지정 사번</option>
|
||||||
|
<option value="TAG">특정 기술 태그</option>
|
||||||
<option value="=">=</option>
|
<option value="=">=</option>
|
||||||
<option value="!=">!=</option>
|
<option value="!=">!=</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
<button class="btn btn-outline-secondary" type="button" data-rule-add>추가</button>
|
<button class="btn btn-outline-secondary" type="button" data-rule-add>추가</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="wizard-hint">특정 기술 태그는 기본적으로 <code>TECH_TAG</code> 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회되고, DENY 태그는 허용 결과에서 제외됩니다. 객체에 TECH_TAG가 없으면 실제 태그 컬럼을 직접 지정하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,21 +163,22 @@
|
|||||||
<div class="wizard-panel-heading">
|
<div class="wizard-panel-heading">
|
||||||
<span class="wizard-step-number">4</span>
|
<span class="wizard-step-number">4</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>권한별 컬럼 마스킹</h3>
|
<h3>권한별 원문 표시 예외</h3>
|
||||||
<p>이 역할이 이 테이블/뷰를 조회할 때 원문 표시를 허용할 마스킹 컬럼을 선택합니다.</p>
|
<p>이 역할이 이미 볼 수 있는 행에서 마스킹을 제외할 컬럼을 여러 개 선택합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="masking-column-picker">
|
<div class="masking-column-picker">
|
||||||
<div class="field-block-title">선택 가능한 마스킹 컬럼</div>
|
<div class="field-block-title">선택 가능한 표시 보호 컬럼</div>
|
||||||
<div class="question-presets" data-maskable-column-list>
|
<div class="question-presets" data-maskable-column-list>
|
||||||
<span class="text-muted small">보호 객체를 선택하면 마스킹 대상 컬럼이 표시됩니다.</span>
|
<span class="text-muted small">보호 객체를 선택하면 마스킹 대상 컬럼이 표시됩니다.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
원문 표시 허용 컬럼
|
원문 표시 허용(마스킹 제외) 컬럼 · 여러 개 가능
|
||||||
<input class="form-control" name="visibleColumns" placeholder="CONTENTS">
|
<input class="form-control" name="visibleColumns" placeholder="예: CONTENTS, SOURCE_URI">
|
||||||
|
<span class="selected-column-list" data-selected-visible-columns aria-live="polite"></span>
|
||||||
</label>
|
</label>
|
||||||
<p class="wizard-hint">객체의 기본 민감도/마스킹 정책은 ORDS 조회 Handler 대상에서 관리하고, 여기서는 역할 권한에 붙일 원문 표시 예외만 정합니다.</p>
|
<p class="wizard-hint">쉼표로 여러 컬럼을 등록하거나 위 버튼을 여러 번 누르세요. 등록하지 않은 민감 컬럼은 기본 표시 보호를 유지합니다. 행을 볼 수 있는지는 이 목록이 아니라 권한 규칙과 VPD가 결정합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wizard-panel" data-wizard-step="5">
|
<div class="wizard-panel" data-wizard-step="5">
|
||||||
@@ -189,7 +192,7 @@
|
|||||||
<div class="policy-preview" data-policy-preview>
|
<div class="policy-preview" data-policy-preview>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>역할</dt><dd data-preview="role">-</dd></div>
|
<div><dt>역할</dt><dd data-preview="role">-</dd></div>
|
||||||
<div><dt>민감도 상한</dt><dd data-preview="sensitivity">-</dd></div>
|
<div><dt>표시 보호 등급 상한(참고)</dt><dd data-preview="sensitivity">-</dd></div>
|
||||||
<div><dt>보호 객체</dt><dd data-preview="object">-</dd></div>
|
<div><dt>보호 객체</dt><dd data-preview="object">-</dd></div>
|
||||||
<div><dt>적용 대상</dt><dd data-preview="affectedPrincipals">-</dd></div>
|
<div><dt>적용 대상</dt><dd data-preview="affectedPrincipals">-</dd></div>
|
||||||
<div><dt>객체 컬럼</dt><dd data-preview="objectColumnsFinal">-</dd></div>
|
<div><dt>객체 컬럼</dt><dd data-preview="objectColumnsFinal">-</dd></div>
|
||||||
@@ -222,7 +225,7 @@
|
|||||||
<th>Effect</th>
|
<th>Effect</th>
|
||||||
<th>행 규칙</th>
|
<th>행 규칙</th>
|
||||||
<th>적용 필터</th>
|
<th>적용 필터</th>
|
||||||
<th>원문 표시 허용 컬럼</th>
|
<th>원문 표시 허용 컬럼(여러 개)</th>
|
||||||
<th>삭제 영향</th>
|
<th>삭제 영향</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -32,23 +32,38 @@
|
|||||||
<label class="span-2">
|
<label class="span-2">
|
||||||
1. 발급받은 토큰 원문
|
1. 발급받은 토큰 원문
|
||||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off"
|
<input class="form-control" name="bearerToken" type="password" autocomplete="off"
|
||||||
placeholder="토큰 발급 직후 복사한 값을 붙여 넣으세요" required>
|
placeholder="토큰 발급 직후 복사한 값을 붙여 넣으세요">
|
||||||
<span class="form-hint">원문은 DB에 저장되지 않습니다. 목록에 보이는 prefix만으로는 검증할 수 없으며, 원문을 잃었다면 새 토큰을 발급해야 합니다.</span>
|
<span class="form-hint">원문은 DB에 저장되지 않습니다. 아래 임시 토큰 사용자를 선택하면 이 입력은 무시되고 실행 중 발급·완료 즉시 회수됩니다. 둘 다 비우면 invalid token 결과를 확인할 수 있습니다.</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
임시 토큰 사용자 (선택)
|
||||||
|
<select class="form-select" name="tempUserId">
|
||||||
|
<option value="">원문 토큰 사용</option>
|
||||||
|
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
|
||||||
|
</select>
|
||||||
|
<span class="form-hint">ORDS 호출에만 사용할 10분 토큰을 만들고 테스트가 끝나면 바로 회수합니다.</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
2. 확인할 데이터
|
2. 확인할 데이터
|
||||||
<select class="form-select" name="objectId" required>
|
<select class="form-select" name="objectId" required>
|
||||||
<option th:each="object : ${objects}"
|
<option th:each="object : ${objects}"
|
||||||
th:value="${object.objectId()}"
|
th:value="${object.objectId()}"
|
||||||
|
th:attr="data-vector-search=${object.objectName() == 'CB_VECTOR_SEARCH_DOCUMENTS'},data-description=${object.descriptionOrDefault()}"
|
||||||
th:text="${object.displayName() + (defaultObjectKeys.contains(object.displayName()) ? ' · 권한체계 자동 (권장)' : ' · 별도 Filter (고급 점검 필요)')}"></option>
|
th:text="${object.displayName() + (defaultObjectKeys.contains(object.displayName()) ? ' · 권한체계 자동 (권장)' : ' · 별도 Filter (고급 점검 필요)')}"></option>
|
||||||
</select>
|
</select>
|
||||||
<span class="form-hint">이 객체에 저장한 권한 규칙과 실제 반환 행을 비교합니다.</span>
|
<span class="form-hint" data-probe-object-description>선택한 대상의 설명이 여기에 표시됩니다.</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
최대 확인 행 수
|
최대 확인 행 수
|
||||||
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
|
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
|
||||||
<span class="form-hint">권한 판정에는 영향을 주지 않고 화면에 가져올 최대 행만 제한합니다.</span>
|
<span class="form-hint">권한 판정에는 영향을 주지 않고 화면에 가져올 최대 행만 제한합니다.</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="span-2 vector-probe-input" data-vector-probe-input hidden>
|
||||||
|
벡터 검색 요청 본문 (JSON)
|
||||||
|
<textarea class="form-control" name="requestBody" rows="3" disabled
|
||||||
|
placeholder='{"embedding":[0.10,0.20,0.30,0.40]}'></textarea>
|
||||||
|
<span class="form-hint">벡터 검색 객체를 선택했을 때만 필요합니다. 검색어를 외부 임베딩 모델로 바꾼 배열을 넣습니다.</span>
|
||||||
|
</label>
|
||||||
<button class="btn rw-btn-primary probe-submit" type="submit">3. 권한 결과 확인</button>
|
<button class="btn rw-btn-primary probe-submit" type="submit">3. 권한 결과 확인</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<h1>역할 관리</h1>
|
<h1>역할 관리</h1>
|
||||||
<p>사용자에게 부여할 역할을 관리합니다.</p>
|
<p>사용자에게 부여할 역할을 관리합니다. 아래 등급은 허용된 행의 컬럼 표시 보호 상한을 기록하는 참고값이며, 행 접근 권한은 권한 규칙과 VPD에서 결정합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
||||||
@@ -25,13 +25,14 @@
|
|||||||
<input class="form-control" name="description" maxlength="200">
|
<input class="form-control" name="description" maxlength="200">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
민감도 허용 상한
|
표시 보호 등급 상한 (참고)
|
||||||
<select class="form-select" name="maxSensitivityLevel">
|
<select class="form-select" name="maxSensitivityLevel">
|
||||||
<option value="PUBLIC">PUBLIC</option>
|
<option value="PUBLIC">PUBLIC</option>
|
||||||
<option value="INTERNAL">INTERNAL</option>
|
<option value="INTERNAL">INTERNAL</option>
|
||||||
<option value="CONFIDENTIAL">CONFIDENTIAL</option>
|
<option value="CONFIDENTIAL">CONFIDENTIAL</option>
|
||||||
<option value="RESTRICTED">RESTRICTED</option>
|
<option value="RESTRICTED">RESTRICTED</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span class="form-hint">이 역할이 허용된 행에서 원문으로 볼 수 있는 컬럼 등급의 참고 상한입니다. 행을 볼 수 있는지는 권한 규칙과 VPD가 결정하며, 이 값은 행 권한을 부여하지 않습니다.</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn rw-btn-primary" type="submit">추가</button>
|
<button class="btn rw-btn-primary" type="submit">추가</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>역할명</th>
|
<th>역할명</th>
|
||||||
<th>민감도 허용 상한</th>
|
<th>표시 보호 등급 상한</th>
|
||||||
<th>삭제 영향</th>
|
<th>삭제 영향</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -57,6 +58,8 @@
|
|||||||
<td th:text="${role.roleId()}">10</td>
|
<td th:text="${role.roleId()}">10</td>
|
||||||
<td th:text="${role.roleName()}">HR_DEPT_ROLE</td>
|
<td th:text="${role.roleName()}">HR_DEPT_ROLE</td>
|
||||||
<td>
|
<td>
|
||||||
|
<span class="badge text-bg-light" th:title="'허용된 행의 컬럼 표시 상한: ' + role.maxSensitivityLevel()"
|
||||||
|
th:text="${role.maxSensitivityLevel()}">PUBLIC</span>
|
||||||
<form method="post" action="/roles/max-sensitivity" class="inline-form">
|
<form method="post" action="/roles/max-sensitivity" class="inline-form">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<input type="hidden" name="roleId" th:value="${role.roleId()}">
|
<input type="hidden" name="roleId" th:value="${role.roleId()}">
|
||||||
@@ -68,6 +71,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button class="btn btn-sm btn-outline-primary" type="submit">저장</button>
|
<button class="btn btn-sm btn-outline-primary" type="submit">저장</button>
|
||||||
</form>
|
</form>
|
||||||
|
<small class="text-muted d-block mt-1">행 접근 권한과 별개인 표시 보호 상한</small>
|
||||||
</td>
|
</td>
|
||||||
<td class="delete-impact">
|
<td class="delete-impact">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -54,9 +54,19 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>발급 이력</h2>
|
<h2>발급 이력</h2>
|
||||||
<p class="section-subtitle">보안상 prefix와 상태만 보관합니다. 이 목록에서는 원문을 복사하거나 복구할 수 없습니다.</p>
|
<p class="section-subtitle">보안상 prefix와 상태만 보관합니다. 기본 목록은 현재 사용 가능한 토큰만 보여주며, 회수·만료 이력은 필터를 켜서 확인합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="action-stack">
|
||||||
<span class="badge text-bg-secondary" th:text="${#lists.size(tokens)}">0</span>
|
<span class="badge text-bg-secondary" th:text="${#lists.size(tokens)}">0</span>
|
||||||
|
<form method="get" action="/tokens" class="inline-form">
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="includeInactive" value="true"
|
||||||
|
th:checked="${includeInactive}">
|
||||||
|
<span class="form-check-label">회수·만료 포함</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-sm rw-btn-secondary" type="submit">필터 적용</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-middle">
|
<table class="table table-sm align-middle">
|
||||||
|
|||||||
@@ -40,6 +40,9 @@
|
|||||||
<code>CB_AGENT_DOC_VPD_FILTER</code>
|
<code>CB_AGENT_DOC_VPD_FILTER</code>
|
||||||
<span>이 화면과 서버 양쪽에서 직접 덮어쓰기를 차단합니다.</span>
|
<span>이 화면과 서버 양쪽에서 직접 덮어쓰기를 차단합니다.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="alert alert-info mt-3 mb-0">
|
||||||
|
<strong>Filter 설명:</strong> 이 화면의 function은 DB가 행을 걸러낼 predicate를 반환합니다. 기본 함수는 사용자·그룹·역할·TAG 권한을 동적으로 읽고, 별도 Filter는 객체 고유 조건을 추가할 때만 사용합니다. 저장하는 설명에는 대상, 조건, 예상되는 허용/차단 결과를 적습니다.
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
@@ -72,11 +75,16 @@
|
|||||||
<label class="span-2">
|
<label class="span-2">
|
||||||
반환할 predicate
|
반환할 predicate
|
||||||
<textarea class="form-control" id="filter-only-predicate" name="filterPredicate" rows="4"
|
<textarea class="form-control" id="filter-only-predicate" name="filterPredicate" rows="4"
|
||||||
placeholder="예: tenant_id = SYS_CONTEXT('CB_AGENT_CTX', 'TENANT_ID')" required></textarea>
|
placeholder="예: REGEXP_LIKE(UPPER(TECH_TAG), '(^|,)SPRING_BOOT(,|$)')" required></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="span-2">
|
||||||
|
이 Filter가 보호하는 내용
|
||||||
|
<input class="form-control" name="description" maxlength="500"
|
||||||
|
placeholder="예: 지식자료 TECH_TAG가 권한에 포함된 청크만 허용" required>
|
||||||
</label>
|
</label>
|
||||||
<div class="question-presets span-2" aria-label="Filter predicate 안전 예시">
|
<div class="question-presets span-2" aria-label="Filter predicate 안전 예시">
|
||||||
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="1=0">안전한 기본 차단</button>
|
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="1=0">안전한 기본 차단</button>
|
||||||
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="dept_code = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')">컨텍스트 비교 예시</button>
|
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="REGEXP_LIKE(UPPER(TECH_TAG), '(^|,)SPRING_BOOT(,|$)')">기술 태그 비교 예시</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn rw-btn-primary" type="submit">별도 Filter 저장</button>
|
<button class="btn rw-btn-primary" type="submit">별도 Filter 저장</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -87,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-middle">
|
<table class="table table-sm align-middle">
|
||||||
<thead><tr><th>Function</th><th>구분</th><th>Source / 변경</th></tr></thead>
|
<thead><tr><th>Function</th><th>구분</th><th>설명</th><th>Source / 변경</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<th:block th:each="function, iter : ${formOptions.functions()}">
|
<th:block th:each="function, iter : ${formOptions.functions()}">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -96,6 +104,9 @@
|
|||||||
<span class="badge text-bg-primary" th:if="${function.permissionSystemDefault()}">권한체계 기본</span>
|
<span class="badge text-bg-primary" th:if="${function.permissionSystemDefault()}">권한체계 기본</span>
|
||||||
<span class="badge text-bg-warning" th:unless="${function.permissionSystemDefault()}">별도 Filter</span>
|
<span class="badge text-bg-warning" th:unless="${function.permissionSystemDefault()}">별도 Filter</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<span th:text="${filterDescriptions[function.owner() + '|' + function.functionName()]}">Filter 목적</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm rw-btn-secondary" type="button"
|
<button class="btn btn-sm rw-btn-secondary" type="button"
|
||||||
th:hx-get="@{/vpd-policies/function-source(owner=${function.owner()},packageName=${function.packageName()},functionName=${function.functionName()})}"
|
th:hx-get="@{/vpd-policies/function-source(owner=${function.owner()},packageName=${function.packageName()},functionName=${function.functionName()})}"
|
||||||
@@ -103,7 +114,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="policy-source-cell">
|
<td colspan="4" class="policy-source-cell">
|
||||||
<div th:id="${'filter-source-' + iter.index}" class="text-muted small">Source 보기를 누르면 현재 함수 내용을 표시합니다.</div>
|
<div th:id="${'filter-source-' + iter.index}" class="text-muted small">Source 보기를 누르면 현재 함수 내용을 표시합니다.</div>
|
||||||
<div class="alert alert-light mt-3 mb-0" th:if="${function.permissionSystemDefault()}">
|
<div class="alert alert-light mt-3 mb-0" th:if="${function.permissionSystemDefault()}">
|
||||||
이 함수는 권한체계의 핵심 실행 경로이므로 직접 수정할 수 없습니다. 권한 규칙을 변경하세요.
|
이 함수는 권한체계의 핵심 실행 경로이므로 직접 수정할 수 없습니다. 권한 규칙을 변경하세요.
|
||||||
@@ -115,14 +126,20 @@
|
|||||||
<input type="hidden" name="functionName" th:value="${function.functionName()}">
|
<input type="hidden" name="functionName" th:value="${function.functionName()}">
|
||||||
<label>
|
<label>
|
||||||
이 별도 Filter의 새 predicate
|
이 별도 Filter의 새 predicate
|
||||||
<textarea class="form-control" name="filterPredicate" rows="3" required></textarea>
|
<textarea class="form-control" name="filterPredicate" rows="3" required
|
||||||
|
th:text="${filterPredicates[function.owner() + '|' + function.functionName()]}"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Filter 설명
|
||||||
|
<input class="form-control" name="description" maxlength="500"
|
||||||
|
th:value="${filterDescriptions[function.owner() + '|' + function.functionName()]}">
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-sm rw-btn-secondary" type="submit">별도 Filter 수정</button>
|
<button class="btn btn-sm rw-btn-secondary" type="submit">별도 Filter 수정</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</th:block>
|
</th:block>
|
||||||
<tr th:if="${#lists.isEmpty(formOptions.functions())}"><td colspan="3" class="text-muted">설치된 VPD 함수가 없습니다.</td></tr>
|
<tr th:if="${#lists.isEmpty(formOptions.functions())}"><td colspan="4" class="text-muted">설치된 VPD 함수가 없습니다.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
<span>허용된 데이터만 반환</span>
|
<span>허용된 데이터만 반환</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted mb-0"><code>CB_AGENT_DOC_VPD_FILTER</code>가 요청할 때마다 현재 권한체계를 읽습니다. 일상적인 권한 변경은 이 함수나 predicate가 아니라 <a href="/permissions">권한 규칙</a>에서 하세요.</p>
|
<p class="text-muted mb-0"><code>CB_AGENT_DOC_VPD_FILTER</code>가 요청할 때마다 현재 권한체계를 읽습니다. 일상적인 권한 변경은 이 함수나 predicate가 아니라 <a href="/permissions">권한 규칙</a>에서 하세요.</p>
|
||||||
|
<div class="alert alert-info mt-3 mb-0">
|
||||||
|
<strong>이 화면의 역할:</strong> policy는 “어느 객체의 어떤 SQL에 어떤 Filter function을 붙일지”를 정합니다. 행의 실제 허용/차단 조건은 권한 규칙과 Filter function이 계산하고, 컬럼 표시 보호는 별도 설정입니다.
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
@@ -113,6 +116,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Object</th>
|
<th>Object</th>
|
||||||
|
<th>대상 설명</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>VPD 상태</th>
|
<th>VPD 상태</th>
|
||||||
<th>Policy</th>
|
<th>Policy</th>
|
||||||
@@ -130,6 +134,7 @@
|
|||||||
data-vpd-applied=${target.vpdApplied()},
|
data-vpd-applied=${target.vpdApplied()},
|
||||||
data-protected-object=${target.protectedObject()}">
|
data-protected-object=${target.protectedObject()}">
|
||||||
<td><code th:text="${target.objectDisplayName()}">ADMIN.TABLE</code></td>
|
<td><code th:text="${target.objectDisplayName()}">ADMIN.TABLE</code></td>
|
||||||
|
<td th:text="${target.description()} ?: '설명 없음'">조회 대상 설명</td>
|
||||||
<td th:text="${target.objectType()}">TABLE</td>
|
<td th:text="${target.objectType()}">TABLE</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge"
|
<span class="badge"
|
||||||
@@ -166,7 +171,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr th:if="${target.vpdApplied()}" data-vpd-target-detail>
|
<tr th:if="${target.vpdApplied()}" data-vpd-target-detail>
|
||||||
<td colspan="7" class="policy-source-cell">
|
<td colspan="8" class="policy-source-cell">
|
||||||
<div th:id="${'target-filter-detail-' + iter.index}" class="text-muted small">
|
<div th:id="${'target-filter-detail-' + iter.index}" class="text-muted small">
|
||||||
Filter 이름을 클릭하면 policy/filter source가 표시됩니다.
|
Filter 이름을 클릭하면 policy/filter source가 표시됩니다.
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +179,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</th:block>
|
</th:block>
|
||||||
<tr th:if="${#lists.isEmpty(vpdTargets)}">
|
<tr th:if="${#lists.isEmpty(vpdTargets)}">
|
||||||
<td colspan="7" class="text-muted">
|
<td colspan="8" class="text-muted">
|
||||||
<span th:if="${selectedSchemaOwner != null && selectedSchemaOwner != ''}"
|
<span th:if="${selectedSchemaOwner != null && selectedSchemaOwner != ''}"
|
||||||
th:text="${selectedSchemaOwner + ' 스키마에서 현재 DB 연결 사용자로 조회 가능한 TABLE/VIEW가 없습니다. 스키마명, ALL_OBJECTS 조회 권한, 객체 권한을 확인하세요.'}">
|
th:text="${selectedSchemaOwner + ' 스키마에서 현재 DB 연결 사용자로 조회 가능한 TABLE/VIEW가 없습니다. 스키마명, ALL_OBJECTS 조회 권한, 객체 권한을 확인하세요.'}">
|
||||||
선택한 스키마에서 조회 가능한 TABLE/VIEW가 없습니다.
|
선택한 스키마에서 조회 가능한 TABLE/VIEW가 없습니다.
|
||||||
@@ -270,6 +275,7 @@
|
|||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Options</th>
|
<th>Options</th>
|
||||||
|
<th>이 policy가 하는 일</th>
|
||||||
<th>Explain</th>
|
<th>Explain</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -298,6 +304,9 @@
|
|||||||
<code th:text="${policy.functionDisplayName()}">OWNER.FUNC</code>
|
<code th:text="${policy.functionDisplayName()}">OWNER.FUNC</code>
|
||||||
</button>
|
</button>
|
||||||
<div class="text-muted small">클릭하면 filter source를 봅니다.</div>
|
<div class="text-muted small">클릭하면 filter source를 봅니다.</div>
|
||||||
|
<div class="small mt-1" th:text="${filterDescriptions[policy.functionOwner() + '|' + policy.functionName()]}">
|
||||||
|
Filter가 반환하는 predicate 설명
|
||||||
|
</div>
|
||||||
<span class="badge text-bg-primary" th:if="${policy.permissionSystemDefault()}">권한체계 자동</span>
|
<span class="badge text-bg-primary" th:if="${policy.permissionSystemDefault()}">권한체계 자동</span>
|
||||||
<span class="badge text-bg-warning" th:unless="${policy.permissionSystemDefault()}">별도 Filter</span>
|
<span class="badge text-bg-warning" th:unless="${policy.permissionSystemDefault()}">별도 Filter</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -313,6 +322,18 @@
|
|||||||
<span class="ms-2">Static: <strong th:text="${policy.staticPolicy()}">NO</strong></span>
|
<span class="ms-2">Static: <strong th:text="${policy.staticPolicy()}">NO</strong></span>
|
||||||
<span class="ms-2">Long: <strong th:text="${policy.longPredicate()}">NO</strong></span>
|
<span class="ms-2">Long: <strong th:text="${policy.longPredicate()}">NO</strong></span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/vpd-policies/description" class="inline-form">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<input type="hidden" name="objectOwner" th:value="${policy.objectOwner()}">
|
||||||
|
<input type="hidden" name="objectName" th:value="${policy.objectName()}">
|
||||||
|
<input type="hidden" name="policyName" th:value="${policy.policyName()}">
|
||||||
|
<input class="form-control form-control-sm" name="description" maxlength="500"
|
||||||
|
th:value="${policyDescriptions[policy.objectDisplayName() + '|' + policy.policyName()]}"
|
||||||
|
placeholder="대상과 적용 목적을 짧게 설명">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="submit">저장</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="action-stack">
|
<div class="action-stack">
|
||||||
<button class="btn btn-sm rw-btn-primary"
|
<button class="btn btn-sm rw-btn-primary"
|
||||||
@@ -331,7 +352,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="policy-source-cell">
|
<td colspan="9" class="policy-source-cell">
|
||||||
<div th:id="${'policy-source-' + iter.index}" class="text-muted small">
|
<div th:id="${'policy-source-' + iter.index}" class="text-muted small">
|
||||||
Policy, Filter, LLM 설명을 클릭하면 상세 내역이 표시됩니다.
|
Policy, Filter, LLM 설명을 클릭하면 상세 내역이 표시됩니다.
|
||||||
</div>
|
</div>
|
||||||
@@ -339,7 +360,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</th:block>
|
</th:block>
|
||||||
<tr th:if="${#lists.isEmpty(policies)}">
|
<tr th:if="${#lists.isEmpty(policies)}">
|
||||||
<td colspan="8" class="text-muted">
|
<td colspan="9" class="text-muted">
|
||||||
등록된 TABLE/VIEW에 적용된 VPD policy가 없습니다. 보호 객체 등록 상태와 DB policy 적용 상태를 확인하세요.
|
등록된 TABLE/VIEW에 적용된 VPD policy가 없습니다. 보호 객체 등록 상태와 DB policy 적용 상태를 확인하세요.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.domain.protectedobject;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class ProtectedColumnTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void explainsPublicColumnAsDisplayDefaultWithoutCallingItAccessPermission() {
|
||||||
|
ProtectedColumn column = new ProtectedColumn(1L, 1L, "TITLE", "N", null, "PUBLIC", "NONE");
|
||||||
|
|
||||||
|
assertThat(column.sensitive()).isFalse();
|
||||||
|
assertThat(column.sensitivityLabel()).isEqualTo("기본 표시");
|
||||||
|
assertThat(column.redactionLabel()).isEqualTo("마스킹 없음");
|
||||||
|
assertThat(column.displayPolicyLabel()).isEqualTo("기본 표시 · 마스킹 없음");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void explainsProtectedColumnAsPostAccessDisplayHandling() {
|
||||||
|
ProtectedColumn column = new ProtectedColumn(2L, 1L, "EMAIL", "Y", null, "CONFIDENTIAL", "NULLIFY");
|
||||||
|
|
||||||
|
assertThat(column.sensitive()).isTrue();
|
||||||
|
assertThat(column.displayPolicyLabel()).isEqualTo("기밀 · 값 숨김(NULL)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@ class PermissionServiceTest {
|
|||||||
public List<ProtectedColumn> findColumns(long objectId) {
|
public List<ProtectedColumn> findColumns(long objectId) {
|
||||||
return List.of(
|
return List.of(
|
||||||
new ProtectedColumn(1L, 1L, "DEPT_CODE", "N", null, "INTERNAL", "NONE"),
|
new ProtectedColumn(1L, 1L, "DEPT_CODE", "N", null, "INTERNAL", "NONE"),
|
||||||
new ProtectedColumn(2L, 1L, "OWNER_EMP_NO", "N", null, "INTERNAL", "NONE")
|
new ProtectedColumn(2L, 1L, "OWNER_EMP_NO", "N", null, "INTERNAL", "NONE"),
|
||||||
|
new ProtectedColumn(3L, 1L, "TECH_TAG", "N", null, "PUBLIC", "NONE")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -138,6 +139,83 @@ class PermissionServiceTest {
|
|||||||
.hasMessageContaining("값이 필요");
|
.hasMessageContaining("값이 필요");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsMultipleTagRulesAsOrCandidates() {
|
||||||
|
var command = new PermissionSetCommand(
|
||||||
|
10L,
|
||||||
|
1L,
|
||||||
|
"SELECT",
|
||||||
|
"ALLOW",
|
||||||
|
List.of(
|
||||||
|
new RuleCommand(null, "TAG", "spring_boot"),
|
||||||
|
new RuleCommand(null, "TAG", "oracle_vpd")
|
||||||
|
),
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
permissionService.savePermissionSet(command);
|
||||||
|
|
||||||
|
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||||
|
assertThat(mapper.insertedRules)
|
||||||
|
.extracting(PermissionRule::ruleType)
|
||||||
|
.containsExactly("TAG", "TAG");
|
||||||
|
assertThat(mapper.insertedRules)
|
||||||
|
.extracting(PermissionRule::ruleColumn)
|
||||||
|
.containsExactly(null, null);
|
||||||
|
assertThat(mapper.insertedRules)
|
||||||
|
.extracting(PermissionRule::ruleValue)
|
||||||
|
.containsExactly("SPRING_BOOT", "ORACLE_VPD");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsMultipleVisibleColumns() {
|
||||||
|
var command = new PermissionSetCommand(
|
||||||
|
10L,
|
||||||
|
1L,
|
||||||
|
"SELECT",
|
||||||
|
"ALLOW",
|
||||||
|
List.of(new RuleCommand("DEPT_CODE", "=", "HR")),
|
||||||
|
List.of("DEPT_CODE", "OWNER_EMP_NO")
|
||||||
|
);
|
||||||
|
|
||||||
|
permissionService.savePermissionSet(command);
|
||||||
|
|
||||||
|
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||||
|
assertThat(mapper.insertedVisibleColumns).containsExactly("DEPT_CODE", "OWNER_EMP_NO");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTagRuleWithoutValue() {
|
||||||
|
var command = new PermissionSetCommand(
|
||||||
|
10L,
|
||||||
|
1L,
|
||||||
|
"SELECT",
|
||||||
|
"ALLOW",
|
||||||
|
List.of(new RuleCommand(null, "TAG", "")),
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
|
||||||
|
.isInstanceOf(AppException.class)
|
||||||
|
.hasMessageContaining("값이 필요");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnsafeTagValue() {
|
||||||
|
var command = new PermissionSetCommand(
|
||||||
|
10L,
|
||||||
|
1L,
|
||||||
|
"SELECT",
|
||||||
|
"ALLOW",
|
||||||
|
List.of(new RuleCommand(null, "TAG", "SPRING' OR '1'='1")),
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
|
||||||
|
.isInstanceOf(AppException.class)
|
||||||
|
.hasMessageContaining("영문 대문자");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptsDenyPermissionEffect() {
|
void acceptsDenyPermissionEffect() {
|
||||||
var command = new PermissionSetCommand(
|
var command = new PermissionSetCommand(
|
||||||
@@ -199,6 +277,7 @@ class PermissionServiceTest {
|
|||||||
|
|
||||||
private static class FakePermissionMapper implements PermissionMapper {
|
private static class FakePermissionMapper implements PermissionMapper {
|
||||||
private final List<PermissionRule> insertedRules = new ArrayList<>();
|
private final List<PermissionRule> insertedRules = new ArrayList<>();
|
||||||
|
private final List<String> insertedVisibleColumns = new ArrayList<>();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AppRole> findRoles() {
|
public List<AppRole> findRoles() {
|
||||||
@@ -304,6 +383,7 @@ class PermissionServiceTest {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void insertVisibleColumn(long permissionId, String columnName) {
|
public void insertVisibleColumn(long permissionId, String columnName) {
|
||||||
|
insertedVisibleColumns.add(columnName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ class GuidedFlowTemplateTest {
|
|||||||
String probe = template("probe.html");
|
String probe = template("probe.html");
|
||||||
String result = template("fragments/probe-result.html");
|
String result = template("fragments/probe-result.html");
|
||||||
|
|
||||||
assertThat(probe).contains("name=\"bearerToken\"").doesNotContain("name=\"tokenKeyId\"");
|
assertThat(probe)
|
||||||
|
.contains("name=\"bearerToken\"")
|
||||||
|
.contains("임시 토큰 사용자")
|
||||||
|
.doesNotContain("name=\"tokenKeyId\"");
|
||||||
assertThat(result)
|
assertThat(result)
|
||||||
.contains("적용된 사용자와 권한")
|
.contains("적용된 사용자와 권한")
|
||||||
.contains("다음에 할 일")
|
.contains("다음에 할 일")
|
||||||
@@ -49,6 +52,63 @@ class GuidedFlowTemplateTest {
|
|||||||
.contains("CB_AGENT_DOC_VPD_FILTER");
|
.contains("CB_AGENT_DOC_VPD_FILTER");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ordsObjectPageSeparatesAccessFromColumnDisplayProtection() throws IOException {
|
||||||
|
String html = template("objects.html");
|
||||||
|
|
||||||
|
assertThat(html)
|
||||||
|
.contains("누가 어떤 행을 볼 수 있는지는 권한 규칙과 VPD에서 결정")
|
||||||
|
.contains("벡터 지식자료 검색 흐름")
|
||||||
|
.contains("VPD context 설정과 기본 SELECT")
|
||||||
|
.contains("전용 벡터 Handler 사용")
|
||||||
|
.contains("원문 표시 허용 컬럼")
|
||||||
|
.doesNotContain("표시 보호 등급", "마스킹 방식");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void permissionWizardExplainsTagRulesAndOrSemantics() throws IOException {
|
||||||
|
String html = template("permissions.html");
|
||||||
|
|
||||||
|
assertThat(html)
|
||||||
|
.contains("value=\"TAG\">특정 기술 태그")
|
||||||
|
.contains("TECH_TAG")
|
||||||
|
.contains("태그를 여러 개 추가하면");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roleTokenAndVpdPagesExplainTheirBoundaries() throws IOException {
|
||||||
|
String roles = template("roles.html");
|
||||||
|
String tokens = template("tokens.html");
|
||||||
|
String policies = template("vpd-policies.html");
|
||||||
|
String filters = template("vpd-filter-policies.html");
|
||||||
|
|
||||||
|
assertThat(roles)
|
||||||
|
.contains("표시 보호 등급 상한")
|
||||||
|
.contains("행 접근 권한은 권한 규칙과 VPD에서 결정합니다");
|
||||||
|
assertThat(tokens)
|
||||||
|
.contains("회수·만료 포함")
|
||||||
|
.contains("현재 사용 가능한 토큰만");
|
||||||
|
assertThat(policies)
|
||||||
|
.contains("어느 객체의 어떤 SQL에 어떤 Filter function을 붙일지")
|
||||||
|
.contains("이 policy가 하는 일");
|
||||||
|
assertThat(filters)
|
||||||
|
.contains("이 Filter가 보호하는 내용")
|
||||||
|
.contains("기술 태그 비교 예시");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mcpPagesDescribeReasoningAndProtocolBoundaries() throws IOException {
|
||||||
|
String chatbot = template("mcp-chatbot.html");
|
||||||
|
String reasoning = template("mcp-reasoning.html");
|
||||||
|
String client = template("mcp-client-demo.html");
|
||||||
|
String sse = template("mcp-sse.html");
|
||||||
|
|
||||||
|
assertThat(chatbot).contains("임시 토큰 사용자").contains("태그 벡터 검색");
|
||||||
|
assertThat(reasoning).contains("MCP tool을 고르고").contains("임시 토큰 사용자").contains("MCP Tool");
|
||||||
|
assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path");
|
||||||
|
assertThat(sse).contains("Instruction / parameter mapping").contains("bearerToken");
|
||||||
|
}
|
||||||
|
|
||||||
private String template(String relativePath) throws IOException {
|
private String template(String relativePath) throws IOException {
|
||||||
return Files.readString(Path.of("src/main/resources/templates").resolve(relativePath));
|
return Files.readString(Path.of("src/main/resources/templates").resolve(relativePath));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user