fix #456: add VPD predicate rule tests

This commit is contained in:
devmrko
2026-06-25 21:21:48 +09:00
parent 5eaffd6229
commit d97dab34bd
5 changed files with 407 additions and 12 deletions

View File

@@ -0,0 +1,93 @@
# 설계서: VPD predicate 생성 함수 구조화 및 단위 검증 보강 (#456)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #456 · 관련 ADR: 없음
> · 구현 파일: `sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`, `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`, `mvn test`
## 1. 목적 (Why)
`cb_agent_doc_vpd_filter`가 DB에 저장된 행규칙을 기반으로 whitelist predicate를 만드는지, rule type별 경계 조건이 fail-closed로 동작하는지 재실행 가능한 테스트로 고정한다.
## 2. 범위 (Scope)
- **포함**: VPD predicate 생성 함수의 의도 주석 보강, rule type별 predicate 단위 검증 SQL 추가, 임시 테스트 데이터 생성/정리.
- **제외 (out of scope)**: 백오피스 화면 변경, ORDS handler 자동 생성 변경, MCP/LLM 응답 개선.
## 3. 인수조건 (Acceptance Criteria)
- [ ] `ALL`, `MY_DEPT`, `SELF`, `=`, `!=` 규칙이 기대 predicate를 생성한다.
- [ ] 권한이 없거나 `USER_ID` 컨텍스트가 없으면 `1 = 0`을 반환한다.
- [ ] 대상 객체에 없는 `rule_column`은 권한 우회로 이어지지 않고 `1 = 0`으로 닫힌다.
- [ ] 테스트 스크립트는 임시 role/permission/rule을 만들고 종료 시 정리한다.
- [ ] 기존 ORDS 검증 결과와 `mvn test`가 유지된다.
## 4. 컨텍스트 & 제약
- 의존성: Oracle ADB, `CB_AGENT_CTX`, `cb_user_role`, `cb_permission`, `cb_permission_rule`, `all_tab_columns`.
- 제약: VPD policy function은 SQL predicate 문자열을 반환하므로 문자열 escape와 컬럼명 검증이 필수다.
- 가정: `p_object`는 DBMS_RLS가 전달하는 실제 object name이고, `cb_permission.target_name`은 대문자 object name으로 저장된다.
## 5. 아키텍처 개요
- `26_agent_ords_security_dynamic_vpd_filter.sql`: 운영 DB에 적용할 predicate 생성 함수.
- `27_agent_ords_security_dynamic_vpd_filter_test.sql`: SQLcl로 실행하는 단위 검증 스크립트.
```
CB_AGENT_CTX.USER_ID
-> CB_USER_ROLE
-> CB_PERMISSION(target_name = p_object, action_name = SELECT)
-> CB_PERMISSION_RULE
-> cb_agent_doc_vpd_filter returns SQL predicate
```
I/O와 순수 로직 경계:
- I/O: rule 조회, `all_tab_columns` 컬럼 검증.
- 전략 로직: rule type별 predicate 문자열 생성, 기본 거부 처리.
## 6. 데이터 모델
- 입력: `p_schema VARCHAR2`, `p_object VARCHAR2`, `SYS_CONTEXT('CB_AGENT_CTX','USER_ID')`.
- 저장 규칙: `rule_column`, `rule_type`, `rule_value`.
- 출력: VPD predicate `VARCHAR2`, 예: `1 = 0`, `1 = 1`, `(DEPT_CODE = SYS_CONTEXT(...))`.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `cb_agent_doc_vpd_filter` | 현재 사용자와 대상 객체의 저장 규칙을 SQL predicate로 변환 | `FUNCTION(p_schema VARCHAR2, p_object VARCHAR2) RETURN VARCHAR2` | schema, object, app context | predicate string | 컨텍스트/권한 없음 `1 = 0` | **복잡** |
| `assert_predicate_contains` | predicate 결과가 기대 문자열을 포함하는지 검증 | PL/SQL local procedure | label, user, object, expected | 없음 | 실패 시 raise_application_error | 단순 |
| `assert_predicate_equals` | predicate 결과가 기대 문자열과 같은지 검증 | PL/SQL local procedure | label, user, object, expected | 없음 | 실패 시 raise_application_error | 단순 |
## 8. 흐름 / 알고리즘
1. 테스트용 role/permission/rule을 생성한다.
2. 사용자별 `cb_agent_ctx_pkg.set_user`로 context를 설정한다.
3. `cb_agent_doc_vpd_filter('ADMIN', object)`를 직접 호출한다.
4. 반환 predicate가 기대 조건을 포함하거나 일치하는지 검증한다.
5. 잘못된 컬럼 규칙은 `1 = 0`을 반환하는지 확인한다.
6. 테스트 데이터를 정리하고 context를 clear한다.
## 9. 엣지케이스 & 에러 처리
- `USER_ID` 없음: `1 = 0`.
- 권한 없음: `1 = 0`.
- rule_column 없음: `MY_DEPT``DEPT_CODE`, `SELF``OWNER_EMP_NO`를 기본 컬럼으로 사용.
- 존재하지 않는 rule_column: 해당 rule 무시, 남는 rule 없으면 `1 = 0`.
- quote 포함 rule_value: single quote escape가 적용된 literal predicate 생성.
## 10. 테스트 계획
- SQLcl: `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
- SQLcl: `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
- Maven: `mvn test`
- ORDS smoke: 기존 HR=3, SELF=1, ALL=6 결과 유지 확인.
## 11. 리스크 & 대안 검토
- 선택: PL/SQL 함수 직접 호출 기반 단위 검증. VPD owner bypass 문제와 무관하게 predicate 자체를 빠르게 검증할 수 있다.
- 대안: ORDS만으로 검증. end-to-end는 좋지만 실패 원인이 handler/token/VPD 중 어디인지 분리하기 어렵다.
## 12. 미해결 질문 (Open Questions)
- rule type 확장 시 UI enum과 PL/SQL branch를 어떻게 한 곳에서 관리할지 별도 이슈 #457에서 다룬다.

