[Developer] #573 normalize vector access whitelist
This commit is contained in:
64
docs/design/573-vector-all-role-normalization/README.md
Normal file
64
docs/design/573-vector-all-role-normalization/README.md
Normal file
@@ -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 동작을 유지한다.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
/
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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<String>) connection -> {
|
||||
|
||||
@@ -145,12 +145,13 @@
|
||||
<pre>VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE) AS score
|
||||
ORDER BY score ASC
|
||||
ROWNUM <= :row_limit</pre>
|
||||
<p class="form-hint mb-0">이 부분이 검색어 벡터와 저장 벡터의 거리 계산입니다. 아래 권한 필터의 TAG 조건은 검색어 유사도 계산이 아니라 접근 통제입니다.</p>
|
||||
<p class="form-hint mb-0">이 부분이 검색어 벡터와 저장 벡터의 거리 계산입니다. 아래에는 실제로 등록된 역할 기반 행 접근 조건만 표시됩니다.</p>
|
||||
</section>
|
||||
<section class="probe-exchange" data-sql-trace-field="vpd_predicate">
|
||||
<h3 th:text="${vectorSearch ? '역할 기반 권한 필터 (VPD)' : 'VPD가 추가한 WHERE 조건'}">VPD가 추가한 WHERE 조건</h3>
|
||||
<pre th:text="${result.vpdPredicate()}">(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))</pre>
|
||||
<p class="form-hint mb-0" th:if="${vectorSearch}">선택한 사용자의 직접 역할·그룹 상속 역할에 연결된 permission rule에서 계산된 조건입니다. 권한이 없으면 <code>1 = 0</code>, ALL이면 <code>1 = 1</code>이 됩니다.</p>
|
||||
<pre th:if="${result.vpdPredicate() == '1 = 1'}">1 = 1 (ALL: 추가 행 필터 없음)</pre>
|
||||
<pre th:unless="${result.vpdPredicate() == '1 = 1'}" th:text="${result.vpdPredicate()}">(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))</pre>
|
||||
<p class="form-hint mb-0" th:if="${vectorSearch}">선택한 사용자의 직접 역할·그룹 상속 역할에 연결된 permission rule에서 계산됩니다. 기본 whitelist 역할의 ALL은 추가 행 필터 없이 조회를 허용하고, 실제로 TAG·부서 조건을 등록한 역할만 그 조건이 SQL에 들어갑니다. 권한이 없으면 <code>1 = 0</code>입니다.</p>
|
||||
</section>
|
||||
<section class="probe-exchange" data-sql-trace-field="effective_sql">
|
||||
<h3>권한 적용 SQL</h3>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user