fix #557: guide permission-driven VPD flow

This commit is contained in:
devmrko
2026-06-29 12:11:25 +09:00
parent fbe3d4682b
commit 908ac9a386
57 changed files with 1952 additions and 312 deletions

View File

@@ -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)

View 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 변경은 별도 승인을 받아 실행한다.

View File

@@ -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

View File

@@ -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 '

View File

@@ -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

View 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;

View 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;

View 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;

View 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;

View File

@@ -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

View File

@@ -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);
}
} }

View File

@@ -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() : "기본 표시 · 마스킹 없음";
}
} }

View File

@@ -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;
}
} }

View File

@@ -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 sensitiveColumns,
String description
) { ) {
public ProtectedObjectCreateCommand(
String owner,
String objectName,
String ordsPath,
String columns,
String sensitiveColumns
) {
this(owner, objectName, ordsPath, columns, sensitiveColumns, null);
}
} }

View File

@@ -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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
);
} }

View File

@@ -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",

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;

View File

@@ -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()

View File

@@ -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")) {

View File

@@ -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(),

View File

@@ -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();
} }

View File

@@ -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, "민감도 등급");

View File

@@ -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("'", "''");
} }

View File

@@ -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());
} }

View File

@@ -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());
} }

View File

@@ -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
) { ) {
model.addAttribute("result", reasoningService.reason( String effectiveToken = bearerToken;
new McpReasoningCommand(tokenKeyId, objectId, bearerToken, limit, question))); Long temporaryKeyId = null;
try {
if (tempUserId != null) {
var issued = tokenService.issueTemporaryToken(tempUserId, "MCP Reasoning 임시 실행");
effectiveToken = issued.plainToken();
temporaryKeyId = issued.keyId();
}
model.addAttribute("result", reasoningService.reason(
new McpReasoningCommand(temporaryKeyId, effectiveToken, limit, question)));
} catch (Exception exception) {
model.addAttribute("errorMessage", exception.getMessage());
} finally {
if (temporaryKeyId != null) {
tokenService.revokeToken(temporaryKeyId, "temporary reasoning completed");
}
}
return "fragments/mcp-reasoning-result :: result"; return "fragments/mcp-reasoning-result :: result";
} }
} }

View File

@@ -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;
model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken)); if (tempUserId != null) {
var issued = tokenService.issueTemporaryToken(tempUserId, "ORDS 검증 임시 실행");
normalizedToken = issued.plainToken();
temporaryKeyId = issued.keyId();
}
try {
model.addAttribute("result", probeService.runProbe(
new ProbeCommand(temporaryKeyId, objectId, normalizedToken, limit, requestBody)));
model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken));
} finally {
if (temporaryKeyId != null) {
tokenService.revokeToken(temporaryKeyId, "temporary probe completed");
}
}
model.addAttribute("selectedObject", protectedObjectService.findEnabled().stream() model.addAttribute("selectedObject", protectedObjectService.findEnabled().stream()
.filter(object -> object.objectId() == objectId) .filter(object -> object.objectId() == objectId)
.findFirst() .findFirst()

View File

@@ -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());
} }

View File

@@ -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";

View File

@@ -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,

View File

@@ -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>

View File

@@ -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 '

View File

@@ -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'

View File

@@ -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>

View File

@@ -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,

View File

@@ -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);

View File

@@ -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"
@@ -59,6 +61,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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> </div>
<tr> <div th:if="${object.objectName() != 'CB_VECTOR_SEARCH_DOCUMENTS'}"
<th>Column</th> th:id="${'handler-source-' + object.objectId()}" class="text-muted small">
<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>
</details>
<div 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>

View File

@@ -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>

View File

@@ -32,24 +32,39 @@
<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>
<label> <label>
2. 확인할 데이터 임시 토큰 사용자 (선택)
<select class="form-select" name="objectId" required> <select class="form-select" name="tempUserId">
<option th:each="object : ${objects}" <option value="">원문 토큰 사용</option>
th:value="${object.objectId()}" <option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
th:text="${object.displayName() + (defaultObjectKeys.contains(object.displayName()) ? ' · 권한체계 자동 (권장)' : ' · 별도 Filter (고급 점검 필요)')}"></option>
</select> </select>
<span class="form-hint">이 객체에 저장한 권한 규칙과 실제 반환 행을 비교합니다.</span> <span class="form-hint">ORDS 호출에만 사용할 10분 토큰을 만들고 테스트가 끝나면 바로 회수합니다.</span>
</label> </label>
<label>
2. 확인할 데이터
<select class="form-select" name="objectId" required>
<option th:each="object : ${objects}"
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>
</select>
<span class="form-hint" data-probe-object-description>선택한 대상의 설명이 여기에 표시됩니다.</span>
</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>
<button class="btn rw-btn-primary probe-submit" type="submit">3. 권한 결과 확인</button> <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>
</form> </form>
</section> </section>

View File

@@ -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>

View File

@@ -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 class="action-stack">
<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>
<span class="badge text-bg-secondary" th:text="${#lists.size(tokens)}">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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)");
}
}

View File

@@ -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

View File

@@ -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));
} }