View File

@@ -0,0 +1,83 @@
# 함수 설계서: `cb_agent_doc_vpd_filter` (#456)
> **부모 설계서**: ./README.md · **상태**: Approved
> **작성**: [AI] Architect · **구현**: `sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql:cb_agent_doc_vpd_filter` · **테스트**: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
## 1. 시그니처
```sql
FUNCTION cb_agent_doc_vpd_filter(
p_schema IN VARCHAR2,
p_object IN VARCHAR2
) RETURN VARCHAR2
```
## 2. 책임
현재 `CB_AGENT_CTX.USER_ID`와 대상 객체명에 매칭되는 저장 행규칙을 읽어 VPD WHERE predicate 문자열을 반환한다.
## 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `p_schema` | `VARCHAR2` | NULL이면 거부 | 대상 object owner |
| `p_object` | `VARCHAR2` | NULL이면 거부, `target_name` 비교는 대문자 | 대상 table/view name |
| `CB_AGENT_CTX.USER_ID` | context string | 숫자 변환 실패 시 거부 | 현재 bearer token 사용자 |
## 4. 출력
- **반환**: SQL predicate 문자열.
- **부수효과**: 없음. DB 조회만 수행한다.
## 5. 동작 / 알고리즘
1. `USER_ID`를 숫자로 변환한다. 실패하거나 NULL이면 `1 = 0`.
2. `p_object`를 대문자로 정규화한다.
3. 사용자 역할과 권한, 행규칙을 조회한다.
4. `ALL` 규칙이 있으면 즉시 `1 = 1`.
5. 조건형 규칙은 대상 객체에 존재하는 컬럼만 predicate에 추가한다.
6. predicate가 하나도 없으면 `1 = 0`, 있으면 OR 묶음으로 반환한다.
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| context 없음 | 기본 거부 | `1 = 0` |
| 권한 없음 | 기본 거부 | `1 = 0` |
| 컬럼 검증 실패 | 해당 rule 무시 | 남는 rule 없으면 `1 = 0` |
| rule_value quote 포함 | literal escape | escape된 predicate |
## 7. 엣지케이스
- `MY_DEPT``rule_column`이 NULL이면 `DEPT_CODE`.
- `SELF``rule_column`이 NULL이면 `OWNER_EMP_NO`.
- `=`, `!=``rule_column`이 반드시 필요하다.
- 여러 role/rule은 OR 조건으로 누적한다.
## 8. 복잡도 / 성능
- rule 수를 `n`이라고 할 때 O(n)로 predicate를 만든다.
- VPD policy function이므로 SELECT마다 호출될 수 있어 쿼리는 단순 join과 컬럼 existence check로 제한한다.
## 9. 의존성
- `cb_user_role`
- `cb_permission`
- `cb_permission_rule`
- `all_tab_columns`
- `CB_AGENT_CTX`
## 10. 테스트 케이스
- [ ] 정상: ALL 사용자 -> `1 = 1`.
- [ ] 정상: MY_DEPT 사용자 -> `DEPT_CODE = SYS_CONTEXT(...)`.
- [ ] 정상: SELF 사용자 -> `OWNER_EMP_NO = SYS_CONTEXT(...)`.
- [ ] 정상: `=` 규칙 -> `TO_CHAR(DOC_ID) = '1'`.
- [ ] 정상: `!=` 규칙 -> `TO_CHAR(DEPT_CODE) <> 'HR'`.
- [ ] 실패: context 없음 -> `1 = 0`.
- [ ] 실패: 존재하지 않는 컬럼 -> `1 = 0`.
## 11. 추적성
- 인수조건: #456 전체.
- 관련 ADR: 없음.

