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

@@ -7,6 +7,8 @@
k.expires_at, k.revoked_at, k.description
FROM cb_agent_bearer_key k
JOIN cb_app_user u ON u.user_id = k.user_id
WHERE #{includeInactive} = 1
OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP)
ORDER BY k.key_id DESC
</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 = 'DEPT' THEN NVL(pr2.rule_column, 'DEPT_CODE') || ' = ' || pr2.rule_value
WHEN pr2.rule_type = 'EMP_NO' THEN NVL(pr2.rule_column, 'OWNER_EMP_NO') || ' = ' || pr2.rule_value
WHEN pr2.rule_type = 'TAG' THEN 'REGEXP_LIKE(UPPER('
|| NVL(pr2.rule_column, 'TECH_TAG')
|| '), ''(^|,)' || REPLACE(UPPER(pr2.rule_value), '''', '''''') || '(,|$)'')'
ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value
END,
CHR(10) || 'AND '

View File

@@ -3,14 +3,14 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper">
<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
WHERE enabled_yn = 'Y'
ORDER BY owner, object_name
</select>
<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
JOIN cb_permission p ON p.target_name = po.object_name
WHERE po.enabled_yn = 'Y'
@@ -18,13 +18,13 @@
</select>
<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
WHERE object_id = #{objectId}
</select>
<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
WHERE owner = UPPER(#{owner,jdbcType=VARCHAR})
AND object_name = UPPER(#{objectName,jdbcType=VARCHAR})
@@ -78,13 +78,14 @@
</select>
<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 (
#{objectId},
UPPER(#{command.owner}),
UPPER(#{command.objectName}),
#{command.ordsPath},
'Y'
'Y',
#{command.description,jdbcType=VARCHAR}
)
</insert>
@@ -115,6 +116,12 @@
WHERE object_id = #{objectId,jdbcType=NUMERIC}
</update>
<update id="updateDescription">
UPDATE cb_protected_object
SET description = #{description,jdbcType=VARCHAR}
WHERE object_id = #{objectId,jdbcType=NUMERIC}
</update>
<update id="enableObject">
UPDATE cb_protected_object
SET enabled_yn = 'Y'

View File

@@ -62,6 +62,7 @@
o.object_type,
CASE WHEN po.object_id IS NULL THEN 'N' ELSE po.enabled_yn END AS protected_yn,
po.ords_path,
po.description,
COUNT(p.policy_name) AS policy_count,
LISTAGG(p.policy_name, ', ') WITHIN GROUP (ORDER BY p.policy_name) AS policy_names,
LISTAGG(
@@ -78,7 +79,7 @@
LEFT JOIN all_policies p
ON p.object_owner = o.owner
AND p.object_name = o.object_name
GROUP BY o.owner, o.object_name, o.object_type, po.object_id, po.enabled_yn, po.ords_path
GROUP BY o.owner, o.object_name, o.object_type, po.object_id, po.enabled_yn, po.ords_path, po.description
ORDER BY CASE WHEN COUNT(p.policy_name) > 0 THEN 0 ELSE 1 END,
o.owner,
o.object_type,
@@ -278,4 +279,50 @@
AND name = UPPER(#{objectName,jdbcType=VARCHAR})
AND type = UPPER(#{objectType,jdbcType=VARCHAR})
</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>

View File

@@ -1802,6 +1802,36 @@ body {
min-width: 520px;
}
.column-policy-explainer {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 8px;
margin: .85rem 0;
padding: .85rem;
}
.column-policy-explainer p {
color: var(--rw-muted);
line-height: 1.55;
margin: .35rem 0 0;
}
.object-handler-explainer {
display: grid;
gap: .25rem;
margin-bottom: .75rem;
}
.object-handler-explainer small {
color: var(--rw-muted);
}
.selected-column-list {
display: block;
margin-top: .45rem;
min-height: 1.5rem;
}
@media (max-width: 920px) {
.journey-grid,
.macro-micro-grid,

View File

@@ -357,6 +357,28 @@ function renderMaskableColumnOptions(option) {
current.push(column);
}
input.value = current.join(', ');
renderSelectedVisibleColumns();
updatePermissionWizardPreview(document);
});
});
}
function renderSelectedVisibleColumns() {
const input = document.querySelector('input[name="visibleColumns"]');
const list = document.querySelector('[data-selected-visible-columns]');
if (!input || !list) {
return;
}
const columns = input.value.split(',').map((value) => value.trim().toUpperCase()).filter(Boolean);
list.innerHTML = columns.map((column) => (
`<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);
});
});
@@ -372,14 +394,15 @@ function syncRuleTypeHints(root = document) {
}
const type = typeSelect.value;
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO'].includes(type);
const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO'].includes(type);
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG'].includes(type);
const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG'].includes(type);
const placeholderByType = {
ALL: '값 불필요',
MY_DEPT: '값 불필요',
SELF: '값 불필요',
DEPT: '예: HR',
EMP_NO: '예: E2001',
TAG: '예: SPRING_BOOT',
'=': '비교 값',
'!=': '비교 값'
};
@@ -441,7 +464,59 @@ async function syncObjectCatalogSelection() {
const owner = (selected.dataset.owner || '').toLowerCase();
const objectName = (selected.dataset.objectName || '').toLowerCase();
ordsPathInput.value = owner && objectName ? `cb-ords/cb-object-query/${owner}/${objectName}` : '';
ordsPathInput.readOnly = true;
}
const ordsPathEdit = document.querySelector('[data-ords-path-edit]');
if (ordsPathEdit) {
ordsPathEdit.checked = false;
}
}
function initOrdsPathEditToggle() {
const toggle = document.querySelector('[data-ords-path-edit]');
const input = document.querySelector('[data-ords-path-input]');
if (!toggle || !input) {
return;
}
toggle.addEventListener('change', () => {
input.readOnly = !toggle.checked;
if (toggle.checked) {
input.focus();
}
});
}
function initProbeObjectDescription() {
const select = document.querySelector('select[name="objectId"]');
const target = document.querySelector('[data-probe-object-description]');
if (!select || !target) {
return;
}
const update = () => {
const option = selectedOption(select);
target.textContent = option?.dataset.description
|| '선택한 대상의 설명이 등록되지 않았습니다. ORDS 조회 대상에서 설명을 추가하세요.';
};
select.addEventListener('change', update);
update();
}
function initProbeVectorInput() {
const select = document.querySelector('select[name="objectId"]');
const block = document.querySelector('[data-vector-probe-input]');
const input = block?.querySelector('textarea[name="requestBody"]');
if (!select || !block || !input) {
return;
}
const update = () => {
const option = selectedOption(select);
const isVectorSearch = option?.dataset.vectorSearch === 'true';
block.hidden = !isVectorSearch;
input.disabled = !isVectorSearch;
input.required = isVectorSearch;
};
select.addEventListener('change', update);
update();
}
function selectedText(select) {
@@ -570,6 +645,7 @@ function collectWizardRules(root) {
SELF: 'OWNER_EMP_NO',
DEPT: 'DEPT_CODE',
EMP_NO: 'OWNER_EMP_NO',
TAG: 'TECH_TAG',
ALL: ''
}[type] || '';
if (type === 'ALL') {
@@ -578,6 +654,9 @@ function collectWizardRules(root) {
if (['MY_DEPT', 'SELF'].includes(type)) {
return `${displayColumn} ${type}`;
}
if (type === 'TAG') {
return `${displayColumn} TAG ${value.toUpperCase()}`;
}
return [displayColumn, type, value].filter(Boolean).join(' ');
}).filter(Boolean);
}
@@ -592,6 +671,7 @@ function collectWizardPredicates(root) {
SELF: 'OWNER_EMP_NO',
DEPT: 'DEPT_CODE',
EMP_NO: 'OWNER_EMP_NO',
TAG: 'TECH_TAG',
ALL: ''
}[type] || '';
if (type === 'ALL') {
@@ -606,6 +686,9 @@ function collectWizardPredicates(root) {
if (type === 'DEPT' || type === 'EMP_NO') {
return `${displayColumn} = ${sqlLiteral(value)}`;
}
if (type === 'TAG') {
return `REGEXP_LIKE(UPPER(${displayColumn}), ${sqlLiteral(`(^|,)${value.toUpperCase()}(,|$)`)})`;
}
if (type === '=') {
return `TO_CHAR(${displayColumn}) = ${sqlLiteral(value)}`;
}
@@ -880,10 +963,16 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
syncRuleTypeHints();
document.querySelector('input[name="visibleColumns"]')?.addEventListener('input', renderSelectedVisibleColumns);
renderSelectedVisibleColumns();
const catalog = document.getElementById('objectCatalogSelect');
if (catalog) {
catalog.addEventListener('change', syncObjectCatalogSelection);
syncObjectCatalogSelection();
}
initOrdsPathEditToggle();
initProbeObjectDescription();
initProbeVectorInput();
document.querySelectorAll('[data-policy-template-select]').forEach((select) => {
select.addEventListener('change', () => syncPolicyTemplate(select));
syncPolicyTemplate(select);

View File

@@ -2,6 +2,8 @@
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<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">
<h2>Reasoning 결과</h2>
<span class="badge"
@@ -59,6 +61,7 @@
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

View File

@@ -8,6 +8,7 @@
<span th:text="${object.owner() + '.' + object.objectName()}">ADMIN.TABLE_NAME</span>
<span class="text-muted"> / 기본 ORDS Handler PL/SQL</span>
</h3>
<p class="form-hint">이 소스는 VPD context를 설정한 뒤 한 테이블을 SELECT하는 시작 예제입니다. 실제 수정·저장은 <a href="/ords-handlers">ORDS 핸들러</a> 화면에서 parsing schema 권한으로 진행하세요.</p>
<pre th:text="${source}">source</pre>
</section>
</div>

View File

@@ -4,15 +4,20 @@
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container page-shell">
<section class="page-heading">
<h1>MCP Chatbot</h1>
<p>질문을 MCP tool로 라우팅하고 ORDS/VPD 조회 결과를 답변으로 정리합니다.</p>
<section class="page-heading compact-heading">
<h1 class="h3">MCP Chatbot</h1>
<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 class="content-band chat-shell">
<div class="chat-message assistant-message">
<strong>질문 실행</strong>
<p>아래 예시를 누르거나 직접 질문을 입력하세요. Bearer Token을 입력하면 실제 ORDS/VPD 결과까지 조회니다.</p>
<p>아래 예시를 누르거나 직접 질문을 입력하세요. Bearer Token 원문을 붙여 넣거나 사용자를 선택해 10분 임시 토큰으로 실제 ORDS/VPD 결과 조회할 수 있습니다.</p>
</div>
<div id="mcp-chatbot-result" class="chat-thread">
@@ -33,6 +38,14 @@
<input class="form-control" name="bearerToken" type="password" autocomplete="off"
placeholder="비우면 라우팅만 확인합니다.">
</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>
질문
<textarea class="form-control" id="mcp-chat-question" name="question" rows="3"
@@ -40,19 +53,19 @@
</label>
<div class="question-presets" aria-label="질문 예시">
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="CB_V_SEARCH_DOCUMENTS에서 이 토큰으로 보이는 문서 행 수와 주요 컬럼을 요약해줘.">
data-question="기술 태그 권한에 따라 CB_V_SEARCH_DOCUMENTS에서 이 토큰으로 보이는 문서 행 수와 주요 컬럼을 요약해줘.">
문서 조회 요약
</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 class="btn rw-btn-secondary question-preset" type="button"
data-question="BOARD_POSTS에서 이 토큰으로 조회 가능한 게시글 행과 VPD 적용 결과를 요약해줘.">
BOARD_POSTS 조회
data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 SPRING_BOOT 또는 ORACLE_VPD 태그 권한으로 검색 가능한 청크와 VPD 결과를 요약해줘.">
태그 벡터 검색
</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>
</div>

View File

@@ -6,7 +6,7 @@
<main class="container page-shell">
<section class="page-heading">
<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 th:if="${runtimeError}" class="alert alert-warning">
@@ -18,29 +18,10 @@
<h2>Java Client 호출</h2>
<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}">
<label>
Context Path
<input class="form-control" name="contextPath" value="vpd-live" pattern="[A-Za-z0-9][A-Za-z0-9_-]{0,63}" required>
</label>
<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>
<div class="alert alert-info span-2 mb-0">
이 화면은 고정된 기본 MCP endpoint에서 initialize와 tools/list만 확인합니다. 질문 기반 tool 선택·Bearer Token 전달·tools/call은 MCP Reasoning 또는 외부 MCP client 흐름에서 수행합니다.
</div>
<button class="btn rw-btn-primary" type="submit">MCP 계약 확인</button>
</form>
</section>
@@ -53,17 +34,17 @@
<div class="mcp-service-item">
<span>1</span>
<strong><code>initialize</code></strong>
<small>context path가 반영된 MCP serverInfo와 tools capability를 확인합니다.</small>
<small>기본 MCP serverInfo와 tools capability를 확인합니다.</small>
</div>
<div class="mcp-service-item">
<span>2</span>
<strong><code>tools/list</code></strong>
<small>현재 보호 객체에서 생성된 ORDS query tool 목록을 조회합니다.</small>
<small>현재 보호 객체에서 생성된 ORDS query tool과 설명/schema를 조회합니다.</small>
</div>
<div class="mcp-service-item">
<span>3</span>
<strong><code>tools/call</code></strong>
<small>Bearer Token이 입력된 경우 선택한 tool을 호출해 ORDS/VPD 결과를 받습니다.</small>
<small>tool 선택은 reasoning 이후 MCP client가 수행하므로 이 화면에서는 호출하지 않습니다.</small>
</div>
</div>
</section>

View File

@@ -6,7 +6,7 @@
<main class="container py-4">
<div class="page-title">
<h1>MCP Reasoning</h1>
<p>Bearer Token으로 보호 객체를 조회한 뒤, 반환된 행과 NULL 처리 결과를 모델이 설명합니다.</p>
<p>질문을 근거로 MCP tool을 고르고, Bearer Token으로 VPD/ORDS 결과를 조회한 뒤 모델이 설명합니다.</p>
</div>
<div class="alert alert-warning" th:if="${runtimeError}">
@@ -24,68 +24,18 @@
</div>
<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}">
<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>
Bearer Token 원문
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
<span class="form-hint">선택 토큰은 컨텍스트 확인용입니다. ORDS 호출에는 원문 입력이 필요합니다.</span>
<input class="form-control" name="bearerToken" type="password" autocomplete="off">
<span class="form-hint">등록 토큰을 선택하지 않습니다. 원문을 붙여 넣거나 아래에서 임시 토큰 사용자를 선택하세요.</span>
</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>
조회 대상
<select class="form-select" id="mcp-reasoning-object" name="objectId" required>
<option th:each="tool : ${tools}"
th:value="${tool.objectId()}"
th:text="${tool.displayName() + ' / ' + tool.ordsPath()}"></option>
임시 토큰 사용자 (선택)
<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">선택하면 reasoning 실행 중 10분 토큰을 발행하고 완료 즉시 회수합니다.</span>
</label>
<label>
Limit
@@ -98,19 +48,19 @@
</label>
<div class="question-presets span-2" aria-label="질문 예시">
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="요약부터 작성해줘. 이 토큰으로 조회 가능한 행 수, 주요 식별자, NULL 처리 여부, 권한 범위를 표로 정리하고 상세 근거를 이어서 설명해줘.">
data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 이 토큰이 볼 수 있는 기술 태그 청크 수와 VPD가 제외한 범위를 요약해줘.">
기본 분석
</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 class="btn rw-btn-secondary question-preset" type="button"
data-question="반환된 행 수와 행의 부서/사번 값을 근거로 이 토큰이 전체 조회, 부서 제한, 본인 제한, 조건 제한 중 어디에 가까운지 판단해줘. 추정이면 추정이라고 표시해줘.">
권한 범위 판단
data-question="반환된 벡터 청크의 기술 태그와 권한 규칙을 근거로 어떤 지식 범위를 조회할 수 있는지 판단해줘. 추정이면 추정이라고 표시해줘.">
태그 권한 범위 판단
</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>
</div>
@@ -120,34 +70,26 @@
<section class="content-band">
<div class="section-heading">
<h2>조회 가능 대상</h2>
<h2>사용 가능한 MCP Tool (사전 선택 없음)</h2>
<span class="badge text-bg-secondary" th:text="${#lists.size(tools)}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>선택</th>
<th>Object</th>
<th>ORDS Path</th>
<th>MCP Tool</th>
<th>ORDS Path</th>
<th>설명</th>
</tr>
</thead>
<tbody>
<tr th:each="tool : ${tools}">
<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.name()}">ords.query.admin.board_posts</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 th:if="${#lists.isEmpty(tools)}">
<td colspan="4" class="text-muted">등록된 보호 객체 도구가 없습니다.</td>
<td colspan="3" class="text-muted">등록된 보호 객체 도구가 없습니다.</td>
</tr>
</tbody>
</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>
</body>
</html>

View File

@@ -82,6 +82,7 @@
<th>Name</th>
<th>Object</th>
<th>ORDS Path</th>
<th>Instruction / parameter mapping</th>
</tr>
</thead>
<tbody>
@@ -89,9 +90,18 @@
<td><code th:text="${tool.name()}">ords.query.admin.board_posts</code></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>
<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 th:if="${#lists.isEmpty(tools)}">
<td colspan="3" class="text-muted">등록된 MCP tool이 없습니다.</td>
<td colspan="4" class="text-muted">등록된 MCP tool이 없습니다.</td>
</tr>
</tbody>
</table>
@@ -104,6 +114,7 @@
"bearerToken": "vpd_live_xxx",
"limit": 50
}</pre>
<p class="form-hint">벡터 검색 tool인 <code>CB_VECTOR_SEARCH_DOCUMENTS</code>는 여기에 외부 임베딩 모델이 만든 <code>embedding</code> 숫자 배열을 추가합니다. 토큰·limit은 모든 tool에 공통이고, embedding은 벡터 tool에만 필요합니다.</p>
</section>
<section class="content-band">

View File

@@ -5,8 +5,15 @@
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>ORDS 조회 Handler 생성</h1>
<p>VPD가 적용된 TABLE/VIEW를 ORDS HTTP 경로로 서빙하기 위한 조회 Handler를 등록합니다.</p>
<h1>ORDS 조회 대상 등록</h1>
<p>이 화면은 DB 객체를 HTTP로 연결할 경로만 등록합니다. 누가 어떤 행을 볼 수 있는지는 권한 규칙과 VPD에서 결정합니다.</p>
</div>
<div class="alert alert-info">
<strong>벡터 지식자료 검색 흐름:</strong>
문서를 청크로 나누고 기술 태그를 붙인 뒤 전용 ORDS 검색을 등록합니다.
그 다음 <a href="/permissions">권한 관리</a>에서 <code>특정 기술 태그</code>를 여러 개 추가하면
태그 중 하나라도 맞는 청크만 검색됩니다.
</div>
<section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section>
@@ -15,7 +22,8 @@
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<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">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="owner" required>
@@ -32,15 +40,40 @@
</select>
</label>
<label>
ORDS Path
<input class="form-control" name="ordsPath" placeholder="DB 객체 선택 시 자동 입력" required>
ORDS Path (자동)
<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>
<button class="btn btn-primary" type="submit">대상 추가</button>
</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 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">
<table class="table table-sm align-middle">
<thead>
@@ -48,6 +81,7 @@
<th>ID</th>
<th>Owner</th>
<th>Object</th>
<th>설명</th>
<th>ORDS Path</th>
<th>Action</th>
</tr>
@@ -58,6 +92,16 @@
<td th:text="${object.objectId()}">1</td>
<td th:text="${object.owner()}">ADMIN</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>
<form method="post" action="/objects/ords-path" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
@@ -67,7 +111,10 @@
</form>
</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">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="objectId" th:value="${object.objectId()}">
@@ -87,74 +134,24 @@
</td>
</tr>
<tr>
<td colspan="5" class="policy-source-cell">
<details class="column-policy-panel">
<summary>기본 컬럼 민감도/마스킹 정책</summary>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Column</th>
<th>민감도</th>
<th>마스킹</th>
<th>상태</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="column : ${columnsByObject[object.objectId()]}">
<td>
<span th:text="${column.columnName()}">CONTENTS</span>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"
th:form="${'column-policy-form-' + column.columnId()}">
<input type="hidden" name="columnId" th:value="${column.columnId()}"
th:form="${'column-policy-form-' + column.columnId()}">
</td>
<td>
<select class="form-select form-select-sm" name="sensitivityLevel"
th:form="${'column-policy-form-' + column.columnId()}">
<option value="PUBLIC" th:selected="${column.sensitivityLevel() == 'PUBLIC'}">PUBLIC</option>
<option value="INTERNAL" th:selected="${column.sensitivityLevel() == 'INTERNAL'}">INTERNAL</option>
<option value="CONFIDENTIAL" th:selected="${column.sensitivityLevel() == 'CONFIDENTIAL'}">CONFIDENTIAL</option>
<option value="RESTRICTED" th:selected="${column.sensitivityLevel() == 'RESTRICTED'}">RESTRICTED</option>
</select>
</td>
<td>
<select class="form-select form-select-sm" name="redactionMethod"
th:form="${'column-policy-form-' + column.columnId()}">
<option value="NONE" th:selected="${column.redactionMethod() == 'NONE'}">NONE</option>
<option value="NULLIFY" th:selected="${column.redactionMethod() == 'NULLIFY'}">NULLIFY</option>
<option value="PARTIAL" th:selected="${column.redactionMethod() == 'PARTIAL'}">PARTIAL</option>
<option value="FULL" th:selected="${column.redactionMethod() == 'FULL'}">FULL</option>
</select>
</td>
<td>
<span class="badge"
th:classappend="${column.sensitive()} ? ' text-bg-warning' : ' text-bg-secondary'"
th:text="${column.policyLabel()}">PUBLIC/NONE</span>
</td>
<td>
<form method="post" action="/objects/column-policy"
th:id="${'column-policy-form-' + column.columnId()}"></form>
<button class="btn btn-sm btn-outline-primary" type="submit"
th:form="${'column-policy-form-' + column.columnId()}">저장</button>
</td>
</tr>
<tr th:if="${#lists.isEmpty(columnsByObject[object.objectId()])}">
<td colspan="5" class="text-muted">등록된 컬럼 정책이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</details>
<div th:id="${'handler-source-' + object.objectId()}" class="text-muted small">
<td colspan="6" class="policy-source-cell">
<div class="object-handler-explainer">
<strong>이 대상에서 자동으로 연결되는 것</strong>
<span>토큰 헤더 → VPD context 설정 → 선택한 단일 테이블의 기본 SELECT → JSON 응답</span>
<small>행 접근은 권한 규칙/VPD가 담당합니다. 원문 표시 예외는 권한 관리 Step 4에서 여러 컬럼을 등록하세요.</small>
</div>
<div th:if="${object.objectName() != 'CB_VECTOR_SEARCH_DOCUMENTS'}"
th:id="${'handler-source-' + object.objectId()}" class="text-muted small">
소스 보기를 누르면 기본 Handler PL/SQL이 표시됩니다.
</div>
<div th:if="${object.objectName() == 'CB_VECTOR_SEARCH_DOCUMENTS'}" class="text-muted small">
전용 벡터 검색 Handler는 SQL 29에서 등록합니다.
</div>
</td>
</tr>
</th:block>
<tr th:if="${#lists.isEmpty(objects)}">
<td colspan="5" class="text-muted">등록된 조회 Handler 대상이 없습니다.</td>
<td colspan="6" class="text-muted">등록된 조회 Handler 대상이 없습니다.</td>
</tr>
</tbody>
</table>

View File

@@ -66,7 +66,7 @@
data-group-users=${#strings.listJoin(groupUsersByRole[role.roleId()], '|')}"></option>
</select>
</label>
<p class="wizard-hint">선택한 역할의 민감도 허용 상한도 preview에 반영됩니다.</p>
<p class="wizard-hint">선택한 역할의 표시 보호 등급 상한(참고)도 preview에 반영됩니다. 행을 볼 수 있는 권한과는 별개입니다.</p>
<div class="effective-preview">
<div class="field-block-title">선택 역할 영향도</div>
<dl>
@@ -107,10 +107,10 @@
</label>
<p class="wizard-hint">DB 스키마 객체를 선택하면 저장 시 보호 객체가 자동 등록됩니다.</p>
<div class="effective-preview">
<div class="field-block-title">선택 객체 컬럼</div>
<div class="field-block-title">선택 객체 컬럼 (표시 보호 참고)</div>
<dl>
<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>
</div>
</div>
@@ -147,6 +147,7 @@
<option value="SELF">현재 사용자 사번</option>
<option value="DEPT">지정 부서</option>
<option value="EMP_NO">지정 사번</option>
<option value="TAG">특정 기술 태그</option>
<option value="=">=</option>
<option value="!=">!=</option>
</select>
@@ -154,6 +155,7 @@
<button class="btn btn-outline-secondary" type="button" data-rule-add>추가</button>
</div>
</div>
<p class="wizard-hint">특정 기술 태그는 기본적으로 <code>TECH_TAG</code> 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회되고, DENY 태그는 허용 결과에서 제외됩니다. 객체에 TECH_TAG가 없으면 실제 태그 컬럼을 직접 지정하세요.</p>
</div>
</div>
@@ -161,21 +163,22 @@
<div class="wizard-panel-heading">
<span class="wizard-step-number">4</span>
<div>
<h3>권한별 컬럼 마스킹</h3>
<p>이 역할이 이 테이블/뷰를 조회할 때 원문 표시를 허용할 마스킹 컬럼을 선택합니다.</p>
<h3>권한별 원문 표시 예외</h3>
<p>이 역할이 이미 볼 수 있는 행에서 마스킹을 제외할 컬럼을 여러 개 선택합니다.</p>
</div>
</div>
<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>
<span class="text-muted small">보호 객체를 선택하면 마스킹 대상 컬럼이 표시됩니다.</span>
</div>
</div>
<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>
<p class="wizard-hint">객체의 기본 민감도/마스킹 정책은 ORDS 조회 Handler 대상에서 관리하고, 여기서는 역할 권한에 붙일 원문 표시 예외만 정합니다.</p>
<p class="wizard-hint">쉼표로 여러 컬럼을 등록하거나 위 버튼을 여러 번 누르세요. 등록하지 않은 민감 컬럼은 기본 표시 보호를 유지합니다. 행을 볼 수 있는지는 이 목록이 아니라 권한 규칙과 VPD가 결정합니다.</p>
</div>
<div class="wizard-panel" data-wizard-step="5">
@@ -189,7 +192,7 @@
<div class="policy-preview" data-policy-preview>
<dl>
<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="affectedPrincipals">-</dd></div>
<div><dt>객체 컬럼</dt><dd data-preview="objectColumnsFinal">-</dd></div>
@@ -222,7 +225,7 @@
<th>Effect</th>
<th>행 규칙</th>
<th>적용 필터</th>
<th>원문 표시 허용 컬럼</th>
<th>원문 표시 허용 컬럼(여러 개)</th>
<th>삭제 영향</th>
<th></th>
</tr>

View File

@@ -32,24 +32,39 @@
<label class="span-2">
1. 발급받은 토큰 원문
<input class="form-control" name="bearerToken" type="password" autocomplete="off"
placeholder="토큰 발급 직후 복사한 값을 붙여 넣으세요" required>
<span class="form-hint">원문은 DB에 저장되지 않습니다. 목록에 보이는 prefix만으로는 검증할 수 없으며, 원문을 잃었다면 새 토큰을 발급해야 합니다.</span>
placeholder="토큰 발급 직후 복사한 값을 붙여 넣으세요">
<span class="form-hint">원문은 DB에 저장되지 않습니다. 아래 임시 토큰 사용자를 선택하면 이 입력은 무시되고 실행 중 발급·완료 즉시 회수됩니다. 둘 다 비우면 invalid token 결과를 확인할 수 있습니다.</span>
</label>
<label>
2. 확인할 데이터
<select class="form-select" name="objectId" required>
<option th:each="object : ${objects}"
th:value="${object.objectId()}"
th:text="${object.displayName() + (defaultObjectKeys.contains(object.displayName()) ? ' · 권한체계 자동 (권장)' : ' · 별도 Filter (고급 점검 필요)')}"></option>
임시 토큰 사용자 (선택)
<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>
<span class="form-hint">ORDS 호출에만 사용할 10분 토큰을 만들고 테스트가 끝나면 바로 회수합니다.</span>
</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>
최대 확인 행 수
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
<span class="form-hint">권한 판정에는 영향을 주지 않고 화면에 가져올 최대 행만 제한합니다.</span>
</label>
<button class="btn rw-btn-primary probe-submit" type="submit">3. 권한 결과 확인</button>
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
<span class="form-hint">권한 판정에는 영향을 주지 않고 화면에 가져올 최대 행만 제한합니다.</span>
</label>
<label class="span-2 vector-probe-input" data-vector-probe-input hidden>
벡터 검색 요청 본문 (JSON)
<textarea class="form-control" name="requestBody" rows="3" disabled
placeholder='{"embedding":[0.10,0.20,0.30,0.40]}'></textarea>
<span class="form-hint">벡터 검색 객체를 선택했을 때만 필요합니다. 검색어를 외부 임베딩 모델로 바꾼 배열을 넣습니다.</span>
</label>
<button class="btn rw-btn-primary probe-submit" type="submit">3. 권한 결과 확인</button>
</form>
</section>

View File

@@ -6,7 +6,7 @@
<main class="container py-4">
<div class="page-title">
<h1>역할 관리</h1>
<p>사용자에게 부여할 역할을 관리합니다.</p>
<p>사용자에게 부여할 역할을 관리합니다. 아래 등급은 허용된 행의 컬럼 표시 보호 상한을 기록하는 참고값이며, 행 접근 권한은 권한 규칙과 VPD에서 결정합니다.</p>
</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">
</label>
<label>
민감도 허용 상한
표시 보호 등급 상한 (참고)
<select class="form-select" name="maxSensitivityLevel">
<option value="PUBLIC">PUBLIC</option>
<option value="INTERNAL">INTERNAL</option>
<option value="CONFIDENTIAL">CONFIDENTIAL</option>
<option value="RESTRICTED">RESTRICTED</option>
</select>
<span class="form-hint">이 역할이 허용된 행에서 원문으로 볼 수 있는 컬럼 등급의 참고 상한입니다. 행을 볼 수 있는지는 권한 규칙과 VPD가 결정하며, 이 값은 행 권한을 부여하지 않습니다.</span>
</label>
<button class="btn rw-btn-primary" type="submit">추가</button>
</form>
@@ -45,7 +46,7 @@
<tr>
<th>ID</th>
<th>역할명</th>
<th>민감도 허용 상한</th>
<th>표시 보호 등급 상한</th>
<th>삭제 영향</th>
<th></th>
</tr>
@@ -57,6 +58,8 @@
<td th:text="${role.roleId()}">10</td>
<td th:text="${role.roleName()}">HR_DEPT_ROLE</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">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="roleId" th:value="${role.roleId()}">
@@ -68,6 +71,7 @@
</select>
<button class="btn btn-sm btn-outline-primary" type="submit">저장</button>
</form>
<small class="text-muted d-block mt-1">행 접근 권한과 별개인 표시 보호 상한</small>
</td>
<td class="delete-impact">
<div>

View File

@@ -54,9 +54,19 @@
<div class="section-heading">
<div>
<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>
<span class="badge text-bg-secondary" th:text="${#lists.size(tokens)}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">

View File

@@ -40,6 +40,9 @@
<code>CB_AGENT_DOC_VPD_FILTER</code>
<span>이 화면과 서버 양쪽에서 직접 덮어쓰기를 차단합니다.</span>
</div>
<div class="alert alert-info mt-3 mb-0">
<strong>Filter 설명:</strong> 이 화면의 function은 DB가 행을 걸러낼 predicate를 반환합니다. 기본 함수는 사용자·그룹·역할·TAG 권한을 동적으로 읽고, 별도 Filter는 객체 고유 조건을 추가할 때만 사용합니다. 저장하는 설명에는 대상, 조건, 예상되는 허용/차단 결과를 적습니다.
</div>
</section>
<section class="content-band">
@@ -72,11 +75,16 @@
<label class="span-2">
반환할 predicate
<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>
<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="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>
<button class="btn rw-btn-primary" type="submit">별도 Filter 저장</button>
</form>
@@ -87,7 +95,7 @@
</div>
<div class="table-responsive">
<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>
<th:block th:each="function, iter : ${formOptions.functions()}">
<tr>
@@ -96,6 +104,9 @@
<span class="badge text-bg-primary" th:if="${function.permissionSystemDefault()}">권한체계 기본</span>
<span class="badge text-bg-warning" th:unless="${function.permissionSystemDefault()}">별도 Filter</span>
</td>
<td>
<span th:text="${filterDescriptions[function.owner() + '|' + function.functionName()]}">Filter 목적</span>
</td>
<td>
<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()})}"
@@ -103,7 +114,7 @@
</td>
</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 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()}">
<label>
이 별도 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>
<button class="btn btn-sm rw-btn-secondary" type="submit">별도 Filter 수정</button>
</form>
</td>
</tr>
</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>
</table>
</div>

View File

@@ -36,6 +36,9 @@
<span>허용된 데이터만 반환</span>
</div>
<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 class="content-band">
@@ -113,6 +116,7 @@
<thead>
<tr>
<th>Object</th>
<th>대상 설명</th>
<th>Type</th>
<th>VPD 상태</th>
<th>Policy</th>
@@ -130,6 +134,7 @@
data-vpd-applied=${target.vpdApplied()},
data-protected-object=${target.protectedObject()}">
<td><code th:text="${target.objectDisplayName()}">ADMIN.TABLE</code></td>
<td th:text="${target.description()} ?: '설명 없음'">조회 대상 설명</td>
<td th:text="${target.objectType()}">TABLE</td>
<td>
<span class="badge"
@@ -166,7 +171,7 @@
</td>
</tr>
<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">
Filter 이름을 클릭하면 policy/filter source가 표시됩니다.
</div>
@@ -174,7 +179,7 @@
</tr>
</th:block>
<tr th:if="${#lists.isEmpty(vpdTargets)}">
<td colspan="7" class="text-muted">
<td colspan="8" class="text-muted">
<span th:if="${selectedSchemaOwner != null && selectedSchemaOwner != ''}"
th:text="${selectedSchemaOwner + ' 스키마에서 현재 DB 연결 사용자로 조회 가능한 TABLE/VIEW가 없습니다. 스키마명, ALL_OBJECTS 조회 권한, 객체 권한을 확인하세요.'}">
선택한 스키마에서 조회 가능한 TABLE/VIEW가 없습니다.
@@ -270,6 +275,7 @@
<th>Status</th>
<th>Type</th>
<th>Options</th>
<th>이 policy가 하는 일</th>
<th>Explain</th>
</tr>
</thead>
@@ -298,6 +304,9 @@
<code th:text="${policy.functionDisplayName()}">OWNER.FUNC</code>
</button>
<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-warning" th:unless="${policy.permissionSystemDefault()}">별도 Filter</span>
</td>
@@ -313,6 +322,18 @@
<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>
</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>
<div class="action-stack">
<button class="btn btn-sm rw-btn-primary"
@@ -331,7 +352,7 @@
</td>
</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">
Policy, Filter, LLM 설명을 클릭하면 상세 내역이 표시됩니다.
</div>
@@ -339,7 +360,7 @@
</tr>
</th:block>
<tr th:if="${#lists.isEmpty(policies)}">
<td colspan="8" class="text-muted">
<td colspan="9" class="text-muted">
등록된 TABLE/VIEW에 적용된 VPD policy가 없습니다. 보호 객체 등록 상태와 DB policy 적용 상태를 확인하세요.
</td>
</tr>