From e0fbf1e0c5c94953f129e93824a9778293b7021e Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 30 Jun 2026 16:33:59 +0900 Subject: [PATCH] [Developer] #573 normalize vector access whitelist --- .../README.md | 64 +++++++++++++ sql/adb/29_agent_ords_vector_search_ords.sql | 8 +- sql/adb/32_dds_vector_tag_setup.sql | 68 +++++++------- ...ds_vector_all_permission_normalization.sql | 91 +++++++++++++++++++ .../service/OrdsProbeService.java | 7 +- .../templates/fragments/probe-result.html | 7 +- .../service/OrdsProbeServiceTest.java | 12 +++ .../web/GuidedFlowTemplateTest.java | 20 +++- 8 files changed, 234 insertions(+), 43 deletions(-) create mode 100644 docs/design/573-vector-all-role-normalization/README.md create mode 100644 sql/adb/38_agent_ords_vector_all_permission_normalization.sql diff --git a/docs/design/573-vector-all-role-normalization/README.md b/docs/design/573-vector-all-role-normalization/README.md new file mode 100644 index 0000000..6b0f8d8 --- /dev/null +++ b/docs/design/573-vector-all-role-normalization/README.md @@ -0,0 +1,64 @@ +# 설계서: 전체 벡터 접근 역할의 ALL predicate 정규화 (#573) + +> **상태**: Implemented · QA passed +> **작성**: [AI] Architect · **최종수정**: 2026-06-30 +> **추적성** — Redmine: #573 · 선행 기능: #558, #570, #572 +> · 구현 파일: `32_dds_vector_tag_setup.sql`, `38_agent_ords_vector_all_permission_normalization.sql`, `29_agent_ords_vector_search_ords.sql`, `OrdsProbeService` +> · 테스트: `OrdsProbeServiceTest`, `GuidedFlowTemplateTest`, `mvn test`, 운영 vector probe + +## 1. 목적 + +기본 벡터 접근은 역할 기반 whitelist다. 기본 역할(10·20·30)에 `CB_VECTOR_SEARCH_DOCUMENTS / SELECT / ALLOW / ALL`을 저장하고, 그 역할을 가진 사용자는 전체 벡터 청크를 조회한다. 역할 권한이 없는 사용자는 VPD의 fail-closed predicate `1 = 0`으로 차단한다. + +`TAG`, 부서, 소유자 같은 행 조건은 관리자가 **ALL rule이 없는 제한 역할**에 실제로 등록했을 때만 추가한다. 기본 예제 역할에 임의의 TAG rule을 넣지 않는다. + +| 역할 | permission rule | VPD predicate | effective SQL 권한 조건 | +|---|---|---|---| +| `HR_DEPT_ROLE` | `ALL` | `1 = 1` | 조건 생략 | +| `FIN_SELF_ROLE` | `ALL` | `1 = 1` | 조건 생략 | +| `ALL_DOC_ROLE` | `ALL` | `1 = 1` | 조건 생략 | +| 관리자가 TAG rule을 준 역할 | `TAG: ...` | TAG OR | `AND (REGEXP_LIKE(...))` | +| 권한 없음 | allow rule 없음 | `1 = 0` | `AND (1 = 0)` | + +## 2. 범위 + +- 초기화 SQL에서 기본 역할 10·20·30의 개별 TAG rule을 제거하고 `ALL` rule을 보장. +- 이미 설치된 DB를 위한 idempotent migration SQL 제공 및 운영 적용. +- vector ORDS/백오피스 effective SQL trace에서 predicate가 `1 = 1`이면 `AND` 절을 출력하지 않음. +- 특정 TAG role의 `REGEXP_LIKE` filter와 fail-closed `1 = 0`은 유지. + +## 3. 인수조건 + +- [x] 기본 whitelist 역할 사용자(`agent_hr`, `agent_fin_self`, `agent_all`)의 VPD predicate가 정확히 `1 = 1`이다. +- [x] 기본 whitelist 역할의 vector effective SQL에 `REGEXP_LIKE`와 `AND (1 = 1)`이 없다. +- [x] 관리자가 TAG rule을 실제로 부여한 역할만 해당 TAG predicate를 포함한다. +- [x] 기본 역할 10·20·30의 기존 vector TAG rule은 migration 후 제거되고 ALL rule은 각 한 개만 존재한다. +- [x] 권한 없는 사용자 결과는 기존 fail-closed semantics를 유지한다. +- [x] SQL trace에 Bearer 원문·임베딩 숫자가 포함되지 않는다. + +## 4. 구현 흐름 + +```text +whitelist role + → CB_PERMISSION (CB_VECTOR_SEARCH_DOCUMENTS / SELECT / ALLOW) + → CB_PERMISSION_RULE (ALL) + → CB_AGENT_DOC_VPD_FILTER = 1 = 1 + → vector effective SQL: base VECTOR_DISTANCE query only +``` + +기존 기본 역할의 TAG rule은 현재 fixture의 태그만 허용할 뿐 이후 추가되는 청크를 불필요하게 막는다. 기본 whitelist는 `ALL` rule로 유지하고, 제한이 필요한 역할에만 명시적 행 rule을 부여한다. + +## 5. 검증 + +- `vectorEffectiveSql(..., "1 = 1")`가 `AND (1 = 1)`을 만들지 않는 단위 테스트. +- setup/migration source가 role 30의 ALL rule과 TAG rule 정리를 포함하는 템플릿/소스 테스트. +- `mvn test`. +- 운영 smoke: `tempUserId=101`, `102`, `103`으로 vector `/probe`를 실행해 모두 `VPD predicate = 1 = 1`, `REGEXP_LIKE` 없는 effective SQL, `SCORE` Top-K를 확인한다. 권한 없는 별도 사용자는 `1 = 0`인지 확인한다. + +## 6. 구현·QA 결과 + +- `38_agent_ords_vector_all_permission_normalization.sql`을 운영 DB에 적용해 기본 role 10·20·30의 legacy TAG rule을 제거하고 `ALL` rule을 보장했다. +- ORDS vector handler(29)를 재등록하고 백오피스를 배포했다. +- `mvn test`: 59 tests, 0 failures. +- 운영 `/probe` smoke(2026-06-30): `agent_hr(101)`, `agent_fin_self(102)`, `agent_all(103)` 모두 `1 = 1 (ALL: 추가 행 필터 없음)` 및 `WHERE d.embedding IS NOT NULL ORDER BY score`를 반환했다. 세 결과에 `REGEXP_LIKE`와 `AND (1 = 1)`은 없었다. +- 권한 없는 역할의 VPD 함수 경로는 변경하지 않아 기존 `1 = 0` fail-closed 동작을 유지한다. diff --git a/sql/adb/29_agent_ords_vector_search_ords.sql b/sql/adb/29_agent_ords_vector_search_ords.sql index 9ff5208..dcacfbc 100644 --- a/sql/adb/29_agent_ords_vector_search_ords.sql +++ b/sql/adb/29_agent_ords_vector_search_ords.sql @@ -61,8 +61,12 @@ BEGIN || '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(:embedding), COSINE) AS score ' || 'FROM admin.cb_vector_search_documents d ' - || 'WHERE d.embedding IS NOT NULL AND (' || v_vpd_predicate || ') ' - || 'ORDER BY score) ranked_chunks ' + || 'WHERE d.embedding IS NOT NULL'; + IF TRIM(v_vpd_predicate) <> '1 = 1' THEN + v_effective_sql := v_effective_sql || ' AND (' || v_vpd_predicate || ')'; + END IF; + v_effective_sql := v_effective_sql + || ' ORDER BY score) ranked_chunks ' || 'WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)'; EXCEPTION WHEN OTHERS THEN diff --git a/sql/adb/32_dds_vector_tag_setup.sql b/sql/adb/32_dds_vector_tag_setup.sql index 50e3754..4e8f579 100644 --- a/sql/adb/32_dds_vector_tag_setup.sql +++ b/sql/adb/32_dds_vector_tag_setup.sql @@ -78,19 +78,14 @@ CREATE OR REPLACE DATA GRANT admin.dds_demo_both_vector_grant -- dds_demo_none_role intentionally receives no grant (default deny). PROMPT === 3. Registering the vector object in the common application permission model === --- The application permission tables remain the source of truth. These rows --- make the standalone DDS example use the same role/TAG scenario as VPD: --- one ALLOW permission per role and multiple TAG rules combined with OR. --- The backoffice's DDS publish screen compiles these rows into the grants --- above (and excludes the protected EMBEDDING column by default). +-- The application permission tables remain the source of truth. Basic vector +-- access is a role whitelist: the seeded application roles have one ALLOW +-- permission plus an ALL rule and can search every vector chunk. TAG rules +-- are reserved for a role deliberately configured with a row condition. DECLARE - PROCEDURE ensure_vector_permission( - p_role_id IN NUMBER, - p_tag_list IN SYS.ODCIVARCHAR2LIST - ) IS + PROCEDURE ensure_vector_all_permission(p_role_id IN NUMBER) IS v_perm_id NUMBER; v_rule_id NUMBER; - v_tag VARCHAR2(100); BEGIN BEGIN SELECT perm_id @@ -111,33 +106,36 @@ DECLARE ); END; - FOR i IN 1 .. p_tag_list.COUNT LOOP - v_tag := UPPER(TRIM(p_tag_list(i))); - BEGIN - SELECT rule_id - INTO v_rule_id - FROM cb_permission_rule - WHERE perm_id = v_perm_id - AND rule_column = 'TECH_TAG' - AND rule_type = 'TAG' - AND rule_value = v_tag - AND ROWNUM = 1; - EXCEPTION - WHEN NO_DATA_FOUND THEN - SELECT NVL(MAX(rule_id), 0) + 1 INTO v_rule_id FROM cb_permission_rule; - INSERT INTO cb_permission_rule ( - rule_id, perm_id, rule_column, rule_type, rule_value - ) VALUES ( - v_rule_id, v_perm_id, 'TECH_TAG', 'TAG', v_tag - ); - END; - END LOOP; + -- The old demo seed put arbitrary TECH_TAG filters on these base roles. + -- Full access must not depend on the current list of document tags. + DELETE FROM cb_permission_rule + WHERE perm_id = v_perm_id + AND UPPER(TRIM(rule_type)) = 'TAG'; + + SELECT MIN(rule_id) + INTO v_rule_id + FROM cb_permission_rule + WHERE perm_id = v_perm_id + AND UPPER(TRIM(rule_type)) = 'ALL'; + + IF v_rule_id IS NULL THEN + SELECT NVL(MAX(rule_id), 0) + 1 INTO v_rule_id FROM cb_permission_rule; + INSERT INTO cb_permission_rule ( + rule_id, perm_id, rule_column, rule_type, rule_value + ) VALUES ( + v_rule_id, v_perm_id, NULL, 'ALL', NULL + ); + END IF; + + DELETE FROM cb_permission_rule + WHERE perm_id = v_perm_id + AND UPPER(TRIM(rule_type)) = 'ALL' + AND rule_id <> v_rule_id; END; BEGIN - ensure_vector_permission(10, SYS.ODCIVARCHAR2LIST('SPRING_BOOT', 'ORDS')); - ensure_vector_permission(20, SYS.ODCIVARCHAR2LIST('ORACLE_VPD', 'ORACLE_DDS')); - ensure_vector_permission(30, SYS.ODCIVARCHAR2LIST( - 'SPRING_BOOT', 'ORDS', 'ORACLE_VPD', 'ORACLE_DDS', 'MCP')); + ensure_vector_all_permission(10); + ensure_vector_all_permission(20); + ensure_vector_all_permission(30); END; / diff --git a/sql/adb/38_agent_ords_vector_all_permission_normalization.sql b/sql/adb/38_agent_ords_vector_all_permission_normalization.sql new file mode 100644 index 0000000..3099a93 --- /dev/null +++ b/sql/adb/38_agent_ords_vector_all_permission_normalization.sql @@ -0,0 +1,91 @@ +-- ============================================================ +-- 38_agent_ords_vector_all_permission_normalization.sql +-- Normalize the seeded vector access roles to whitelist / ALL access. +-- +-- The default roles 10, 20 and 30 may search every document chunk. A role +-- with no matching ALLOW permission remains fail-closed through the VPD +-- function (1 = 0). Keep row conditions such as TAG on dedicated restricted +-- roles that do not also have an ALL rule. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO ON +SET FEEDBACK ON +SET DEFINE OFF + +PROMPT === Normalizing default vector roles to ALL === +DECLARE + PROCEDURE normalize_vector_role(p_role_id IN NUMBER) IS + v_perm_id NUMBER; + v_rule_id NUMBER; + BEGIN + SELECT MIN(perm_id) + INTO v_perm_id + FROM cb_permission + WHERE role_id = p_role_id + AND target_name = 'CB_VECTOR_SEARCH_DOCUMENTS' + AND action_name = 'SELECT' + AND permission_effect = 'ALLOW'; + + IF v_perm_id IS NULL THEN + SELECT NVL(MAX(perm_id), 0) + 1 INTO v_perm_id FROM cb_permission; + INSERT INTO cb_permission ( + perm_id, role_id, target_name, action_name, permission_effect + ) VALUES ( + v_perm_id, p_role_id, 'CB_VECTOR_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW' + ); + END IF; + + -- Remove only the legacy demo TAG filters from the default ALL roles. + DELETE FROM cb_permission_rule + WHERE perm_id = v_perm_id + AND UPPER(TRIM(rule_type)) = 'TAG'; + + SELECT MIN(rule_id) + INTO v_rule_id + FROM cb_permission_rule + WHERE perm_id = v_perm_id + AND UPPER(TRIM(rule_type)) = 'ALL'; + + IF v_rule_id IS NULL THEN + SELECT NVL(MAX(rule_id), 0) + 1 INTO v_rule_id FROM cb_permission_rule; + INSERT INTO cb_permission_rule ( + rule_id, perm_id, rule_column, rule_type, rule_value + ) VALUES ( + v_rule_id, v_perm_id, NULL, 'ALL', NULL + ); + END IF; + + DELETE FROM cb_permission_rule + WHERE perm_id = v_perm_id + AND UPPER(TRIM(rule_type)) = 'ALL' + AND rule_id <> v_rule_id; + END; +BEGIN + normalize_vector_role(10); -- HR_DEPT_ROLE + normalize_vector_role(20); -- FIN_SELF_ROLE + normalize_vector_role(30); -- ALL_DOC_ROLE + COMMIT; +END; +/ + +PROMPT === Verifying default vector whitelist rules === +SELECT r.role_id, + r.role_name, + p.target_name, + p.action_name, + p.permission_effect, + pr.rule_type, + pr.rule_column, + pr.rule_value +FROM cb_app_role r +JOIN cb_permission p +ON p.role_id = r.role_id +LEFT JOIN cb_permission_rule pr +ON pr.perm_id = p.perm_id +WHERE r.role_id IN (10, 20, 30) +AND p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS' +AND p.action_name = 'SELECT' +ORDER BY r.role_id, p.perm_id, pr.rule_id; + +PROMPT === Default vector whitelist normalization complete === +EXIT; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java index 653d1b8..866e050 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java @@ -268,15 +268,20 @@ public class OrdsProbeService { } static String vectorEffectiveSql(ProtectedObject object, String predicate) { + String vpdFilter = isAllowAllPredicate(predicate) ? "" : " AND (" + predicate + ")"; return "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(:embedding), COSINE) AS score" + " FROM " + object.owner() + "." + object.objectName() + " d" - + " WHERE d.embedding IS NOT NULL AND (" + predicate + ")" + + " WHERE d.embedding IS NOT NULL" + vpdFilter + " ORDER BY score) ranked_chunks" + " WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)"; } + private static boolean isAllowAllPredicate(String predicate) { + return predicate != null && predicate.trim().replaceAll("\\s+", " ").equals("1 = 1"); + } + private String findVpdPredicate(String bearerToken, ProtectedObject object) { try { return jdbcTemplate.execute((ConnectionCallback) connection -> { diff --git a/src/main/resources/templates/fragments/probe-result.html b/src/main/resources/templates/fragments/probe-result.html index c5950a0..5093706 100644 --- a/src/main/resources/templates/fragments/probe-result.html +++ b/src/main/resources/templates/fragments/probe-result.html @@ -145,12 +145,13 @@
VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE) AS score
 ORDER BY score ASC
 ROWNUM <= :row_limit