View File

@@ -341,22 +341,34 @@ BEGIN
RETURN '1 = 1';
ELSIF r.rule_type = 'MY_DEPT' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
append_or(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')');
IF v_column IS NOT NULL THEN
append_or(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')');
END IF;
ELSIF r.rule_type = 'SELF' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
append_or(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')');
IF v_column IS NOT NULL THEN
append_or(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')');
END IF;
ELSIF r.rule_type = 'DEPT' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
append_or(v_column || ' = ' || quote_literal(r.rule_value));
IF v_column IS NOT NULL THEN
append_or(v_column || ' = ' || quote_literal(r.rule_value));
END IF;
ELSIF r.rule_type = 'EMP_NO' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
append_or(v_column || ' = ' || quote_literal(r.rule_value));
IF v_column IS NOT NULL THEN
append_or(v_column || ' = ' || quote_literal(r.rule_value));
END IF;
ELSIF r.rule_type = '=' THEN
v_column := safe_column(p_schema, p_object, r.rule_column);
append_or('TO_CHAR(' || v_column || ') = ' || quote_literal(r.rule_value));
IF v_column IS NOT NULL THEN
append_or('TO_CHAR(' || v_column || ') = ' || quote_literal(r.rule_value));
END IF;
ELSIF r.rule_type IN ('!=', '<>') THEN
v_column := safe_column(p_schema, p_object, r.rule_column);
append_or('TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value));
IF v_column IS NOT NULL THEN
append_or('TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value));
END IF;
END IF;
END;
END LOOP;

View File

@@ -18,6 +18,8 @@ CREATE OR REPLACE FUNCTION cb_agent_doc_vpd_filter(
) RETURN VARCHAR2
AUTHID DEFINER
AS
-- This function is intentionally whitelist-first:
-- no matched stored row rule means no rows are visible.
v_user_id NUMBER;
v_target VARCHAR2(128);
v_predicate VARCHAR2(32767);
@@ -104,23 +106,37 @@ BEGIN
IF r.rule_type = 'ALL' THEN
RETURN '1 = 1';
ELSIF r.rule_type = 'MY_DEPT' THEN
-- NULL rule_column keeps backward compatibility with the demo seed.
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
append_or(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')');
IF v_column IS NOT NULL THEN
append_or(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')');
END IF;
ELSIF r.rule_type = 'SELF' THEN
-- NULL rule_column keeps backward compatibility with the demo seed.
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
append_or(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')');
IF v_column IS NOT NULL THEN
append_or(v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')');
END IF;
ELSIF r.rule_type = 'DEPT' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
append_or(v_column || ' = ' || quote_literal(r.rule_value));
IF v_column IS NOT NULL THEN
append_or(v_column || ' = ' || quote_literal(r.rule_value));
END IF;
ELSIF r.rule_type = 'EMP_NO' THEN
v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
append_or(v_column || ' = ' || quote_literal(r.rule_value));
IF v_column IS NOT NULL THEN
append_or(v_column || ' = ' || quote_literal(r.rule_value));
END IF;
ELSIF r.rule_type = '=' THEN
v_column := safe_column(p_schema, p_object, r.rule_column);
append_or('TO_CHAR(' || v_column || ') = ' || quote_literal(r.rule_value));
IF v_column IS NOT NULL THEN
append_or('TO_CHAR(' || v_column || ') = ' || quote_literal(r.rule_value));
END IF;
ELSIF r.rule_type IN ('!=', '<>') THEN
v_column := safe_column(p_schema, p_object, r.rule_column);
append_or('TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value));
IF v_column IS NOT NULL THEN
append_or('TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value));
END IF;
END IF;
END;
END LOOP;

View File

@@ -0,0 +1,191 @@
-- ============================================================
-- 27_agent_ords_security_dynamic_vpd_filter_test.sql
-- Unit-style checks for cb_agent_doc_vpd_filter.
--
-- Run after:
-- @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
SET SERVEROUTPUT ON
PROMPT === Preparing dynamic VPD filter unit test data ===
BEGIN
cb_agent_ctx_pkg.clear_user;
DELETE FROM cb_permission_rule
WHERE perm_id BETWEEN 456000 AND 456999;
DELETE FROM cb_permission
WHERE perm_id BETWEEN 456000 AND 456999;
DELETE FROM cb_user_role
WHERE role_id BETWEEN 456000 AND 456999;
DELETE FROM cb_app_role
WHERE role_id BETWEEN 456000 AND 456999;
DELETE FROM cb_app_user
WHERE user_id BETWEEN 456000 AND 456999;
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456101, 'rule_test_hr', 'E456101', 'HR', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456102, 'rule_test_self', 'E2001', 'FIN', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456103, 'rule_test_all', 'E456103', 'HQ', 'Y', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456104, 'rule_test_eq', 'E456104', 'QA', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456105, 'rule_test_ne', 'E456105', 'QA', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456106, 'rule_test_bad_column', 'E456106', 'QA', 'N', 'Y');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456101, 'RULE_TEST_MY_DEPT');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456102, 'RULE_TEST_SELF');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456103, 'RULE_TEST_ALL');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456104, 'RULE_TEST_EQ');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456105, 'RULE_TEST_NE');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456106, 'RULE_TEST_BAD_COLUMN');
INSERT INTO cb_user_role(user_id, role_id) VALUES (456101, 456101);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456102, 456102);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456103, 456103);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456104, 456104);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456105, 456105);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456106, 456106);
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456101, 456101, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456102, 456102, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456103, 456103, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456104, 456104, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456105, 456105, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456106, 456106, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456101, 456101, NULL, 'MY_DEPT', NULL);
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456102, 456102, NULL, 'SELF', NULL);
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456103, 456103, NULL, 'ALL', NULL);
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456104, 456104, 'DOC_ID', '=', '1');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456105, 456105, 'DEPT_CODE', '!=', 'HR');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456106, 456106, 'NO_SUCH_COLUMN', '=', 'x');
COMMIT;
END;
/
PROMPT === Running dynamic VPD filter unit checks ===
DECLARE
PROCEDURE fail(p_label IN VARCHAR2, p_expected IN VARCHAR2, p_actual IN VARCHAR2) IS
BEGIN
RAISE_APPLICATION_ERROR(
-20456,
p_label || ' expected [' || p_expected || '] actual [' || p_actual || ']'
);
END;
PROCEDURE assert_equals(
p_label IN VARCHAR2,
p_user_id IN NUMBER,
p_expected IN VARCHAR2
) IS
v_actual VARCHAR2(32767);
BEGIN
IF p_user_id IS NULL THEN
cb_agent_ctx_pkg.clear_user;
ELSE
cb_agent_ctx_pkg.set_user(p_user_id);
END IF;
v_actual := cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS');
IF v_actual != p_expected THEN
fail(p_label, p_expected, v_actual);
END IF;
DBMS_OUTPUT.PUT_LINE('PASS ' || p_label || ': ' || v_actual);
END;
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_V_SEARCH_DOCUMENTS');
IF INSTR(v_actual, p_expected) = 0 THEN
fail(p_label, p_expected, v_actual);
END IF;
DBMS_OUTPUT.PUT_LINE('PASS ' || p_label || ': ' || v_actual);
END;
BEGIN
assert_equals('NO_CONTEXT_DENIES', NULL, '1 = 0');
assert_contains('MY_DEPT_DEFAULT_COLUMN', 456101, 'DEPT_CODE = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')');
assert_contains('SELF_DEFAULT_COLUMN', 456102, 'OWNER_EMP_NO = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')');
assert_equals('ALL_ALLOWS', 456103, '1 = 1');
assert_contains('EQUALS_RULE', 456104, 'TO_CHAR(DOC_ID) = ''1''');
assert_contains('NOT_EQUALS_RULE', 456105, 'TO_CHAR(DEPT_CODE) <> ''HR''');
assert_equals('BAD_COLUMN_DENIES', 456106, '1 = 0');
cb_agent_ctx_pkg.clear_user;
END;
/
PROMPT === Cleaning dynamic VPD filter unit test data ===
BEGIN
cb_agent_ctx_pkg.clear_user;
DELETE FROM cb_permission_rule
WHERE perm_id BETWEEN 456000 AND 456999;
DELETE FROM cb_permission
WHERE perm_id BETWEEN 456000 AND 456999;
DELETE FROM cb_user_role
WHERE role_id BETWEEN 456000 AND 456999;
DELETE FROM cb_app_role
WHERE role_id BETWEEN 456000 AND 456999;
DELETE FROM cb_app_user
WHERE user_id BETWEEN 456000 AND 456999;
COMMIT;
END;
/
PROMPT === Dynamic VPD filter unit checks passed ===