-

이 부분이 검색어 벡터와 저장 벡터의 거리 계산입니다. 아래 권한 필터의 TAG 조건은 검색어 유사도 계산이 아니라 접근 통제입니다.

+

이 부분이 검색어 벡터와 저장 벡터의 거리 계산입니다. 아래에는 실제로 등록된 역할 기반 행 접근 조건만 표시됩니다.

VPD가 추가한 WHERE 조건

-
(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))
-

선택한 사용자의 직접 역할·그룹 상속 역할에 연결된 permission rule에서 계산된 조건입니다. 권한이 없으면 1 = 0, ALL이면 1 = 1이 됩니다.

+
1 = 1 (ALL: 추가 행 필터 없음)
+
(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))
+

선택한 사용자의 직접 역할·그룹 상속 역할에 연결된 permission rule에서 계산됩니다. 기본 whitelist 역할의 ALL은 추가 행 필터 없이 조회를 허용하고, 실제로 TAG·부서 조건을 등록한 역할만 그 조건이 SQL에 들어갑니다. 권한이 없으면 1 = 0입니다.

권한 적용 SQL

diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java index 0bf290c..cad0fba 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java @@ -23,4 +23,16 @@ class OrdsProbeServiceTest { .contains("ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)") .doesNotContain("Bearer", "0.10", "0.20"); } + + @Test + void omitsNoopAllowAllPredicateFromVectorEffectiveSql() { + ProtectedObject object = new ProtectedObject( + 5L, "ADMIN", "CB_VECTOR_SEARCH_DOCUMENTS", "cb-agent-vector/search", "Y"); + + String sql = OrdsProbeService.vectorEffectiveSql(object, " 1 = 1 "); + + assertThat(sql) + .contains("WHERE d.embedding IS NOT NULL ORDER BY score") + .doesNotContain("AND (1 = 1)", "REGEXP_LIKE"); + } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index 75ee7ab..5444152 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -147,10 +147,26 @@ class GuidedFlowTemplateTest { assertThat(result) .contains("토큰 적용 후 SQL") .contains("권한 적용 SQL") - .contains("VPD가 추가한 WHERE 조건"); + .contains("VPD가 추가한 WHERE 조건") + .contains("ALL: 추가 행 필터 없음") + .contains("기본 whitelist 역할"); assertThat(vectorSql) .contains("VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE)") - .contains("WHERE d.embedding IS NOT NULL AND (' || v_vpd_predicate || ')"); + .contains("IF TRIM(v_vpd_predicate) <> '1 = 1' THEN") + .contains("' AND (' || v_vpd_predicate || ')'"); + } + + @Test + void vectorDefaultRolesAreSeededAsWhitelistAllRules() throws IOException { + String setup = Files.readString(Path.of("sql/adb/32_dds_vector_tag_setup.sql")); + + assertThat(setup) + .contains("ensure_vector_all_permission(10)") + .contains("ensure_vector_all_permission(20)") + .contains("ensure_vector_all_permission(30)") + .contains("UPPER(TRIM(rule_type)) = 'TAG'") + .contains("NULL, 'ALL', NULL") + .doesNotContain("SYS.ODCIVARCHAR2LIST('SPRING_BOOT', 'ORDS')"); } @Test