Compare commits

...

41 Commits

Author SHA1 Message Date
devmrko
a906dd0128 fix #488: improve mobile responsive layout 2026-06-26 09:58:52 +09:00
devmrko
1ea8d34f31 fix #477: add group based role grants 2026-06-26 09:33:49 +09:00
devmrko
3c9bcfdc0a fix #477: remove vpd apply actions from filter policy page 2026-06-26 09:11:20 +09:00
devmrko
74fbd9a2b2 fix #477: use common vpd policy name by default 2026-06-26 07:53:52 +09:00
devmrko
453dac6e5d fix #477: make nav menus persistent toggles 2026-06-26 07:42:57 +09:00
devmrko
ef47893324 fix #477: apply vpd by policy filter template 2026-06-26 07:36:23 +09:00
devmrko
49feccb659 fix #477: clarify ords path meaning in vpd targets 2026-06-26 06:33:04 +09:00
devmrko
696813ea13 fix #477: toggle vpd filter detail 2026-06-26 06:28:07 +09:00
devmrko
d010f2659a fix #477: show target policy filters 2026-06-26 06:21:29 +09:00
devmrko
7fe8d14a40 fix #477: allow manual vpd schema lookup 2026-06-26 06:13:10 +09:00
devmrko
0b81d5b1a1 fix #477: filter vpd targets by schema 2026-06-26 06:07:27 +09:00
devmrko
cf46b4ddd1 fix #477: show vpd filter in policy list 2026-06-26 05:57:17 +09:00
devmrko
f0782a10d9 fix #477: use vpd targets in policy dropdowns 2026-06-26 05:50:25 +09:00
devmrko
e806a92e98 fix #477: default vpd permission filter 2026-06-26 05:44:51 +09:00
devmrko
91b03ed952 fix #477: list vpd target objects 2026-06-26 05:34:56 +09:00
devmrko
6e4a306dcd feat #477: show vpd ords control architecture 2026-06-26 05:17:27 +09:00
devmrko
8d2909e589 fix #475: label vpd targets as db objects 2026-06-26 05:12:07 +09:00
devmrko
b64f06d5d5 feat #475: consolidate vpd apply actions 2026-06-26 04:59:21 +09:00
devmrko
62f8abce5f fix #467: move masking grants into permission wizard 2026-06-26 04:51:22 +09:00
devmrko
3629c84861 feat #468: add permission setup wizard 2026-06-26 04:38:20 +09:00
devmrko
4961083f06 feat #467: separate column sensitivity policies 2026-06-25 22:36:33 +09:00
devmrko
d6c7533edd feat #466: add deny permission effect 2026-06-25 22:24:18 +09:00
devmrko
1f924f67de ux #463: streamline MCP reasoning flow 2026-06-25 22:10:21 +09:00
devmrko
8fddf1033e test #462: add VPD predicate injection checks 2026-06-25 22:05:24 +09:00
devmrko
2379e2c9ec feat #461: add operational status dashboard 2026-06-25 21:55:26 +09:00
devmrko
1869fe46a6 ops #460: add SQLcl environment check runbook 2026-06-25 21:45:41 +09:00
devmrko
ab190bf6fa test #459: document edge scenarios and invalid token smoke 2026-06-25 21:43:09 +09:00
devmrko
76227552d7 test #458: automate ORDS VPD regression matrix 2026-06-25 21:40:10 +09:00
devmrko
e347c1bc2f fix #457: align permission rule UI with VPD filters 2026-06-25 21:28:34 +09:00
devmrko
d97dab34bd fix #456: add VPD predicate rule tests 2026-06-25 21:21:48 +09:00
devmrko
5eaffd6229 fix #424: build VPD filter from stored row rules 2026-06-25 18:21:58 +09:00
devmrko
fab166e309 fix #424: clarify filter editing and VPD apply modes 2026-06-25 17:50:27 +09:00
devmrko
de179e47d5 fix #424: add filter and policy management lists 2026-06-25 17:37:18 +09:00
devmrko
effd29c534 fix #424: add filter policy management tab 2026-06-25 17:31:09 +09:00
devmrko
f98729aa78 fix #424: add schema bulk VPD policy apply 2026-06-25 17:16:15 +09:00
devmrko
6957dc05b9 fix #424: limit VPD form options to VPD policies 2026-06-25 17:08:26 +09:00
devmrko
9a546b7997 fix #424: use dropdowns for VPD policy form 2026-06-25 17:03:44 +09:00
devmrko
1bcb6b6a43 fix #424: add VPD policy registration form 2026-06-25 16:45:08 +09:00
devmrko
7f12530edb fix #424: clarify MCP reasoning workflow 2026-06-25 16:34:24 +09:00
devmrko
63d2af6d80 fix #424: simplify MCP chatbot question entry 2026-06-25 16:28:35 +09:00
devmrko
4d662f7a36 fix #424: set VPD context before ORDS query cursors 2026-06-25 16:19:26 +09:00
87 changed files with 6029 additions and 248 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

@@ -0,0 +1,82 @@
# 설계서: 권한 UI와 VPD filter function의 rule type 의미 정렬 (#457)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #457 · 관련 ADR: 없음
> · 구현 파일: `PermissionService`, `PermissionMapper.xml`, `permissions.html`, `app.js` · 테스트: `PermissionServiceTest`, `mvn test`
## 1. 목적 (Why)
권한 화면에서 저장하는 행규칙의 의미가 실제 `cb_agent_doc_vpd_filter`가 해석하는 predicate와 일치하도록 한다.
## 2. 범위 (Scope)
- **포함**: rule type enum 정렬, `MY_DEPT/SELF` 기본 컬럼 허용, `DEPT/EMP_NO` rule type 추가, 적용 필터 preview 개선, 권한 저장 검증 테스트 보강.
- **제외**: DB VPD 함수 자체 재설계, ORDS handler 변경, 권한 수정 전용 화면 신설.
## 3. 인수조건 (Acceptance Criteria)
- [ ] UI rule type 목록이 VPD 함수 지원 타입과 일치한다: `ALL`, `MY_DEPT`, `SELF`, `DEPT`, `EMP_NO`, `=`, `!=`.
- [ ] `MY_DEPT`는 컬럼을 비우면 `DEPT_CODE`, `SELF`는 컬럼을 비우면 `OWNER_EMP_NO`로 저장 가능하다.
- [ ] `DEPT`, `EMP_NO`, `=`, `!=`는 비교 값이 없으면 저장을 거부한다.
- [ ] 권한 목록의 적용 필터 preview가 실제 함수 의미와 같은 기본 컬럼/컨텍스트를 보여준다.
- [ ] `mvn test`가 통과한다.
## 4. 컨텍스트 & 제약
- `cb_agent_doc_vpd_filter``DEPT``EMP_NO`를 이미 지원한다.
- Java 서비스 검증은 저장 전 사용자 입력을 막는 1차 방어선이다.
- 실제 predicate 보안은 DB 함수에서 다시 fail-closed로 처리한다.
## 5. 아키텍처 개요
```
permissions.html rule input
-> PermissionController.buildRules
-> PermissionService.validateRules
-> cb_permission_rule 저장
-> PermissionMapper filter_preview
-> cb_agent_doc_vpd_filter runtime predicate
```
## 6. 데이터 모델
- `rule_type`: `ALL`, `MY_DEPT`, `SELF`, `DEPT`, `EMP_NO`, `=`, `!=`.
- `rule_column`: 선택값. `MY_DEPT/SELF/DEPT/EMP_NO`는 비어 있으면 VPD 함수의 기본 컬럼을 사용한다.
- `rule_value`: `DEPT`, `EMP_NO`, `=`, `!=`에서 필수.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `validateRules` | 저장 전 rule type/column/value 조합 검증 | `void validateRules(long, List<RuleCommand>)` | objectId, rules | 없음 | AppException | **복잡** |
| `filter_preview SQL` | 저장된 rule을 사람이 읽는 predicate preview로 변환 | MyBatis select fragment | rule rows | text | 없음 | 단순 |
| `syncRuleTypeHints` | rule type별 UI placeholder/상태를 갱신 | JS DOM handler | rule row | DOM update | 없음 | 단순 |
## 8. 흐름 / 알고리즘
1. 사용자가 보호 객체를 선택하면 컬럼 목록을 로드한다.
2. rule type을 선택하면 UI가 기본 컬럼/비교값 필요 여부를 표시한다.
3. 저장 시 서비스가 rule type과 컬럼/값 조합을 검증한다.
4. 저장된 권한 목록에서 실제 VPD 함수 해석과 같은 preview를 보여준다.
## 9. 엣지케이스 & 에러 처리
- `ALL`은 단독만 허용한다.
- `MY_DEPT/SELF`의 컬럼 생략은 허용한다.
- 컬럼이 제공되면 보호 객체 컬럼 목록에 있어야 한다.
- 값이 필요한 타입에서 값이 없으면 저장 거부한다.
## 10. 테스트 계획
- `PermissionServiceTest`에 기본 컬럼 허용과 값 필수 검증 추가.
- `mvn test`.
## 11. 리스크 & 대안 검토
- 선택: Java 검증과 UI hint를 동시에 맞춘다. DB 함수 fail-closed만 의존하면 운영자가 저장 성공 후 조회가 안 되는 원인을 알기 어렵다.
- 대안: UI만 수정. 서버 검증과 불일치가 남아 회귀 위험이 크다.
## 12. 미해결 질문 (Open Questions)
- 권한 수정 전용 화면에서 기존 rule rows를 편집하는 UX는 별도 이슈로 분리할 수 있다.

View File

@@ -0,0 +1,87 @@
# 설계서: VPD/Redaction 회귀 테스트 매트릭스 자동화 (#458)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #458 · 관련 ADR: 없음
> · 구현 파일: `scripts/vpd-ords-regression-test.sh`, `run.sh` · 테스트: `./run.sh backoffice-vpd-ords-test`, `mvn test`
## 1. 목적 (Why)
VPD filter, ORDS handler, Redaction 설정 변경 후에도 Bearer Token별 조회 행 수와 민감 컬럼 노출 결과가 유지되는지 자동으로 검증한다.
## 2. 범위 (Scope)
- **포함**: 임시 bearer token 생성, ORDS POST 호출, 응답 JSON 검증, token revoke, 실패 증거 파일 저장.
- **제외**: ORDS metadata 생성, VPD policy 생성, UI E2E 브라우저 테스트.
## 3. 인수조건 (Acceptance Criteria)
- [ ] SQLcl로 임시 token을 생성하고 종료 시 revoke한다.
- [ ] HR token은 3행 `[1,2,6]`, `CONTENTS` 미노출을 검증한다.
- [ ] SELF token은 1행 `[3]`, `CONTENTS` 미노출을 검증한다.
- [ ] ALL token은 6행 `[1,2,3,4,5,6]`, `CONTENTS` 노출을 검증한다.
- [ ] 실패 시 request/response 파일 위치를 출력한다.
- [ ] `./run.sh backoffice-vpd-ords-test`로 실행할 수 있다.
## 4. 컨텍스트 & 제약
- SQLcl이 필요하다. `SQLCL_BIN`이 있으면 그 값을 쓰고, 없으면 PATH의 `sql`을 찾는다.
- DB 접속은 `.env``BACKOFFICE_DB_USERNAME`, `BACKOFFICE_DB_PASSWORD`, `ADB_TNS`를 사용한다.
- ORDS base URL은 `BACKOFFICE_ORDS_BASE_URL`이 있으면 쓰고, 없으면 현재 백오피스 기본값을 사용한다.
- token 원문은 출력하지 않는다.
## 5. 아키텍처 개요
```
run.sh backoffice-vpd-ords-test
-> scripts/vpd-ords-regression-test.sh
-> SQLcl INSERT temp tokens
-> curl POST ORDS documents endpoint
-> Python JSON assertions
-> SQLcl revoke temp tokens
```
## 6. 데이터 모델
- 테스트 token description: `codex issue458 regression`.
- 사용자 매트릭스:
- `101`: HR 부서 권한
- `102`: SELF 권한
- `103`: ALL 권한 + `CONTENTS` 노출 권한
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `run_sqlcl` | SQLcl에 SQL stdin을 전달 | bash function | SQL text | SQLcl output | SQLcl exit code | 단순 |
| `call_and_assert` | ORDS 호출 후 JSON 결과 검증 | bash function | label, token, expected rows | PASS/FAIL | 실패 시 exit 1 | **복잡** |
## 8. 흐름 / 알고리즘
1. `.env`를 로드한다.
2. SQLcl/JDK/DB/ORDS 환경을 확인한다.
3. UUID 기반 token 3개를 생성한다.
4. SQLcl로 token hash를 DB에 insert한다.
5. ORDS endpoint를 호출해 `items` 행 수, DOC_ID, CONTENTS 노출 수를 검증한다.
6. 성공/실패와 관계없이 token을 revoke한다.
## 9. 엣지케이스 & 에러 처리
- SQLcl 없음: 실행 전 명확한 메시지로 실패.
- ORDS 200 아님: response body 파일 경로 출력.
- JSON parse 실패: response body 일부와 파일 경로 출력.
- 중간 실패: cleanup trap으로 token revoke 시도.
## 10. 테스트 계획
- `SQLCL_BIN=/tmp/sqlcl/sqlcl/bin/sql ./run.sh backoffice-vpd-ords-test`
- `mvn test`
## 11. 리스크 & 대안 검토
- 선택: bash + SQLcl + curl + Python 표준 json. 배포 의존성을 추가하지 않고 운영자가 그대로 실행할 수 있다.
- 대안: JUnit 통합 테스트. Maven에서 외부 ORDS/ADB를 항상 요구하게 되어 로컬 단위 테스트가 불안정해진다.
## 12. 미해결 질문 (Open Questions)
- SQLcl/JDK 경로 자동 탐지는 #460 운영 런북에서 더 정리한다.

View File

@@ -0,0 +1,75 @@
# 설계서: 권한 조합/경계값/오류 메시지 시나리오 확장 (#459)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #459 · 관련 ADR: 없음
> · 구현 파일: `docs/testing/459-permission-edge-scenarios.md`, `scripts/vpd-ords-regression-test.sh` · 테스트: `./run.sh backoffice-vpd-ords-test`, `mvn test`
## 1. 목적 (Why)
정상 권한 매트릭스뿐 아니라 잘못된 token, 권한 조합, 컬럼/값 누락, ORDS path 불일치 같은 운영 중 자주 발생할 경계 시나리오를 테스트 항목으로 고정한다.
## 2. 범위 (Scope)
- **포함**: 수동/E2E 테스트 체크리스트 문서, invalid bearer token 자동 smoke, 기대 오류 관점 정리.
- **제외**: Playwright 브라우저 자동화, 모든 UI 입력 케이스 자동화.
## 3. 인수조건 (Acceptance Criteria)
- [ ] 권한 조합/경계값/오류 메시지 체크리스트가 문서화된다.
- [ ] 자동 ORDS smoke에 invalid token 실패 검증이 포함된다.
- [ ] 실패 시 request/response evidence 경로가 출력된다.
- [ ] `mvn test`가 통과한다.
## 4. 컨텍스트 & 제약
- ORDS 정상 매트릭스는 #458 스크립트를 재사용한다.
- UI 입력 검증은 #457에서 일부 보강됐고, 여기서는 테스트 관점에서 확인 항목을 정리한다.
## 5. 아키텍처 개요
```
docs/testing/459-permission-edge-scenarios.md
-> 수동/운영 테스트 체크리스트
scripts/vpd-ords-regression-test.sh
-> 정상 HR/SELF/ALL 검증
-> invalid token smoke 검증
```
## 6. 데이터 모델
- invalid token: DB에 등록하지 않은 UUID token.
- 기대 결과: HTTP 200이 아니어야 하며, 성공 rows로 해석되면 실패.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `call_and_assert_rejected` | 잘못된 token이 성공 조회로 처리되지 않는지 검증 | bash function | label, token | PASS/FAIL | HTTP 200이면 실패 | 단순 |
## 8. 흐름 / 알고리즘
1. 정상 매트릭스 HR/SELF/ALL을 검증한다.
2. 등록되지 않은 token으로 같은 ORDS endpoint를 호출한다.
3. HTTP 200이면 실패, 아니면 거부 smoke PASS로 처리한다.
4. request/response evidence를 남긴다.
## 9. 엣지케이스 & 에러 처리
- invalid token이 ORDS 403을 반환하는지 확인한다.
- ORDS 404/500도 성공 조회가 아니므로 이 smoke에서는 거부로 본다. 상세 오류 분류는 백오피스 probe classifier에서 검증한다.
## 10. 테스트 계획
- `SQLCL_BIN=/tmp/sqlcl/sqlcl/bin/sql SQLCL_JAVA_HOME=... ./run.sh backoffice-vpd-ords-test`
- `mvn test`
## 11. 리스크 & 대안 검토
- 선택: invalid token smoke를 기존 회귀 스크립트에 포함한다. 테스트 실행 시간이 거의 늘지 않고 보안 기본 동작을 고정할 수 있다.
- 대안: 별도 스크립트. 중복 token/ORDS 설정 로직이 생긴다.
## 12. 미해결 질문 (Open Questions)
- 브라우저 기반 권한 등록/삭제 자동화는 별도 E2E 도입 시 다룬다.

View File

@@ -0,0 +1,82 @@
# 설계서: SQLcl 기반 배포/롤백 런북 및 환경 점검 명령 정리 (#460)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #460 · 관련 ADR: 없음
> · 구현 파일: `scripts/check-sqlcl-backoffice-env.sh`, `docs/runbooks/460-sqlcl-vpd-deploy-runbook.md`, `run.sh` · 테스트: `./run.sh backoffice-env-check`
## 1. 목적 (Why)
운영자가 신규 DB/ORDS 환경에서 SQLcl, JDK, ADB 접속, ORDS endpoint를 적용 전에 점검하고 VPD filter 배포/검증/롤백 순서를 확인할 수 있게 한다.
## 2. 범위 (Scope)
- **포함**: SQLcl/JDK/DB/ORDS 환경 점검 스크립트, 배포/검증/롤백 런북, `run.sh backoffice-env-check` 명령.
- **제외**: SQLcl 설치 자동화, JDK 설치 자동화, ORDS metadata 자동 생성.
## 3. 인수조건 (Acceptance Criteria)
- [ ] SQLcl 실행 파일과 버전을 확인한다.
- [ ] Java 버전을 확인한다.
- [ ] DB 접속 사용자와 `CB_AGENT_DOC_VPD_FILTER` 상태를 확인한다.
- [ ] ORDS base URL과 documents endpoint의 HTTP 상태를 확인한다.
- [ ] 운영자가 실행할 배포/검증/롤백 순서가 문서화된다.
## 4. 컨텍스트 & 제약
- `.env`는 민감 정보를 포함하므로 점검 출력에 password/token을 출력하지 않는다.
- SQLcl 경로는 `SQLCL_BIN` 우선, 없으면 PATH의 `sql`을 사용한다.
- JDK는 `SQLCL_JAVA_HOME`이 있으면 `JAVA_HOME`으로 사용한다.
## 5. 아키텍처 개요
```
run.sh backoffice-env-check
-> scripts/check-sqlcl-backoffice-env.sh
-> SQLcl version
-> Java version
-> SELECT USER, function status
-> curl ORDS endpoint
```
## 6. 데이터 모델
- 입력 환경변수: `SQLCL_BIN`, `SQLCL_JAVA_HOME`, `BACKOFFICE_DB_USERNAME`, `BACKOFFICE_DB_PASSWORD`, `ADB_TNS`, `BACKOFFICE_ORDS_BASE_URL`.
- 출력: PASS/FAIL 로그, 민감값 마스킹.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `resolve_sqlcl` | SQLcl 실행 파일 찾기 | bash function | env/path | path | 없으면 exit 1 | 단순 |
| `check_db` | DB 접속과 function 상태 확인 | bash function | SQLcl connection | PASS/FAIL | SQLcl exit code | 단순 |
| `check_ords` | ORDS endpoint HTTP 상태 확인 | bash function | base url/path | PASS/WARN | curl status | 단순 |
## 8. 흐름 / 알고리즘
1. `.env`를 로드한다.
2. SQLcl 경로와 Java 버전을 출력한다.
3. DB 접속 필수값 존재 여부를 확인한다.
4. SQLcl로 현재 사용자와 VPD function 상태를 조회한다.
5. ORDS endpoint에 GET을 보내 상태 코드를 확인한다.
6. 다음 실행 명령을 안내한다.
## 9. 엣지케이스 & 에러 처리
- SQLcl 없음: `SQLCL_BIN=/path/to/sql` 안내.
- DB 접속 실패: DB 설정 확인 메시지.
- ORDS 404: base URL/path 확인 경고.
- ORDS 401/403: endpoint는 살아 있으나 인증 필요 상태로 안내.
## 10. 테스트 계획
- `SQLCL_BIN=/tmp/sqlcl/sqlcl/bin/sql SQLCL_JAVA_HOME=... ./run.sh backoffice-env-check`
## 11. 리스크 & 대안 검토
- 선택: bash 점검 스크립트. 운영자가 Java/Maven 없이도 SQLcl/curl만으로 확인 가능하다.
- 대안: Spring Boot health endpoint. 앱이 뜨기 전 문제를 잡기 어렵다.
## 12. 미해결 질문 (Open Questions)
- SQLcl을 repo 외부 어디에 표준 설치할지는 사용자 로컬/운영 환경 정책에 따른다.

View File

@@ -0,0 +1,89 @@
# 설계서: ORDS handler와 VPD filter 적용 상태 관측성 강화 (#461)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #461 · 관련 ADR: 없음
> · 구현 파일: `OperationStatusMapper`, `OperationStatusService`, `OperationStatusController`, `operation-status.html` · 테스트: `mvn test`, Spring Boot 기동
## 1. 목적 (Why)
보호 객체별 ORDS path, handler 존재 여부, VPD policy/function 상태, 권한 rule 수, 최근 ORDS 검증 결과를 한 화면에서 확인하게 한다.
## 2. 범위 (Scope)
- **포함**: 운영 상태 탭 추가, 보호 객체 기준 상태 집계, 상태 badge/조치 문구 표시.
- **제외**: handler source inline 편집, 자동 probe 실행, 장기 검증 로그 검색.
## 3. 인수조건 (Acceptance Criteria)
- [ ] 보호 객체마다 ORDS path와 handler 매칭 상태를 보여준다.
- [ ] VPD policy enabled 여부와 policy function status를 보여준다.
- [ ] permission/rule count와 최근 probe 상태/row count를 보여준다.
- [ ] 문제가 있는 항목에는 조치 문구를 표시한다.
- [ ] 운영 메뉴에서 접근 가능하다.
## 4. 컨텍스트 & 제약
- ORDS metadata는 기존 `dba_ords_*` view를 조회한다.
- 최근 검증은 `cb_ords_probe_audit`의 최신 row를 사용한다.
- VPD status는 `all_policies`, `all_objects`를 사용한다.
## 5. 아키텍처 개요
```
/operation-status
-> OperationStatusController
-> OperationStatusService
-> OperationStatusMapper.findRows()
cb_protected_object
+ all_policies/all_objects
+ dba_ords_* metadata
+ cb_permission/cb_permission_rule
+ cb_ords_probe_audit latest row
```
## 6. 데이터 모델
- `OperationStatusRow`: 한 보호 객체의 end-to-end 상태.
- health:
- `OK`: handler, enabled policy, valid function이 모두 확인됨.
- `WARN`: 일부 미설정 또는 최근 검증 실패.
- `ERROR`: function invalid 등 즉시 조치 필요.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `findRows` | 보호 객체별 운영 상태를 DB에서 집계 | mapper select | 없음 | rows | DB 예외 | **복잡** |
| `healthLevel` | row 상태를 OK/WARN/ERROR로 분류 | Java method | row fields | string | 없음 | 단순 |
| `actionText` | 운영자가 볼 조치 문구 생성 | Java method | row fields | string | 없음 | 단순 |
## 8. 흐름 / 알고리즘
1. enabled 보호 객체를 조회한다.
2. object별 permission/rule count를 집계한다.
3. object별 VPD policy와 function status를 left join한다.
4. ORDS path와 metadata full path를 비교해 handler 상태를 찾는다.
5. 최신 audit row를 붙인다.
6. 화면에서 health와 action text를 계산해 표시한다.
## 9. 엣지케이스 & 에러 처리
- ORDS metadata 권한이 없으면 페이지 전체가 DB 예외를 받을 수 있다. 기존 ORDS handler 화면과 같은 전제다.
- policy가 여러 개면 policy 이름을 `LISTAGG`로 보여준다.
- 최근 probe가 없으면 `검증 이력 없음`으로 표시한다.
## 10. 테스트 계획
- `mvn test`
- Spring Boot 재기동
- `/operation-status` HTTP 200 확인
## 11. 리스크 & 대안 검토
- 선택: DB join 기반 read-only 화면. 실행 비용이 낮고 운영자가 한 번에 상태를 볼 수 있다.
- 대안: 각 화면 fragment를 iframe/htmx로 모으기. 중복 조회가 많고 상태 판정이 분산된다.
## 12. 미해결 질문 (Open Questions)
- 최신 probe 상세 request/response 저장은 현재 audit 테이블에 없으므로 별도 이슈로 확장할 수 있다.

View File

@@ -0,0 +1,82 @@
# 설계서: Predicate injection 및 권한 우회 방어 테스트 (#462)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #462 · 관련 ADR: 없음
> · 구현 파일: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: SQLcl `27`, `./run.sh backoffice-vpd-ords-test`, `mvn test`
## 1. 목적 (Why)
VPD predicate function이 DB에 저장된 `rule_column`/`rule_value`를 SQL 문자열로 변환하므로, quote/OR/주석/함수형 컬럼명 입력이 권한 우회로 이어지지 않는지 테스트로 고정한다.
## 2. 범위 (Scope)
- **포함**: `rule_value` injection 문자열 escape 검증, `rule_column` injection fail-closed 검증, target mismatch 기본 거부 검증.
- **제외**: UI 입력 fuzzing, ORDS response SQL 노출 정책 변경.
## 3. 인수조건 (Acceptance Criteria)
- [ ] `rule_value`에 single quote가 있어도 literal escape가 적용된다.
- [ ] `rule_value``OR 1=1` 또는 `--`가 있어도 predicate 구조를 탈출하지 않는다.
- [ ] `rule_column`에 함수 호출/표현식이 들어가면 해당 rule은 무시되고 `1 = 0`이 된다.
- [ ] `p_object`가 권한의 `target_name`과 다르면 `1 = 0`이 된다.
- [ ] 기존 정상/경계 테스트와 ORDS 회귀 테스트가 유지된다.
## 4. 컨텍스트 & 제약
- `safe_column``DBMS_ASSERT.SIMPLE_SQL_NAME``all_tab_columns` 존재 확인을 사용한다.
- `quote_literal`은 single quote를 두 번으로 escape한다.
- 테스트 데이터는 `456000~456999` 범위를 재사용한다.
## 5. 아키텍처 개요
```
malicious cb_permission_rule rows
-> cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS')
-> assert predicate contains escaped literal or equals 1 = 0
```
## 6. 데이터 모델
- injection users:
- `456107`: quote 포함 값
- `456108`: `OR 1=1 --` 포함 값
- `456109`: 함수형 컬럼명
- `456110`: target mismatch 확인
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `assert_contains_for_object` | 특정 object에 대한 predicate 포함 검증 | PL/SQL local procedure | label, user, object, expected | 없음 | raise_application_error | 단순 |
| `assert_equals_for_object` | 특정 object에 대한 predicate 일치 검증 | PL/SQL local procedure | label, user, object, expected | 없음 | raise_application_error | 단순 |
## 8. 흐름 / 알고리즘
1. 악성 문자열이 들어간 test user/role/permission/rule을 생성한다.
2. predicate function을 직접 호출한다.
3. 값 기반 injection은 quote escaped literal로 남는지 확인한다.
4. 컬럼 기반 injection은 `1 = 0`이 되는지 확인한다.
5. target mismatch는 `1 = 0`인지 확인한다.
## 9. 엣지케이스 & 에러 처리
- `TITLE = 'HR' OR '1'='1'` 형태 문자열은 전체가 literal 값이어야 한다.
- `DOC_ID) OR 1=1 --` 같은 컬럼명은 컬럼 검증 실패로 무시되어야 한다.
- 모든 rule이 무시되면 whitelist 기본값 `1 = 0`이어야 한다.
## 10. 테스트 계획
- `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
- `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
- `./run.sh backoffice-vpd-ords-test`
- `mvn test`
## 11. 리스크 & 대안 검토
- 선택: DB 함수 직접 테스트. 공격 문자열이 실제 predicate 문자열로 어떻게 변환되는지 가장 직접적으로 확인할 수 있다.
- 대안: ORDS만 테스트. 결과 행 수는 확인 가능하지만 predicate escaping 자체를 확인하기 어렵다.
## 12. 미해결 질문 (Open Questions)
- 더 넓은 fuzzing은 별도 보안 테스트 체계가 필요할 때 확장한다.

View File

@@ -0,0 +1,83 @@
# 설계서: MCP Reasoning/ORDS 검증 탭 사용 흐름 정리 (#463)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #463 · 관련 ADR: 없음
> · 구현 파일: `mcp-reasoning.html`, `mcp-reasoning-result.html`, `probe.html`, `McpReasoningService` · 테스트: `mvn test`, Spring Boot 기동
## 1. 목적 (Why)
운영자가 보호 객체와 Bearer Token을 선택한 뒤 ORDS 검증과 MCP Reasoning을 자연스럽게 이어서 실행하고, 모델 답변을 표/요약 중심으로 읽을 수 있게 한다.
## 2. 범위 (Scope)
- **포함**: MCP Reasoning 기본 질문/원클릭 질문 개선, 결과 Markdown 렌더링, 실행 증거 요약, ORDS 검증에서 Reasoning 이동 안내.
- **제외**: MCP protocol 자체 변경, LLM 모델 변경, token 자동 공유.
## 3. 인수조건 (Acceptance Criteria)
- [ ] Reasoning 답변이 Markdown view로 렌더링되어 표가 표시된다.
- [ ] Prompt가 요약 먼저, 상세 나중 구조를 요구한다.
- [ ] 질문 없이 실행해도 권한 검증에 맞는 기본 질문이 사용된다.
- [ ] ORDS 검증 화면에서 같은 흐름의 다음 단계로 Reasoning 탭을 인지할 수 있다.
- [ ] `mvn test`와 Spring Boot 기동이 통과한다.
## 4. 컨텍스트 & 제약
- token 원문은 화면 이동으로 자동 전달하지 않는다.
- Reasoning은 실제 ORDS 호출 결과를 evidence JSON으로 모델에 전달한다.
- Markdown 렌더러는 기존 `data-markdown-view`를 사용한다.
## 5. 아키텍처 개요
```
ORDS 검증
-> 결과 확인
-> MCP Reasoning 이동
-> 보호 객체 선택
-> token 입력
-> 기본 질문 또는 원클릭 질문
-> ORDS tool 실행
-> Markdown answer + evidence 표시
```
## 6. 데이터 모델
- `McpReasoningResult.answer`: Markdown text.
- `ProbeResult`: request/response/evidence table의 근거.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `buildPrompt` | 모델에 전달할 업무형 권한 분석 prompt 생성 | Java method | question, tool, evidence | prompt | 없음 | 단순 |
| `fallbackAnswer` | AI 미설정 시 evidence 기반 기본 설명 생성 | Java method | question, probeResult | Markdown text | 없음 | 단순 |
## 8. 흐름 / 알고리즘
1. 질문이 비어 있으면 기본 권한 검증 질문을 사용한다.
2. ORDS probe를 실행한다.
3. evidence JSON을 구성한다.
4. 모델 답변은 요약, 판단 근거 표, 상세, 다음 조치 순서로 요청한다.
5. 화면은 answer를 Markdown으로 렌더링한다.
## 9. 엣지케이스 & 에러 처리
- AI 미설정: Markdown fallback으로 ORDS 상태와 row count를 보여준다.
- ORDS 실패: 모델 호출 여부와 무관하게 request/response evidence를 표시한다.
- token 원문은 prompt/evidence에 포함하지 않는다.
## 10. 테스트 계획
- `mvn test`
- Spring Boot 재기동
- `/mcp-reasoning` 인증 요청 HTTP 200 확인
## 11. 리스크 & 대안 검토
- 선택: 화면/프롬프트 개선. 데이터 모델 변경 없이 사용 흐름을 개선한다.
- 대안: wizard UI. 구현 범위가 커지고 기존 탭 구조를 크게 바꿔야 한다.
## 12. 미해결 질문 (Open Questions)
- token 선택 드롭다운과 token 원문 자동 복호화는 보안 정책상 별도 검토가 필요하다.

View File

@@ -0,0 +1,30 @@
# 설계서: 역할/권한 모델에 deny, 우선순위, 충돌 해소 규칙 추가 (#466)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
> **추적성** — Redmine: #466 · 관련 ADR: 없음
> · 구현 파일: `cb_permission.permission_effect`, `PermissionService`, `PermissionMapper`, `cb_agent_doc_vpd_filter` · 테스트: SQLcl `27`, `mvn test`
## 1. 목적
역할이 여러 개 붙은 사용자에게 allow 권한과 deny 권한이 동시에 존재할 때 최종 접근 범위를 명확히 계산한다.
## 2. 범위
- 포함: permission 단위 `ALLOW/DENY` 효과 추가, deny 우선 VPD predicate 생성, UI 저장/목록 표시, 테스트.
- 제외: role 자체 우선순위 숫자, 승인 workflow, rule별 deny.
## 3. 인수조건
- [ ] `cb_permission.permission_effect``ALLOW` 기본값으로 추가된다.
- [ ] deny permission은 같은 object/action의 allow 결과에서 제외된다.
- [ ] deny `ALL`이 있으면 최종 결과는 `1 = 0`이다.
- [ ] allow가 없으면 deny가 있어도 `1 = 0`이다.
- [ ] UI에서 권한 효과를 선택하고 목록에서 확인할 수 있다.
## 4. 설계 결정
- 효과는 `cb_permission` 단위로 둔다. 하나의 permission set이 allow인지 deny인지 명확히 구분한다.
- 최종 predicate는 `(<allow predicate>) AND NOT (<deny predicate>)`이다.
- allow `ALL``1 = 1`, deny `ALL`은 즉시 `1 = 0`이다.
- deny만 있고 allow가 없으면 whitelist 기본값에 따라 `1 = 0`이다.

View File

@@ -0,0 +1,45 @@
# #467 컬럼 민감도와 redaction 정책 분리
## 배경
기존 모델은 `cb_permission_column`에 포함된 컬럼을 "NULL 처리 예외"로 해석했다. 이 방식은 역할별 표시 예외는 표현할 수 있지만, 컬럼 자체의 데이터 민감도와 masking 방식을 별도로 관리하기 어렵다.
## 결정
- `cb_protected_column`을 컬럼 정책의 기준 테이블로 유지한다.
- `sensitivity_level`을 추가해 컬럼 데이터 등급을 관리한다.
- `PUBLIC`
- `INTERNAL`
- `CONFIDENTIAL`
- `RESTRICTED`
- `redaction_method`를 추가해 masking 방식을 관리한다.
- `NONE`
- `NULLIFY`
- `PARTIAL`
- `FULL`
- `cb_app_role.max_sensitivity_level`을 추가해 역할별 민감도 허용 상한을 설정한다.
- `cb_permission_column`은 기존 호환을 위해 "해당 권한에서 redaction 예외로 표시 가능한 컬럼" 의미로 유지한다.
## Migration
기존 `sensitive_yn = 'Y'` 컬럼은 `CONFIDENTIAL/NULLIFY`로 보정한다. 기존 `sensitive_yn = 'N'` 컬럼은 `PUBLIC/NONE`을 기본값으로 둔다. 운영자가 실제 업무 기준에 따라 `INTERNAL/NONE`, `RESTRICTED/FULL` 등으로 조정한다.
## UI 반영
- ORDS 조회 Handler 대상 화면에서 객체별 기본 컬럼 정책을 조회/수정한다.
- 역할 화면에서 `max_sensitivity_level`을 조회/수정한다.
- 권한 관리 wizard에서 역할이 해당 보호 객체를 조회할 때 원문 표시를 허용할 마스킹 컬럼을 선택한다.
- ORDS 검증 결과의 masked 컬럼은 `contents [CONFIDENTIAL/NULLIFY]`처럼 정책 라벨을 같이 표시한다.
## 권한 부여 시 컬럼 마스킹 처리
컬럼 마스킹의 기준 정보는 보호 객체 컬럼에 둔다. 하지만 실제 원문 표시 허용은 역할과 보호 객체를 묶는 권한에 붙인다. 따라서 운영자는 권한을 줄 때 다음 순서로 판단한다.
1. 보호 객체의 기본 컬럼 민감도/마스킹 정책을 확인한다.
2. 역할의 민감도 허용 상한을 확인한다.
3. 권한 wizard에서 해당 역할에 원문 표시를 허용할 컬럼만 선택한다.
4. 선택하지 않은 마스킹 대상 컬럼은 기본 정책대로 NULL 처리 또는 마스킹된다.
## 남은 판단
`cb_permission_column``max_sensitivity_level`의 최종 런타임 결합 방식은 다음 단계에서 결정한다. 현재는 기존 Oracle Redaction expression과 호환되도록 표시 예외 구조를 유지한다.

View File

@@ -0,0 +1,34 @@
# #468 권한 등록 Wizard
## 배경
권한 등록은 역할, 보호 객체, 허용/거부, 행 규칙, 컬럼 redaction 예외를 한 번에 설정해야 한다. 기존 단일 폼은 빠르게 입력할 수 있지만 신규 운영자가 순서를 놓치기 쉽고, 저장 전에 최종 정책 의미를 확인하기 어렵다.
## 결정
- 서버 저장 API(`/permissions`)는 유지한다.
- 권한 추가 영역만 단계형 wizard UI로 재구성한다.
- 기존 권한 목록은 그대로 유지한다.
- 단계는 다음 순서로 고정한다.
1. 역할 선택
2. 보호 객체 선택
3. 권한 효과와 행 규칙
4. 컬럼 NULL 처리 예외
5. 저장 전 검토
- 저장 전 검토 단계에서 effective policy preview를 표시한다.
## Preview 규칙
- `ALLOW + ALL`: 모든 행 조회 허용
- `ALLOW + 조건`: 조건에 맞는 행만 조회 허용
- `DENY + ALL`: 해당 역할의 객체 조회 거부
- `DENY + 조건`: 조건에 맞는 행을 제외
- 컬럼 예외가 비어 있으면 민감 컬럼은 NULL 처리 대상으로 설명한다.
- 컬럼 예외가 있으면 해당 컬럼을 NULL 처리 예외로 표시한다.
## 완료 기준
- `/permissions`에서 단계 이동과 요약 갱신이 동작한다.
- 기존 저장 POST parameter 이름은 유지한다.
- 행 규칙 컬럼 동적 조회와 규칙 추가/삭제가 계속 동작한다.
- Maven 테스트와 인증 화면 렌더링 확인을 통과한다.

View File

@@ -0,0 +1,22 @@
# #475 VPD 적용 화면 통합
## 배경
VPD 적용은 보호 객체 하나를 선택해서 policy를 붙이는 개별 적용이 기본이다. 벌크 적용은 같은 filter/function을 스키마의 여러 TABLE/VIEW에 확장할 때 사용하는 보조 기능이다.
기존 화면은 `/vpd-policies`가 적용 현황 위주이고, 실제 개별/벌크 적용 폼은 `/vpd-filter-policies`에 있어 운영 흐름이 맞지 않았다.
## 결정
- `/vpd-policies`를 VPD 적용의 중심 화면으로 둔다.
- 개별 적용 폼을 먼저 배치한다.
- VPD 적용 대상은 ORDS path가 아니라 DB TABLE/VIEW다.
- ORDS는 VPD가 적용된 TABLE/VIEW를 HTTP 경로로 서빙하고 검증하는 계층으로만 표시한다.
- 벌크 적용은 같은 화면의 접힌 영역으로 둔다.
- `/vpd-filter-policies`는 filter function 등록/수정과 고급 policy 수정 화면으로 유지한다.
## 완료 기준
- 운영자가 VPD 설정 화면에서 바로 개별 적용을 수행할 수 있다.
- 같은 화면에서 필요 시 벌크 적용을 펼쳐 실행할 수 있다.
- 기존 controller/service API는 유지한다.

View File

@@ -0,0 +1,26 @@
# #477 Oracle VPD/ORDS와 권한 테이블 제어 구조 표시
## 배경
이 백오피스는 Oracle Database의 VPD와 ORDS 기능을 새로 구현하는 도구가 아니다. Oracle이 제공하는 기능을 사용하되, 애플리케이션 권한 테이블로 제어하고 운영자가 결과를 확인할 수 있게 만든 도구다.
## 구조
1. Backoffice Tables
- 사용자, 역할, 권한, 행 규칙, 컬럼 원문 허용을 저장한다.
- VPD policy function이 이 테이블을 조회해 predicate를 만든다.
2. Oracle Database VPD
- TABLE/VIEW에 policy function을 붙인다.
- SELECT 시점에 DB가 행 접근을 제한한다.
3. ORDS
- VPD가 적용된 TABLE/VIEW를 HTTP API로 서빙한다.
- Bearer Token을 받아 DB session context를 세팅하고 결과를 확인한다.
## UX 결정
- 대시보드에 전체 구조를 표시한다.
- 권한 관리 화면은 Backoffice Tables 레이어를 강조한다.
- VPD 설정 화면은 Oracle Database VPD 레이어를 강조한다.
- ORDS Handler 생성과 ORDS 검증 화면은 ORDS 서빙/검증 레이어를 강조한다.
- VPD 적용 대상은 Oracle DB catalog 기준의 TABLE/VIEW 목록으로 표시한다.
- VPD 화면에서는 VPD 적용 여부를 중심으로 보여주고, 백오피스 권한 테이블 등록 여부와 ORDS Path는 서빙 상태 참고 정보로 함께 표시한다.

View File

@@ -0,0 +1,43 @@
# Redmine #488 - 모바일 VPD/권한 화면 반응형 개선 설계
## 프로젝트 개요
VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이블로 제어하기 위한 Spring Boot 관리 도구다. 사용자는 사용자, 그룹, 역할, TABLE/VIEW 권한, VPD policy/filter, Bearer Token, ORDS 검증, MCP Reasoning을 한 화면 흐름에서 운영한다.
## 목표
390px 모바일 viewport에서 `VPD 설정``권한 관리` 화면이 가로로 밀리지 않고, 아키텍처 설명 카드와 테이블/폼이 읽을 수 있는 형태로 표시되게 한다.
## 문제
캡처 기준:
- `/tmp/vpd-ux-audit/screenshots/mobile-vpd-policies.png`
- `/tmp/vpd-ux-audit/screenshots/mobile-permissions.png`
확인된 문제:
- `architecture-strip` 모바일 override가 기본 grid 선언보다 앞에 있어 390px에서도 5열 grid가 유지된다.
- 일부 grid/table item이 `min-width:auto`로 계산되어 `.table-responsive` 바깥의 body 폭을 밀어낸다.
- 행 규칙 grid가 모바일에서도 4열을 유지해 입력 컨트롤이 좁아진다.
## 설계
- 공통 CSS에서 architecture strip의 모바일 override를 실제 선언 뒤쪽에 다시 배치한다.
- card, form, table wrapper에 `min-width: 0`을 부여해 내부 overflow가 body 폭을 밀지 않게 한다.
- 모바일에서 큰 table은 body가 아니라 wrapper 내부에서만 horizontal scroll 되게 한다.
- 모바일에서 `form-grid`, `rule-row`, `section-heading`은 1열 또는 wrap 형태로 전환한다.
## 완료 기준
- 390px viewport에서 `document.documentElement.scrollWidth <= window.innerWidth`가 성립한다.
- `VPD 설정`, `권한 관리` 모바일 캡처에서 architecture strip이 1열로 표시된다.
- 테이블은 화면 밖으로 body를 밀지 않고 wrapper 내부에서 스크롤된다.
- 1440px 데스크톱 화면의 정보 밀도는 유지된다.
## 검증
- `mvn test`
- Playwright 캡처 또는 DOM 검사:
- `/vpd-policies`
- `/permissions`

View File

@@ -0,0 +1,78 @@
# SQLcl VPD 배포/검증/롤백 런북 (#460)
## 사전 점검
```bash
SQLCL_BIN=/path/to/sql \
SQLCL_JAVA_HOME=/path/to/jdk \
./run.sh backoffice-env-check
```
확인 항목:
- SQLcl 버전이 출력된다.
- Java 11 이상이 출력된다.
- DB 현재 사용자가 의도한 schema다. 예: `ADMIN`.
- `CB_AGENT_DOC_VPD_FILTER` 상태가 `VALID`다.
- ORDS endpoint가 200/401/403 중 하나를 반환한다. 404면 base URL 또는 path가 맞지 않는다.
## VPD filter 배포
```bash
SQLCL_BIN=/path/to/sql SQLCL_JAVA_HOME=/path/to/jdk "$SQLCL_BIN" "USER/PASSWORD@TNS"
```
SQLcl 안에서 실행:
```sql
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter
@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql
```
기대 결과:
- function compile 성공
- `SHOW ERRORS` 결과 없음
- `Dynamic VPD filter unit checks passed`
## ORDS 회귀 검증
```bash
SQLCL_BIN=/path/to/sql \
SQLCL_JAVA_HOME=/path/to/jdk \
./run.sh backoffice-vpd-ords-test
```
기대 결과:
- HR: 3행, `DOC_ID=[1,2,6]`, `CONTENTS` 미노출
- SELF: 1행, `DOC_ID=[3]`, `CONTENTS` 미노출
- ALL: 6행, `DOC_ID=[1,2,3,4,5,6]`, `CONTENTS` 노출
- INVALID_TOKEN: HTTP 403 등 성공이 아닌 응답
## 장애 시 확인 순서
1. `./run.sh backoffice-env-check`로 SQLcl/JDK/DB/ORDS 상태를 먼저 확인한다.
2. DB function이 `INVALID``SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter`를 본다.
3. ORDS가 404면 `BACKOFFICE_ORDS_BASE_URL``cb_protected_object.ords_path`를 비교한다.
4. ORDS가 403이면 token hash, 만료시각, revoke 여부를 확인한다.
5. 행 수가 맞지 않으면 `cb_user_role`, `cb_permission`, `cb_permission_rule`을 확인한다.
## 롤백
가장 단순한 롤백은 직전 Git revision의 `26_agent_ords_security_dynamic_vpd_filter.sql`을 다시 적용하는 것이다.
```bash
git show HEAD~1:sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql > /tmp/previous-vpd-filter.sql
SQLCL_BIN=/path/to/sql SQLCL_JAVA_HOME=/path/to/jdk "$SQLCL_BIN" "USER/PASSWORD@TNS"
```
SQLcl 안에서:
```sql
@/tmp/previous-vpd-filter.sql
SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter
```
롤백 후에도 반드시 ORDS 회귀 검증을 다시 실행한다.

View File

@@ -0,0 +1,56 @@
# 권한 조합/경계값/오류 메시지 테스트 체크리스트 (#459)
## 목적
VPD/ORDS 권한 백오피스에서 권한 저장, token 검증, ORDS 호출 실패가 운영자가 이해 가능한 결과로 드러나는지 확인한다.
## 자동 Smoke
| ID | 시나리오 | 실행 | 기대 결과 |
|----|----------|------|-----------|
| A-01 | HR token 문서 조회 | `./run.sh backoffice-vpd-ords-test` | 3행, `DOC_ID=[1,2,6]`, `CONTENTS` 없음 |
| A-02 | SELF token 문서 조회 | `./run.sh backoffice-vpd-ords-test` | 1행, `DOC_ID=[3]`, `CONTENTS` 없음 |
| A-03 | ALL token 문서 조회 | `./run.sh backoffice-vpd-ords-test` | 6행, `DOC_ID=[1,2,3,4,5,6]`, `CONTENTS` 있음 |
| A-04 | 미등록 bearer token 조회 | `./run.sh backoffice-vpd-ords-test` | HTTP 200 성공 rows로 처리되지 않음 |
## 권한 저장 경계값
| ID | 입력 | 기대 결과 |
|----|------|-----------|
| P-01 | `ALL` 단독 | 저장 성공 |
| P-02 | `ALL` + 다른 rule | 저장 거부, `ALL 규칙은 다른 규칙과 함께 저장할 수 없습니다.` |
| P-03 | `MY_DEPT`, 컬럼 비움 | 저장 성공, VPD 기본 컬럼 `DEPT_CODE` 사용 |
| P-04 | `SELF`, 컬럼 비움 | 저장 성공, VPD 기본 컬럼 `OWNER_EMP_NO` 사용 |
| P-05 | `DEPT`, 값 비움 | 저장 거부, 값 필요 메시지 |
| P-06 | `EMP_NO`, 값 비움 | 저장 거부, 값 필요 메시지 |
| P-07 | `=`, 컬럼 비움 | 저장 거부, 컬럼 필요 메시지 |
| P-08 | `!=`, 값 비움 | 저장 거부, 값 필요 메시지 |
| P-09 | 보호 객체에 없는 컬럼 | 저장 거부 또는 DB VPD 함수에서 fail-closed |
## 권한 조합
| ID | 조합 | 기대 결과 |
|----|------|-----------|
| C-01 | 동일 사용자에게 HR role + SELF role | 두 rule이 OR 조건으로 합쳐진 결과 |
| C-02 | 동일 사용자에게 ALL role 포함 | 전체 행 조회 |
| C-03 | 권한 삭제 후 해당 객체 권한 0개 | 보호 객체 드롭다운에서 protected 표시 제거 |
| C-04 | 민감 컬럼 예외 없음 | 민감 컬럼 NULL/미노출 |
| C-05 | 민감 컬럼 예외 있음 | 허용 role에서만 민감 컬럼 노출 |
## ORDS/환경 오류
| ID | 상황 | 기대 메시지 방향 |
|----|------|------------------|
| O-01 | ORDS base URL 미설정 | `ORDS_NOT_CONFIGURED`, 설정 조치 안내 |
| O-02 | ORDS path 불일치 | `ORDS_PATH_NOT_FOUND`, schema/module/template 확인 안내 |
| O-03 | ORDS handler PL/SQL 오류 | `ORDS_USER_DEFINED_RESOURCE_ERROR`, response body 표시 |
| O-04 | DB 연결 실패 | DB URL/사용자/비밀번호/ADB wallet 확인 안내 |
| O-05 | token 만료/폐기 | 권한 결과가 아니라 token 거부로 표시 |
## 수동 확인 절차
1. `권한` 탭에서 `MY_DEPT`, `SELF`, `DEPT`, `EMP_NO`, `=`, `!=`를 각각 저장해 본다.
2. 저장 후 권한 목록의 `적용 필터`가 실제 rule 의미와 일치하는지 확인한다.
3. `ORDS 검증` 탭에서 HR/SELF/ALL token으로 결과를 비교한다.
4. 잘못된 ORDS path를 임시로 넣어 request/response header/body가 표시되는지 확인한다.
5. 권한 삭제 후 보호 객체 표시가 갱신되는지 확인한다.

18
run.sh
View File

@@ -10,6 +10,8 @@
# ./run.sh tests # 4-user (my/pg/both/none) 로 접속해서 행 필터 검증 # ./run.sh tests # 4-user (my/pg/both/none) 로 접속해서 행 필터 검증
# ./run.sh audit # admin 으로 정책/뷰/유저 상태 점검 # ./run.sh audit # admin 으로 정책/뷰/유저 상태 점검
# ./run.sh backoffice-support # Spring Boot 백오피스 보조 객체 생성 # ./run.sh backoffice-support # Spring Boot 백오피스 보조 객체 생성
# ./run.sh backoffice-env-check # SQLcl/JDK/DB/ORDS 환경 점검
# ./run.sh backoffice-vpd-ords-test # SQLcl + ORDS VPD/Redaction 회귀 테스트
# ./run.sh all # source → adb → tests → audit # ./run.sh all # source → adb → tests → audit
# ./run.sh teardown # ADB 측 객체 + 원격 link/cred 만 정리 (원격 PG/MySQL 데이터는 보존) # ./run.sh teardown # ADB 측 객체 + 원격 link/cred 만 정리 (원격 PG/MySQL 데이터는 보존)
# #
@@ -174,6 +176,18 @@ do_backoffice_support() {
ok "backoffice-support 완료" ok "backoffice-support 완료"
} }
do_backoffice_vpd_ords_test() {
log "=== backoffice-vpd-ords-test: SQLcl + ORDS VPD/Redaction 회귀 테스트 ==="
bash "$ROOT/scripts/vpd-ords-regression-test.sh"
ok "backoffice-vpd-ords-test 완료"
}
do_backoffice_env_check() {
log "=== backoffice-env-check: SQLcl/JDK/DB/ORDS 환경 점검 ==="
bash "$ROOT/scripts/check-sqlcl-backoffice-env.sh"
ok "backoffice-env-check 완료"
}
do_teardown() { do_teardown() {
log "=== teardown: ADB 측 객체 + dblink/credential 정리 ===" log "=== teardown: ADB 측 객체 + dblink/credential 정리 ==="
warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)" warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)"
@@ -226,6 +240,8 @@ case "$CMD" in
tests) do_prereq; do_tests ;; tests) do_prereq; do_tests ;;
audit) do_prereq; do_audit ;; audit) do_prereq; do_audit ;;
backoffice-support) do_prereq; do_backoffice_support ;; backoffice-support) do_prereq; do_backoffice_support ;;
backoffice-env-check) do_backoffice_env_check ;;
backoffice-vpd-ords-test) do_backoffice_vpd_ords_test ;;
teardown) do_prereq; do_teardown ;; teardown) do_prereq; do_teardown ;;
all) all)
do_prereq do_prereq
@@ -245,6 +261,6 @@ case "$CMD" in
ok "=== DDS DONE — Deep Data Security 변형 셋업 + 검증 통과 ===" ok "=== DDS DONE — Deep Data Security 변형 셋업 + 검증 통과 ==="
;; ;;
*) *)
die "알 수 없는 명령: $CMD (사용: prereq|source|adb|tests|audit|backoffice-support|all|teardown | dds|dds-setup|dds-tests|dds-teardown)" die "알 수 없는 명령: $CMD (사용: prereq|source|adb|tests|audit|backoffice-support|backoffice-env-check|backoffice-vpd-ords-test|all|teardown | dds|dds-setup|dds-tests|dds-teardown)"
;; ;;
esac esac

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Check SQLcl/JDK/ADB/ORDS prerequisites for the VPD backoffice scripts.
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ -f "$ROOT/.env" ]]; then
# shellcheck disable=SC1091
set -a; . "$ROOT/.env"; set +a
fi
SQLCL_BIN="${SQLCL_BIN:-}"
if [[ -z "$SQLCL_BIN" ]]; then
SQLCL_BIN="$(command -v sql || true)"
fi
if [[ -n "${SQLCL_JAVA_HOME:-}" ]]; then
export JAVA_HOME="$SQLCL_JAVA_HOME"
fi
DB_USER="${BACKOFFICE_DB_USERNAME:-${ADB_USER:-}}"
DB_PASSWORD="${BACKOFFICE_DB_PASSWORD:-${ADB_PASSWORD:-}}"
DB_TNS="${ADB_TNS:-}"
ORDS_BASE="${BACKOFFICE_ORDS_BASE_URL:-https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords}"
ORDS_PATH="${BACKOFFICE_REGRESSION_ORDS_PATH:-cb-ords/cb-agent-security/vpd/documents}"
fail() {
echo "[FAIL] $*" >&2
exit 1
}
warn() {
echo "[WARN] $*" >&2
}
pass() {
echo "[PASS] $*"
}
[[ -n "$SQLCL_BIN" && -x "$SQLCL_BIN" ]] || fail "SQLcl을 찾을 수 없습니다. SQLCL_BIN=/path/to/sql 로 지정하세요."
[[ -n "$DB_USER" ]] || fail "BACKOFFICE_DB_USERNAME 또는 ADB_USER가 필요합니다."
[[ -n "$DB_PASSWORD" ]] || fail "BACKOFFICE_DB_PASSWORD 또는 ADB_PASSWORD가 필요합니다."
[[ -n "$DB_TNS" ]] || fail "ADB_TNS가 필요합니다."
echo "[INFO] SQLcl path: $SQLCL_BIN"
"$SQLCL_BIN" -version
if command -v java >/dev/null 2>&1; then
java -version 2>&1 | sed -n '1,3p'
else
warn "java 명령을 PATH에서 찾을 수 없습니다. SQLcl이 자체 Java를 쓰지 않는 환경이면 실패할 수 있습니다."
fi
echo "[INFO] DB user: $DB_USER"
{
printf "%s\n" "WHENEVER SQLERROR EXIT SQL.SQLCODE"
printf "%s\n" "SET SQLFORMAT ansiconsole"
printf "%s\n" "SELECT USER AS current_user FROM dual;"
printf "%s\n" "SELECT object_name, status FROM user_objects WHERE object_name = 'CB_AGENT_DOC_VPD_FILTER';"
printf "%s\n" "exit"
} | "$SQLCL_BIN" -s "$DB_USER/$DB_PASSWORD@$DB_TNS"
pass "DB 접속 및 VPD function 상태 조회 완료"
ORDS_URL="${ORDS_BASE%/}/${ORDS_PATH}?limit=1"
STATUS="$(curl -sS -o /tmp/vpd-backoffice-env-check-ords.json -w "%{http_code}" \
-X POST "$ORDS_URL" \
-H "Content-Type: application/json" \
-d "{}" || true)"
case "$STATUS" in
200|401|403)
pass "ORDS endpoint 응답 확인: HTTP $STATUS"
;;
404)
warn "ORDS endpoint가 404입니다. BACKOFFICE_ORDS_BASE_URL과 ORDS path를 확인하세요: $ORDS_URL"
;;
*)
warn "ORDS endpoint 응답이 예상 밖입니다: HTTP $STATUS ($ORDS_URL)"
;;
esac
echo "[INFO] 다음 검증: ./run.sh backoffice-vpd-ords-test"

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# VPD/Redaction ORDS regression test for the backoffice demo objects.
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ -f "$ROOT/.env" ]]; then
# shellcheck disable=SC1091
set -a; . "$ROOT/.env"; set +a
fi
SQLCL_BIN="${SQLCL_BIN:-}"
if [[ -z "$SQLCL_BIN" ]]; then
SQLCL_BIN="$(command -v sql || true)"
fi
if [[ -z "$SQLCL_BIN" || ! -x "$SQLCL_BIN" ]]; then
echo "[FAIL] SQLcl 실행 파일을 찾을 수 없습니다. SQLCL_BIN=/path/to/sql 로 지정하세요." >&2
exit 1
fi
if [[ -n "${SQLCL_JAVA_HOME:-}" ]]; then
export JAVA_HOME="$SQLCL_JAVA_HOME"
fi
DB_USER="${BACKOFFICE_DB_USERNAME:-${ADB_USER:-}}"
DB_PASSWORD="${BACKOFFICE_DB_PASSWORD:-${ADB_PASSWORD:-}}"
DB_TNS="${ADB_TNS:-}"
ORDS_BASE="${BACKOFFICE_ORDS_BASE_URL:-https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords}"
ORDS_PATH="${BACKOFFICE_REGRESSION_ORDS_PATH:-cb-ords/cb-agent-security/vpd/documents}"
DESCRIPTION="codex issue458 regression"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vpd-ords-regression.XXXXXX")"
require_value() {
local name="$1" value="$2"
if [[ -z "$value" ]]; then
echo "[FAIL] $name 값이 필요합니다." >&2
exit 1
fi
}
require_value BACKOFFICE_DB_USERNAME "$DB_USER"
require_value BACKOFFICE_DB_PASSWORD "$DB_PASSWORD"
require_value ADB_TNS "$DB_TNS"
TOKEN_HR="codex_hr_$(uuidgen)"
TOKEN_SELF="codex_self_$(uuidgen)"
TOKEN_ALL="codex_all_$(uuidgen)"
run_sqlcl() {
"$SQLCL_BIN" -s "$DB_USER/$DB_PASSWORD@$DB_TNS"
}
cleanup_tokens() {
{
printf "%s\n" "WHENEVER SQLERROR CONTINUE"
printf "%s\n" "UPDATE cb_agent_bearer_key SET revoked_at = SYSDATE, active = 'N' WHERE description = '$DESCRIPTION';"
printf "%s\n" "COMMIT;"
printf "%s\n" "exit"
} | run_sqlcl >/dev/null || true
}
trap cleanup_tokens EXIT
insert_tokens() {
{
printf "%s\n" "WHENEVER SQLERROR EXIT SQL.SQLCODE"
printf "%s\n" "INSERT INTO cb_agent_bearer_key(key_id, user_id, key_hash, key_prefix, expires_at, active, description) VALUES (cb_agent_bearer_key_seq.NEXTVAL, 101, STANDARD_HASH('$TOKEN_HR', 'SHA256'), 'reg_hr', SYSDATE + (10/1440), 'Y', '$DESCRIPTION');"
printf "%s\n" "INSERT INTO cb_agent_bearer_key(key_id, user_id, key_hash, key_prefix, expires_at, active, description) VALUES (cb_agent_bearer_key_seq.NEXTVAL, 102, STANDARD_HASH('$TOKEN_SELF', 'SHA256'), 'reg_self', SYSDATE + (10/1440), 'Y', '$DESCRIPTION');"
printf "%s\n" "INSERT INTO cb_agent_bearer_key(key_id, user_id, key_hash, key_prefix, expires_at, active, description) VALUES (cb_agent_bearer_key_seq.NEXTVAL, 103, STANDARD_HASH('$TOKEN_ALL', 'SHA256'), 'reg_all', SYSDATE + (10/1440), 'Y', '$DESCRIPTION');"
printf "%s\n" "COMMIT;"
printf "%s\n" "exit"
} | run_sqlcl >"$WORK_DIR/token-insert.out"
}
call_and_assert() {
local label="$1" token="$2" expected_count="$3" expected_ids="$4" expected_contents="$5"
local response_file="$WORK_DIR/${label}.response.json"
local request_file="$WORK_DIR/${label}.request.txt"
local url="${ORDS_BASE%/}/${ORDS_PATH}?limit=50"
{
printf "POST %s\n" "$url"
printf "Authorization: Bearer ****%s\n" "${token: -6}"
printf "Content-Type: application/json\n"
printf "{}\n"
} >"$request_file"
local status
status="$(curl -sS -o "$response_file" -w "%{http_code}" \
-X POST "$url" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "{}")"
python3 - "$label" "$status" "$response_file" "$request_file" "$expected_count" "$expected_ids" "$expected_contents" <<'PY'
import json
import sys
label, status, response_file, request_file, expected_count, expected_ids, expected_contents = sys.argv[1:8]
if status != "200":
print(f"[FAIL] {label}: HTTP {status}")
print(f" request={request_file}")
print(f" response={response_file}")
raise SystemExit(1)
try:
with open(response_file, encoding="utf-8") as handle:
data = json.load(handle)
except Exception as exc:
print(f"[FAIL] {label}: JSON parse failed: {exc}")
print(f" request={request_file}")
print(f" response={response_file}")
raise SystemExit(1)
rows = data.get("items", data.get("rows", []))
ids = [row.get("DOC_ID", row.get("doc_id")) for row in rows]
contents_count = sum(1 for row in rows if "CONTENTS" in row or "contents" in row)
expected_ids_list = [int(value) for value in expected_ids.split(",") if value]
failures = []
if len(rows) != int(expected_count):
failures.append(f"rows expected {expected_count}, actual {len(rows)}")
if ids != expected_ids_list:
failures.append(f"ids expected {expected_ids_list}, actual {ids}")
if contents_count != int(expected_contents):
failures.append(f"rows_with_contents expected {expected_contents}, actual {contents_count}")
if failures:
print(f"[FAIL] {label}: " + "; ".join(failures))
print(f" request={request_file}")
print(f" response={response_file}")
raise SystemExit(1)
print(f"[PASS] {label}: rows={len(rows)} ids={ids} rows_with_contents={contents_count}")
PY
}
call_and_assert_rejected() {
local label="$1" token="$2"
local response_file="$WORK_DIR/${label}.response.json"
local request_file="$WORK_DIR/${label}.request.txt"
local url="${ORDS_BASE%/}/${ORDS_PATH}?limit=50"
{
printf "POST %s\n" "$url"
printf "Authorization: Bearer ****%s\n" "${token: -6}"
printf "Content-Type: application/json\n"
printf "{}\n"
} >"$request_file"
local status
status="$(curl -sS -o "$response_file" -w "%{http_code}" \
-X POST "$url" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "{}")"
if [[ "$status" == "200" ]]; then
echo "[FAIL] $label: invalid token returned HTTP 200"
echo " request=$request_file"
echo " response=$response_file"
exit 1
fi
echo "[PASS] $label: rejected with HTTP $status"
}
echo "[INFO] SQLcl: $SQLCL_BIN"
echo "[INFO] ORDS: ${ORDS_BASE%/}/${ORDS_PATH}"
echo "[INFO] Evidence: $WORK_DIR"
insert_tokens
call_and_assert "HR" "$TOKEN_HR" "3" "1,2,6" "0"
call_and_assert "SELF" "$TOKEN_SELF" "1" "3" "0"
call_and_assert "ALL" "$TOKEN_ALL" "6" "1,2,3,4,5,6" "6"
call_and_assert_rejected "INVALID_TOKEN" "codex_invalid_$(uuidgen)"
echo "[PASS] VPD/Redaction ORDS regression passed"

View File

@@ -51,8 +51,9 @@ CREATE TABLE cb_app_user (
); );
CREATE TABLE cb_app_role ( CREATE TABLE cb_app_role (
role_id NUMBER PRIMARY KEY, role_id NUMBER PRIMARY KEY,
role_name VARCHAR2(50) NOT NULL role_name VARCHAR2(50) NOT NULL,
max_sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL
); );
CREATE TABLE cb_user_role ( CREATE TABLE cb_user_role (
@@ -65,7 +66,8 @@ CREATE TABLE cb_permission (
perm_id NUMBER PRIMARY KEY, perm_id NUMBER PRIMARY KEY,
role_id NUMBER NOT NULL REFERENCES cb_app_role(role_id), role_id NUMBER NOT NULL REFERENCES cb_app_role(role_id),
target_name VARCHAR2(128) NOT NULL, target_name VARCHAR2(128) NOT NULL,
action_name VARCHAR2(30) NOT NULL action_name VARCHAR2(30) NOT NULL,
permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL
); );
CREATE TABLE cb_permission_rule ( CREATE TABLE cb_permission_rule (
@@ -98,17 +100,17 @@ INSERT INTO cb_app_user VALUES (101, 'agent_hr', 'E10234', 'HR', 'N', 'Y'
INSERT INTO cb_app_user VALUES (102, 'agent_fin_self', 'E2001', 'FIN', 'N', 'Y'); INSERT INTO cb_app_user VALUES (102, 'agent_fin_self', 'E2001', 'FIN', 'N', 'Y');
INSERT INTO cb_app_user VALUES (103, 'agent_all', 'E99999', 'HQ', 'Y', 'Y'); INSERT INTO cb_app_user VALUES (103, 'agent_all', 'E99999', 'HQ', 'Y', 'Y');
INSERT INTO cb_app_role VALUES (10, 'HR_DEPT_ROLE'); INSERT INTO cb_app_role VALUES (10, 'HR_DEPT_ROLE', 'INTERNAL');
INSERT INTO cb_app_role VALUES (20, 'FIN_SELF_ROLE'); INSERT INTO cb_app_role VALUES (20, 'FIN_SELF_ROLE', 'INTERNAL');
INSERT INTO cb_app_role VALUES (30, 'ALL_DOC_ROLE'); INSERT INTO cb_app_role VALUES (30, 'ALL_DOC_ROLE', 'CONFIDENTIAL');
INSERT INTO cb_user_role VALUES (101, 10); INSERT INTO cb_user_role VALUES (101, 10);
INSERT INTO cb_user_role VALUES (102, 20); INSERT INTO cb_user_role VALUES (102, 20);
INSERT INTO cb_user_role VALUES (103, 30); INSERT INTO cb_user_role VALUES (103, 30);
INSERT INTO cb_permission VALUES (100, 10, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); INSERT INTO cb_permission VALUES (100, 10, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
INSERT INTO cb_permission VALUES (200, 20, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); INSERT INTO cb_permission VALUES (200, 20, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
INSERT INTO cb_permission VALUES (300, 30, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); INSERT INTO cb_permission VALUES (300, 30, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
INSERT INTO cb_permission_rule VALUES (1000, 100, 'MY_DEPT', NULL); INSERT INTO cb_permission_rule VALUES (1000, 100, 'MY_DEPT', NULL);
INSERT INTO cb_permission_rule VALUES (2000, 200, 'SELF', NULL); INSERT INTO cb_permission_rule VALUES (2000, 200, 'SELF', NULL);
@@ -254,66 +256,148 @@ CREATE OR REPLACE FUNCTION cb_agent_doc_vpd_filter(
) RETURN VARCHAR2 ) RETURN VARCHAR2
AUTHID DEFINER AUTHID DEFINER
AS AS
v_user_id VARCHAR2(30); v_user_id NUMBER;
v_target_name VARCHAR2(128); v_target VARCHAR2(128);
BEGIN v_allow_predicate VARCHAR2(32767);
v_user_id := SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'); v_deny_predicate VARCHAR2(32767);
IF v_user_id IS NULL THEN FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS
BEGIN
RETURN '''' || REPLACE(NVL(p_value, ''), '''', '''''') || '''';
END;
FUNCTION safe_column(
p_owner IN VARCHAR2,
p_table_name IN VARCHAR2,
p_column_name IN VARCHAR2
) RETURN VARCHAR2 IS
v_column VARCHAR2(128);
v_count NUMBER;
BEGIN
IF p_column_name IS NULL THEN
RETURN NULL;
END IF;
v_column := DBMS_ASSERT.SIMPLE_SQL_NAME(UPPER(TRIM(p_column_name)));
SELECT COUNT(*)
INTO v_count
FROM all_tab_columns
WHERE owner = UPPER(p_owner)
AND table_name = UPPER(p_table_name)
AND column_name = v_column;
IF v_count = 0 THEN
RETURN NULL;
END IF;
RETURN v_column;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
PROCEDURE append_or(p_effect IN VARCHAR2, p_clause IN VARCHAR2) IS
BEGIN
IF p_clause IS NULL THEN
RETURN;
END IF;
IF p_effect = 'DENY' THEN
IF v_deny_predicate IS NULL THEN
v_deny_predicate := '(' || p_clause || ')';
ELSE
v_deny_predicate := v_deny_predicate || ' OR (' || p_clause || ')';
END IF;
ELSIF v_allow_predicate IS NULL THEN
v_allow_predicate := '(' || p_clause || ')';
ELSE
v_allow_predicate := v_allow_predicate || ' OR (' || p_clause || ')';
END IF;
END;
BEGIN
BEGIN
v_user_id := TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'));
EXCEPTION
WHEN OTHERS THEN
RETURN '1 = 0';
END;
IF v_user_id IS NULL OR p_schema IS NULL OR p_object IS NULL THEN
RETURN '1 = 0'; RETURN '1 = 0';
END IF; END IF;
v_target_name := REPLACE(UPPER(p_object), '''', ''''''); v_target := UPPER(TRIM(p_object));
RETURN FOR r IN (
'EXISTS ( SELECT UPPER(TRIM(r.rule_type)) AS rule_type,
SELECT 1 UPPER(TRIM(r.rule_column)) AS rule_column,
FROM admin.cb_user_role ur NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
JOIN admin.cb_permission p r.rule_value
ON p.role_id = ur.role_id FROM cb_user_role ur
JOIN admin.cb_permission_rule r JOIN cb_permission p
ON r.perm_id = p.perm_id ON p.role_id = ur.role_id
WHERE ur.user_id = TO_NUMBER(SYS_CONTEXT(''CB_AGENT_CTX'', ''USER_ID'')) JOIN cb_permission_rule r
AND p.target_name = ''' || v_target_name || ''' ON r.perm_id = p.perm_id
AND p.action_name = ''SELECT'' WHERE ur.user_id = v_user_id
AND ( AND p.target_name = v_target
r.rule_type = ''ALL'' AND p.action_name = 'SELECT'
OR ( ORDER BY r.rule_id
r.rule_type = ''MY_DEPT'' ) LOOP
AND dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'') DECLARE
) v_column VARCHAR2(128);
OR ( BEGIN
r.rule_type = ''DEPT'' IF r.rule_type = 'ALL' THEN
AND r.rule_value = dept_code IF r.permission_effect = 'DENY' THEN
) RETURN '1 = 0';
OR ( END IF;
r.rule_type = ''SELF'' v_allow_predicate := '(1 = 1)';
AND owner_emp_no = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'') ELSIF r.rule_type = 'MY_DEPT' THEN
) v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
OR ( IF v_column IS NOT NULL THEN
r.rule_type = ''EMP_NO'' append_or(r.permission_effect, v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')');
AND r.rule_value = owner_emp_no END IF;
) ELSIF r.rule_type = 'SELF' THEN
OR ( v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
r.rule_type = ''='' IF v_column IS NOT NULL THEN
AND ( append_or(r.permission_effect, v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')');
(r.rule_column = ''DOC_ID'' AND TO_CHAR(doc_id) = r.rule_value) END IF;
OR (r.rule_column = ''TITLE'' AND title = r.rule_value) ELSIF r.rule_type = 'DEPT' THEN
OR (r.rule_column = ''OWNER_EMP_NO'' AND owner_emp_no = r.rule_value) v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'DEPT_CODE'));
OR (r.rule_column = ''DEPT_CODE'' AND dept_code = r.rule_value) IF v_column IS NOT NULL THEN
) append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value));
) END IF;
OR ( ELSIF r.rule_type = 'EMP_NO' THEN
r.rule_type = ''!='' v_column := safe_column(p_schema, p_object, NVL(r.rule_column, 'OWNER_EMP_NO'));
AND ( IF v_column IS NOT NULL THEN
(r.rule_column = ''DOC_ID'' AND TO_CHAR(doc_id) != r.rule_value) append_or(r.permission_effect, v_column || ' = ' || quote_literal(r.rule_value));
OR (r.rule_column = ''TITLE'' AND title != r.rule_value) END IF;
OR (r.rule_column = ''OWNER_EMP_NO'' AND owner_emp_no != r.rule_value) ELSIF r.rule_type = '=' THEN
OR (r.rule_column = ''DEPT_CODE'' AND dept_code != r.rule_value) v_column := safe_column(p_schema, p_object, r.rule_column);
) IF v_column IS NOT NULL THEN
) append_or(r.permission_effect, '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);
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, 'TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value));
END IF;
END IF;
END;
END LOOP;
IF v_allow_predicate IS NULL THEN
RETURN '1 = 0';
END IF;
IF v_deny_predicate IS NULL THEN
IF v_allow_predicate = '(1 = 1)' THEN
RETURN '1 = 1';
END IF;
RETURN '(' || v_allow_predicate || ')';
END IF;
RETURN '((' || v_allow_predicate || ') AND NOT (' || v_deny_predicate || '))';
END; END;
/ /

View File

@@ -86,17 +86,45 @@ BEGIN
p_module_name => 'cb.agent.security', p_module_name => 'cb.agent.security',
p_pattern => 'vpd/documents', p_pattern => 'vpd/documents',
p_method => 'POST', p_method => 'POST',
p_source_type => ORDS.source_type_query, p_source_type => ORDS.source_type_plsql,
p_source => q'! p_source => q'!
SELECT d.doc_id, DECLARE
d.title, v_rows SYS_REFCURSOR;
d.owner_emp_no, BEGIN
d.dept_code, cb_ords_handler_pkg.set_vpd_context(:auth_header);
d.contents
FROM admin.cb_v_search_documents d OPEN v_rows FOR
WHERE (SELECT cb_ords_handler_pkg.set_vpd_context_sql(:auth_header) FROM dual) = 1 SELECT d.doc_id,
d.title,
d.owner_emp_no,
d.dept_code,
d.contents
FROM admin.cb_v_search_documents d
WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500);
:status_code := 200;
OWA_UTIL.MIME_HEADER('application/json', FALSE);
HTP.P('Cache-Control: no-store');
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
APEX_JSON.WRITE('items', v_rows);
APEX_JSON.CLOSE_OBJECT;
cb_ords_handler_pkg.clear_vpd_context;
EXCEPTION
WHEN OTHERS THEN
cb_ords_handler_pkg.clear_vpd_context;
:status_code := 403;
OWA_UTIL.MIME_HEADER('application/json', FALSE);
HTP.P('Cache-Control: no-store');
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
APEX_JSON.WRITE('error', SQLERRM);
APEX_JSON.CLOSE_OBJECT;
END;
!', !',
p_items_per_page => 25 p_items_per_page => 0
); );
ORDS.DEFINE_PARAMETER( ORDS.DEFINE_PARAMETER(
@@ -109,6 +137,16 @@ WHERE (SELECT cb_ords_handler_pkg.set_vpd_context_sql(:auth_header) FROM dual)
p_param_type => 'STRING', p_param_type => 'STRING',
p_access_method => 'IN' p_access_method => 'IN'
); );
ORDS.DEFINE_PARAMETER(
p_module_name => 'cb.agent.security',
p_pattern => 'vpd/documents',
p_method => 'POST',
p_name => 'limit',
p_bind_variable_name => 'row_limit',
p_source_type => 'URI',
p_param_type => 'INT',
p_access_method => 'IN'
);
COMMIT; COMMIT;
END; END;

View File

@@ -52,6 +52,74 @@ EXCEPTION
END; END;
/ /
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE cb_permission ADD (permission_effect VARCHAR2(10) DEFAULT ''ALLOW'' NOT NULL)';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -1430 THEN
RAISE;
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE cb_app_role ADD (max_sensitivity_level VARCHAR2(20) DEFAULT ''PUBLIC'' NOT NULL)';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -1430 THEN
RAISE;
END IF;
END;
/
PROMPT === Creating group role support tables ===
BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE cb_app_group (
group_id NUMBER PRIMARY KEY,
group_code VARCHAR2(100) NOT NULL UNIQUE,
group_name VARCHAR2(100) NOT NULL,
description VARCHAR2(200),
active_yn CHAR(1) DEFAULT ''Y'' CHECK (active_yn IN (''Y'',''N'')) NOT NULL
)';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE cb_user_group (
group_id NUMBER NOT NULL,
user_id NUMBER NOT NULL,
CONSTRAINT cb_user_group_pk PRIMARY KEY (group_id, user_id)
)';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE cb_group_role (
group_id NUMBER NOT NULL,
role_id NUMBER NOT NULL,
CONSTRAINT cb_group_role_pk PRIMARY KEY (group_id, role_id)
)';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
/
CREATE TABLE cb_protected_object ( CREATE TABLE cb_protected_object (
object_id NUMBER PRIMARY KEY, object_id NUMBER PRIMARY KEY,
owner VARCHAR2(128) NOT NULL, owner VARCHAR2(128) NOT NULL,
@@ -66,9 +134,38 @@ CREATE TABLE cb_protected_column (
column_name VARCHAR2(128) NOT NULL, column_name VARCHAR2(128) NOT NULL,
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL, sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL,
visible_role_id NUMBER, visible_role_id NUMBER,
sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL,
redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL,
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name) CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name)
); );
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT ''PUBLIC'' NOT NULL)';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -1430 THEN
RAISE;
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE cb_protected_column ADD (redaction_method VARCHAR2(20) DEFAULT ''NONE'' NOT NULL)';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -1430 THEN
RAISE;
END IF;
END;
/
UPDATE cb_protected_column
SET sensitivity_level = CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END,
redaction_method = CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END
WHERE sensitivity_level = 'PUBLIC'
AND redaction_method = 'NONE'
AND sensitive_yn = 'Y';
CREATE TABLE cb_permission_column ( CREATE TABLE cb_permission_column (
permission_id NUMBER NOT NULL, permission_id NUMBER NOT NULL,
column_name VARCHAR2(128) NOT NULL, column_name VARCHAR2(128) NOT NULL,
@@ -136,16 +233,23 @@ VALUES (src.object_id, src.owner, src.object_name, src.ords_path, src.enabled_yn
MERGE INTO cb_protected_column dst MERGE INTO cb_protected_column dst
USING ( USING (
SELECT 1 column_id, 1 object_id, 'DOC_ID' column_name, 'N' sensitive_yn FROM dual UNION ALL SELECT 1 column_id, 1 object_id, 'DOC_ID' column_name, 'N' sensitive_yn, 'PUBLIC' sensitivity_level, 'NONE' redaction_method FROM dual UNION ALL
SELECT 2, 1, 'TITLE', 'N' FROM dual UNION ALL SELECT 2, 1, 'TITLE', 'N', 'PUBLIC', 'NONE' FROM dual UNION ALL
SELECT 3, 1, 'OWNER_EMP_NO', 'N' FROM dual UNION ALL SELECT 3, 1, 'OWNER_EMP_NO', 'N', 'INTERNAL', 'NONE' FROM dual UNION ALL
SELECT 4, 1, 'DEPT_CODE', 'N' FROM dual UNION ALL SELECT 4, 1, 'DEPT_CODE', 'N', 'INTERNAL', 'NONE' FROM dual UNION ALL
SELECT 5, 1, 'CONTENTS', 'Y' FROM dual SELECT 5, 1, 'CONTENTS', 'Y', 'CONFIDENTIAL', 'NULLIFY' FROM dual
) src ) src
ON (dst.object_id = src.object_id AND dst.column_name = src.column_name) ON (dst.object_id = src.object_id AND dst.column_name = src.column_name)
WHEN MATCHED THEN UPDATE SET dst.sensitive_yn = src.sensitive_yn WHEN MATCHED THEN UPDATE SET
WHEN NOT MATCHED THEN INSERT (column_id, object_id, column_name, sensitive_yn) dst.sensitive_yn = src.sensitive_yn,
VALUES (src.column_id, src.object_id, src.column_name, src.sensitive_yn); dst.sensitivity_level = src.sensitivity_level,
dst.redaction_method = src.redaction_method
WHEN NOT MATCHED THEN INSERT (
column_id, object_id, column_name, sensitive_yn, sensitivity_level, redaction_method
)
VALUES (
src.column_id, src.object_id, src.column_name, src.sensitive_yn, src.sensitivity_level, src.redaction_method
);
MERGE INTO cb_permission_column dst MERGE INTO cb_permission_column dst
USING ( USING (
@@ -170,13 +274,25 @@ BEGIN
SELECT COUNT(*) SELECT COUNT(*)
INTO v_allowed INTO v_allowed
FROM cb_user_role ur FROM (
SELECT ur.role_id
FROM cb_user_role ur
WHERE ur.user_id = TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'))
UNION
SELECT gr.role_id
FROM cb_user_group ug
JOIN cb_app_group g
ON g.group_id = ug.group_id
AND g.active_yn = 'Y'
JOIN cb_group_role gr
ON gr.group_id = ug.group_id
WHERE ug.user_id = TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'))
) er
JOIN cb_permission p JOIN cb_permission p
ON p.role_id = ur.role_id ON p.role_id = er.role_id
JOIN cb_permission_column pc JOIN cb_permission_column pc
ON pc.permission_id = p.perm_id ON pc.permission_id = p.perm_id
WHERE ur.user_id = TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID')) WHERE p.target_name = UPPER(p_target_name)
AND p.target_name = UPPER(p_target_name)
AND p.action_name = 'SELECT' AND p.action_name = 'SELECT'
AND pc.column_name = UPPER(p_column_name); AND pc.column_name = UPPER(p_column_name);

View File

@@ -0,0 +1,186 @@
-- ============================================================
-- 26_agent_ords_security_dynamic_vpd_filter.sql
-- Replaces the demo VPD filter with a whitelist predicate builder.
--
-- The function does not hardcode object names or rule values. It reads:
-- CB_AGENT_CTX.USER_ID -> direct CB_USER_ROLE + group CB_USER_GROUP/CB_GROUP_ROLE
-- -> CB_PERMISSION -> CB_PERMISSION_RULE
-- and builds a row predicate for the object passed by DBMS_RLS.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === Creating dynamic whitelist VPD filter ===
CREATE OR REPLACE FUNCTION cb_agent_doc_vpd_filter(
p_schema IN VARCHAR2,
p_object IN VARCHAR2
) 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_allow_predicate VARCHAR2(32767);
v_deny_predicate VARCHAR2(32767);
FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS
BEGIN
RETURN '''' || REPLACE(NVL(p_value, ''), '''', '''''') || '''';
END;
FUNCTION safe_column(
p_owner IN VARCHAR2,
p_table_name IN VARCHAR2,
p_column_name IN VARCHAR2
) RETURN VARCHAR2 IS
v_column VARCHAR2(128);
v_count NUMBER;
BEGIN
IF p_column_name IS NULL THEN
RETURN NULL;
END IF;
v_column := DBMS_ASSERT.SIMPLE_SQL_NAME(UPPER(TRIM(p_column_name)));
SELECT COUNT(*)
INTO v_count
FROM all_tab_columns
WHERE owner = UPPER(p_owner)
AND table_name = UPPER(p_table_name)
AND column_name = v_column;
IF v_count = 0 THEN
RETURN NULL;
END IF;
RETURN v_column;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
PROCEDURE append_or(p_effect IN VARCHAR2, p_clause IN VARCHAR2) IS
BEGIN
IF p_clause IS NULL THEN
RETURN;
END IF;
IF p_effect = 'DENY' THEN
IF v_deny_predicate IS NULL THEN
v_deny_predicate := '(' || p_clause || ')';
ELSE
v_deny_predicate := v_deny_predicate || ' OR (' || p_clause || ')';
END IF;
ELSIF v_allow_predicate IS NULL THEN
v_allow_predicate := '(' || p_clause || ')';
ELSE
v_allow_predicate := v_allow_predicate || ' OR (' || p_clause || ')';
END IF;
END;
BEGIN
BEGIN
v_user_id := TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'));
EXCEPTION
WHEN OTHERS THEN
RETURN '1 = 0';
END;
IF v_user_id IS NULL OR p_schema IS NULL OR p_object IS NULL THEN
RETURN '1 = 0';
END IF;
v_target := UPPER(TRIM(p_object));
FOR r IN (
WITH effective_role AS (
SELECT ur.role_id
FROM cb_user_role ur
WHERE ur.user_id = v_user_id
UNION
SELECT gr.role_id
FROM cb_user_group ug
JOIN cb_app_group g
ON g.group_id = ug.group_id
AND g.active_yn = 'Y'
JOIN cb_group_role gr
ON gr.group_id = ug.group_id
WHERE ug.user_id = v_user_id
)
SELECT UPPER(TRIM(r.rule_type)) AS rule_type,
UPPER(TRIM(r.rule_column)) AS rule_column,
NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
r.rule_value
FROM effective_role er
JOIN cb_permission p
ON p.role_id = er.role_id
JOIN cb_permission_rule r
ON r.perm_id = p.perm_id
WHERE p.target_name = v_target
AND p.action_name = 'SELECT'
ORDER BY r.rule_id
) LOOP
DECLARE
v_column VARCHAR2(128);
BEGIN
IF r.rule_type = 'ALL' THEN
IF r.permission_effect = 'DENY' THEN
RETURN '1 = 0';
END IF;
v_allow_predicate := '(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'));
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, 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'));
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, 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'));
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, 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'));
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, 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);
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, '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);
IF v_column IS NOT NULL THEN
append_or(r.permission_effect, 'TO_CHAR(' || v_column || ') <> ' || quote_literal(r.rule_value));
END IF;
END IF;
END;
END LOOP;
IF v_allow_predicate IS NULL THEN
RETURN '1 = 0';
END IF;
IF v_deny_predicate IS NULL THEN
IF v_allow_predicate = '(1 = 1)' THEN
RETURN '1 = 1';
END IF;
RETURN '(' || v_allow_predicate || ')';
END IF;
RETURN '((' || v_allow_predicate || ') AND NOT (' || v_deny_predicate || '))';
END;
/
SHOW ERRORS FUNCTION cb_agent_doc_vpd_filter
PROMPT === Dynamic whitelist VPD filter ready ===

View File

@@ -0,0 +1,291 @@
-- ============================================================
-- 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_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456107, 'rule_test_quote_value', 'E456107', 'QA', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456108, 'rule_test_or_injection', 'E456108', 'QA', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456109, 'rule_test_column_injection', 'E456109', 'QA', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456110, 'rule_test_target_mismatch', 'E456110', 'QA', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456111, 'rule_test_allow_deny', 'E456111', 'HR', 'N', 'Y');
INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (456112, 'rule_test_deny_only', 'E456112', 'HR', '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_app_role(role_id, role_name) VALUES (456107, 'RULE_TEST_QUOTE_VALUE');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456108, 'RULE_TEST_OR_INJECTION');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456109, 'RULE_TEST_COLUMN_INJECTION');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456110, 'RULE_TEST_TARGET_MISMATCH');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456111, 'RULE_TEST_ALLOW_DENY');
INSERT INTO cb_app_role(role_id, role_name) VALUES (456112, 'RULE_TEST_DENY_ONLY');
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_user_role(user_id, role_id) VALUES (456107, 456107);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456108, 456108);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456109, 456109);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456110, 456110);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456111, 456111);
INSERT INTO cb_user_role(user_id, role_id) VALUES (456112, 456112);
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(perm_id, role_id, target_name, action_name)
VALUES (456107, 456107, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456108, 456108, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456109, 456109, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name)
VALUES (456110, 456110, 'BOARD_POSTS', 'SELECT');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
VALUES (456111, 456111, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
VALUES (456112, 456111, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'DENY');
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
VALUES (456113, 456112, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'DENY');
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');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456107, 456107, 'TITLE', '=', 'Bob''s plan');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456108, 456108, 'TITLE', '=', 'HR'' OR ''1''=''1'' --');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456109, 456109, 'DOC_ID) OR 1=1 --', '=', '1');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456110, 456110, NULL, 'ALL', NULL);
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456111, 456111, NULL, 'ALL', NULL);
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456112, 456112, 'DEPT_CODE', '=', 'HR');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (456113, 456113, 'DEPT_CODE', '=', 'HR');
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_for_object(
p_label IN VARCHAR2,
p_user_id IN NUMBER,
p_object IN VARCHAR2,
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', p_object);
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_equals(
p_label IN VARCHAR2,
p_user_id IN NUMBER,
p_expected IN VARCHAR2
) IS
BEGIN
assert_equals_for_object(p_label, p_user_id, 'CB_V_SEARCH_DOCUMENTS', p_expected);
END;
PROCEDURE assert_contains_for_object(
p_label IN VARCHAR2,
p_user_id IN NUMBER,
p_object IN VARCHAR2,
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', p_object);
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;
PROCEDURE assert_contains(
p_label IN VARCHAR2,
p_user_id IN NUMBER,
p_expected IN VARCHAR2
) IS
BEGIN
assert_contains_for_object(p_label, p_user_id, 'CB_V_SEARCH_DOCUMENTS', p_expected);
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');
assert_contains('QUOTE_VALUE_ESCAPED', 456107, 'TO_CHAR(TITLE) = ''Bob''''s plan''');
assert_contains('OR_INJECTION_STAYS_LITERAL', 456108, 'TO_CHAR(TITLE) = ''HR'''' OR ''''1''''=''''1'''' --''');
assert_equals('COLUMN_INJECTION_DENIES', 456109, '1 = 0');
assert_equals_for_object('TARGET_MISMATCH_DENIES', 456110, 'CB_V_SEARCH_DOCUMENTS', '1 = 0');
assert_equals_for_object('TARGET_MATCH_ALL_ALLOWS', 456110, 'BOARD_POSTS', '1 = 1');
assert_contains('ALLOW_ALL_MINUS_DENY_DEPT', 456111, 'AND NOT');
assert_contains('ALLOW_ALL_MINUS_DENY_DEPT_DETAIL', 456111, 'TO_CHAR(DEPT_CODE) = ''HR''');
assert_equals('DENY_ONLY_DENIES', 456112, '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 ===

View File

@@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.config;
import com.cloudhandson.vpdbackoffice.service.PermissionService; import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
import java.sql.Connection; import java.sql.Connection;
import javax.sql.DataSource; import javax.sql.DataSource;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -17,15 +18,18 @@ public class DbPoolWarmup {
private final DataSource dataSource; private final DataSource dataSource;
private final ProtectedObjectService protectedObjectService; private final ProtectedObjectService protectedObjectService;
private final PermissionService permissionService; private final PermissionService permissionService;
private final VpdPolicyService vpdPolicyService;
public DbPoolWarmup( public DbPoolWarmup(
DataSource dataSource, DataSource dataSource,
ProtectedObjectService protectedObjectService, ProtectedObjectService protectedObjectService,
PermissionService permissionService PermissionService permissionService,
VpdPolicyService vpdPolicyService
) { ) {
this.dataSource = dataSource; this.dataSource = dataSource;
this.protectedObjectService = protectedObjectService; this.protectedObjectService = protectedObjectService;
this.permissionService = permissionService; this.permissionService = permissionService;
this.vpdPolicyService = vpdPolicyService;
} }
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
@@ -45,6 +49,8 @@ public class DbPoolWarmup {
protectedObjectService.findDatabaseObjects(); protectedObjectService.findDatabaseObjects();
permissionService.findRoles(); permissionService.findRoles();
permissionService.findPermissionViews(); permissionService.findPermissionViews();
vpdPolicyService.findVpdTargets();
vpdPolicyService.formOptions();
log.info("Backoffice DB catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000); log.info("Backoffice DB catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
} }
} }

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice.domain.group;
public record AppGroup(
long groupId,
String groupCode,
String groupName,
String description,
String activeYn
) {
public boolean active() {
return "Y".equalsIgnoreCase(activeYn);
}
}

View File

@@ -0,0 +1,8 @@
package com.cloudhandson.vpdbackoffice.domain.group;
public record GroupCreateCommand(
String groupCode,
String groupName,
String description
) {
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.vpdbackoffice.domain.group;
public record GroupRoleView(
long groupId,
String groupCode,
String groupName,
long roleId,
String roleName
) {
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.vpdbackoffice.domain.group;
public record GroupUserView(
long groupId,
String groupCode,
String groupName,
long userId,
String username
) {
}

View File

@@ -0,0 +1,69 @@
package com.cloudhandson.vpdbackoffice.domain.operation;
public record OperationStatusRow(
long objectId,
String owner,
String objectName,
String ordsPath,
String enabledYn,
Integer permissionCount,
Integer ruleCount,
String policyNames,
String policyEnabled,
String functionName,
String functionStatus,
Long handlerId,
String handlerMethod,
String handlerSourceType,
String handlerFullPath,
String lastProbeStatus,
Integer lastProbeRowCount,
String lastProbeErrorCode,
String lastProbeAt
) {
public String displayName() {
return owner + "." + objectName;
}
public String healthLevel() {
if (functionStatus != null && !"VALID".equalsIgnoreCase(functionStatus)) {
return "ERROR";
}
if (handlerId == null || policyNames == null || policyNames.isBlank()
|| policyEnabled == null || !policyEnabled.toUpperCase().contains("YES")) {
return "WARN";
}
if (lastProbeStatus != null && !lastProbeStatus.isBlank()
&& !lastProbeStatus.toUpperCase().contains("SUCCESS")
&& !lastProbeStatus.toUpperCase().contains("ALLOW")) {
return "WARN";
}
return "OK";
}
public String actionText() {
if (handlerId == null) {
return "ORDS path와 handler schema/module/template 매핑을 확인하세요.";
}
if (policyNames == null || policyNames.isBlank()) {
return "VPD policy를 적용하세요.";
}
if (policyEnabled == null || !policyEnabled.toUpperCase().contains("YES")) {
return "VPD policy enable 상태를 확인하세요.";
}
if (functionStatus != null && !"VALID".equalsIgnoreCase(functionStatus)) {
return "Policy function 컴파일 오류를 확인하세요.";
}
if (lastProbeStatus == null || lastProbeStatus.isBlank()) {
return "ORDS 검증을 한 번 실행하세요.";
}
return "현재 상태에서 즉시 조치할 항목은 없습니다.";
}
public String permissionSummary() {
int permissions = permissionCount == null ? 0 : permissionCount;
int rules = ruleCount == null ? 0 : ruleCount;
return permissions + " permissions / " + rules + " rules";
}
}

View File

@@ -3,6 +3,7 @@ package com.cloudhandson.vpdbackoffice.domain.permission;
public record AppRole( public record AppRole(
long roleId, long roleId,
String roleName, String roleName,
String description String description,
String maxSensitivityLevel
) { ) {
} }

View File

@@ -7,6 +7,7 @@ public record PermissionSet(
long roleId, long roleId,
long objectId, long objectId,
String action, String action,
String permissionEffect,
List<PermissionRule> rules, List<PermissionRule> rules,
List<String> visibleColumns List<String> visibleColumns
) { ) {

View File

@@ -9,6 +9,7 @@ public record PermissionSetCommand(
@Positive long roleId, @Positive long roleId,
@Positive long objectId, @Positive long objectId,
@NotBlank String action, @NotBlank String action,
String permissionEffect,
@NotEmpty List<RuleCommand> rules, @NotEmpty List<RuleCommand> rules,
List<String> visibleColumns List<String> visibleColumns
) { ) {

View File

@@ -7,6 +7,7 @@ public record PermissionView(
long objectId, long objectId,
String objectName, String objectName,
String action, String action,
String permissionEffect,
String rules, String rules,
String visibleColumns, String visibleColumns,
String filterPreview, String filterPreview,

View File

@@ -5,10 +5,19 @@ public record ProtectedColumn(
long objectId, long objectId,
String columnName, String columnName,
String sensitiveYn, String sensitiveYn,
Long visibleRoleId Long visibleRoleId,
String sensitivityLevel,
String redactionMethod
) { ) {
public boolean sensitive() { public boolean sensitive() {
return "Y".equalsIgnoreCase(sensitiveYn); return "Y".equalsIgnoreCase(sensitiveYn)
|| !"PUBLIC".equalsIgnoreCase(sensitivityLevel);
}
public String policyLabel() {
String level = sensitivityLevel == null || sensitivityLevel.isBlank() ? "PUBLIC" : sensitivityLevel;
String method = redactionMethod == null || redactionMethod.isBlank() ? "NONE" : redactionMethod;
return level + "/" + method;
} }
} }

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
public record VpdBulkApplyResult(
int total,
int created,
int skipped,
int failed
) {
public String summary() {
return "VPD bulk 적용 완료: 대상 " + total + "개, 등록 " + created
+ "개, 건너뜀 " + skipped + "개, 실패 " + failed + "";
}
}

View File

@@ -0,0 +1,18 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
public record VpdFunctionOption(
String owner,
String packageName,
String functionName,
String objectType
) {
public String value() {
String packagePrefix = packageName == null || packageName.isBlank() ? "" : packageName + ".";
return owner + "." + packagePrefix + functionName;
}
public String label() {
return value() + " / " + objectType;
}
}

View File

@@ -0,0 +1,20 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
import java.util.List;
public record VpdObjectFilterDetail(
String objectOwner,
String objectName,
List<Row> rows
) {
public String objectDisplayName() {
return objectOwner + "." + objectName;
}
public record Row(
VpdPolicyView policy,
VpdFunctionSource source
) {
}
}

View File

@@ -0,0 +1,15 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
public record VpdPolicyCreateCommand(
String objectOwner,
String objectName,
String policyName,
String functionKey,
String functionOwner,
String functionName,
String statementTypes,
boolean enabled,
boolean updateCheck,
String filterPredicate
) {
}

View File

@@ -0,0 +1,21 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
import java.util.List;
public record VpdPolicyFormOptions(
List<String> policyNames,
List<String> schemaOwners,
List<String> owners,
List<VpdFunctionOption> functions,
List<VpdPolicyTemplateOption> policyTemplates,
List<String> statementTypes
) {
public String defaultPermissionFunctionKey() {
return functions.stream()
.filter(function -> "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(function.functionName()))
.findFirst()
.map(VpdFunctionOption::value)
.orElse("");
}
}

View File

@@ -0,0 +1,33 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
public record VpdPolicyTemplateOption(
String policyName,
String functionOwner,
String packageName,
String functionName,
String statementTypes,
String enabled,
String checkOption
) {
public String functionKey() {
String packagePrefix = packageName == null || packageName.isBlank() ? "" : packageName + ".";
return functionOwner + "." + packagePrefix + functionName;
}
public String filterDisplayName() {
return functionKey();
}
public String label() {
return policyName + " / " + filterDisplayName() + " / " + statementTypes;
}
public boolean enabledValue() {
return "YES".equalsIgnoreCase(enabled);
}
public boolean updateCheckValue() {
return "YES".equalsIgnoreCase(checkOption);
}
}

View File

@@ -0,0 +1,8 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
public record VpdSchemaObjectOption(
String owner,
String objectName,
String objectType
) {
}

View File

@@ -0,0 +1,25 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
public record VpdTargetView(
String owner,
String objectName,
String objectType,
String protectedYn,
String ordsPath,
int policyCount,
String policyNames,
String filterNames
) {
public String objectDisplayName() {
return owner + "." + objectName;
}
public boolean protectedObject() {
return "Y".equalsIgnoreCase(protectedYn);
}
public boolean vpdApplied() {
return policyCount > 0;
}
}

View File

@@ -0,0 +1,33 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.group.AppGroup;
import com.cloudhandson.vpdbackoffice.domain.group.GroupCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView;
import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface GroupMapper {
List<AppGroup> findAll();
List<GroupUserView> findGroupUsers();
List<GroupRoleView> findGroupRoles();
long nextGroupId();
void insertGroup(@Param("groupId") long groupId, @Param("command") GroupCreateCommand command);
int updateActive(@Param("groupId") long groupId, @Param("activeYn") String activeYn);
void insertGroupUser(@Param("groupId") long groupId, @Param("userId") long userId);
int deleteGroupUser(@Param("groupId") long groupId, @Param("userId") long userId);
void insertGroupRole(@Param("groupId") long groupId, @Param("roleId") long roleId);
int deleteGroupRole(@Param("groupId") long groupId, @Param("roleId") long roleId);
}

View File

@@ -0,0 +1,11 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.operation.OperationStatusRow;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OperationStatusMapper {
List<OperationStatusRow> findRows();
}

View File

@@ -19,7 +19,11 @@ public interface PermissionMapper {
void insertRole(@Param("roleId") long roleId, void insertRole(@Param("roleId") long roleId,
@Param("roleName") String roleName, @Param("roleName") String roleName,
@Param("description") String description); @Param("description") String description,
@Param("maxSensitivityLevel") String maxSensitivityLevel);
int updateRoleMaxSensitivity(@Param("roleId") long roleId,
@Param("maxSensitivityLevel") String maxSensitivityLevel);
int deleteRole(@Param("roleId") long roleId); int deleteRole(@Param("roleId") long roleId);
@@ -36,10 +40,14 @@ public interface PermissionMapper {
void insertPermission(@Param("permissionId") long permissionId, void insertPermission(@Param("permissionId") long permissionId,
@Param("roleId") long roleId, @Param("roleId") long roleId,
@Param("objectId") long objectId, @Param("objectId") long objectId,
@Param("action") String action); @Param("action") String action,
@Param("permissionEffect") String permissionEffect);
void updatePermissionAction(@Param("permissionId") long permissionId, @Param("action") String action); void updatePermissionAction(@Param("permissionId") long permissionId, @Param("action") String action);
void updatePermissionEffect(@Param("permissionId") long permissionId,
@Param("permissionEffect") String permissionEffect);
void deleteRules(@Param("permissionId") long permissionId); void deleteRules(@Param("permissionId") long permissionId);
void insertRule(PermissionRule rule); void insertRule(PermissionRule rule);

View File

@@ -34,7 +34,13 @@ public interface ProtectedObjectMapper {
void insertColumn(@Param("columnId") long columnId, void insertColumn(@Param("columnId") long columnId,
@Param("objectId") long objectId, @Param("objectId") long objectId,
@Param("columnName") String columnName, @Param("columnName") String columnName,
@Param("sensitiveYn") String sensitiveYn); @Param("sensitiveYn") String sensitiveYn,
@Param("sensitivityLevel") String sensitivityLevel,
@Param("redactionMethod") String redactionMethod);
int updateColumnPolicy(@Param("columnId") long columnId,
@Param("sensitivityLevel") String sensitivityLevel,
@Param("redactionMethod") String redactionMethod);
int updateOrdsPath(@Param("objectId") long objectId, @Param("ordsPath") String ordsPath); int updateOrdsPath(@Param("objectId") long objectId, @Param("ordsPath") String ordsPath);

View File

@@ -1,6 +1,10 @@
package com.cloudhandson.vpdbackoffice.mapper; package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyTemplateOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdTargetView;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@@ -10,12 +14,41 @@ public interface VpdPolicyMapper {
List<VpdPolicyView> findPolicies(); List<VpdPolicyView> findPolicies();
List<VpdTargetView> findVpdTargets(@Param("owner") String owner);
List<String> findPolicyNameOptions();
List<String> findSchemaOwnerOptions();
List<String> findOwnerOptions();
List<VpdFunctionOption> findFunctionOptions();
List<VpdPolicyTemplateOption> findPolicyTemplateOptions();
List<VpdSchemaObjectOption> findSchemaObjects(
@Param("owner") String owner,
@Param("includeTablesYn") String includeTablesYn,
@Param("includeViewsYn") String includeViewsYn
);
VpdPolicyView findPolicy( VpdPolicyView findPolicy(
@Param("objectOwner") String objectOwner, @Param("objectOwner") String objectOwner,
@Param("objectName") String objectName, @Param("objectName") String objectName,
@Param("policyName") String policyName @Param("policyName") String policyName
); );
List<VpdPolicyView> findPoliciesForObject(
@Param("objectOwner") String objectOwner,
@Param("objectName") String objectName
);
VpdPolicyView findAnyPolicy(
@Param("objectOwner") String objectOwner,
@Param("objectName") String objectName,
@Param("policyName") String policyName
);
String findFunctionSource( String findFunctionSource(
@Param("owner") String owner, @Param("owner") String owner,
@Param("objectName") String objectName, @Param("objectName") String objectName,

View File

@@ -40,8 +40,20 @@ public class BackofficeSchemaService {
"""); """);
createTable(results, "cb_app_role", """ createTable(results, "cb_app_role", """
CREATE TABLE cb_app_role ( CREATE TABLE cb_app_role (
role_id NUMBER PRIMARY KEY, role_id NUMBER PRIMARY KEY,
role_name VARCHAR2(100) NOT NULL UNIQUE role_name VARCHAR2(100) NOT NULL UNIQUE,
max_sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL
)
""");
addColumn(results, "cb_app_role", "max_sensitivity_level",
"ALTER TABLE cb_app_role ADD (max_sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)");
createTable(results, "cb_app_group", """
CREATE TABLE cb_app_group (
group_id NUMBER PRIMARY KEY,
group_code VARCHAR2(100) NOT NULL UNIQUE,
group_name VARCHAR2(100) NOT NULL,
description VARCHAR2(200),
active_yn CHAR(1) DEFAULT 'Y' CHECK (active_yn IN ('Y','N')) NOT NULL
) )
"""); """);
createTable(results, "cb_user_role", """ createTable(results, "cb_user_role", """
@@ -51,14 +63,31 @@ public class BackofficeSchemaService {
CONSTRAINT cb_user_role_pk PRIMARY KEY (user_id, role_id) CONSTRAINT cb_user_role_pk PRIMARY KEY (user_id, role_id)
) )
"""); """);
createTable(results, "cb_user_group", """
CREATE TABLE cb_user_group (
group_id NUMBER NOT NULL,
user_id NUMBER NOT NULL,
CONSTRAINT cb_user_group_pk PRIMARY KEY (group_id, user_id)
)
""");
createTable(results, "cb_group_role", """
CREATE TABLE cb_group_role (
group_id NUMBER NOT NULL,
role_id NUMBER NOT NULL,
CONSTRAINT cb_group_role_pk PRIMARY KEY (group_id, role_id)
)
""");
createTable(results, "cb_permission", """ createTable(results, "cb_permission", """
CREATE TABLE cb_permission ( CREATE TABLE cb_permission (
perm_id NUMBER PRIMARY KEY, perm_id NUMBER PRIMARY KEY,
role_id NUMBER NOT NULL, role_id NUMBER NOT NULL,
target_name VARCHAR2(128) NOT NULL, target_name VARCHAR2(128) NOT NULL,
action_name VARCHAR2(30) DEFAULT 'SELECT' NOT NULL action_name VARCHAR2(30) DEFAULT 'SELECT' NOT NULL,
permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL
) )
"""); """);
addColumn(results, "cb_permission", "permission_effect",
"ALTER TABLE cb_permission ADD (permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL)");
createTable(results, "cb_permission_rule", """ createTable(results, "cb_permission_rule", """
CREATE TABLE cb_permission_rule ( CREATE TABLE cb_permission_rule (
rule_id NUMBER PRIMARY KEY, rule_id NUMBER PRIMARY KEY,
@@ -105,9 +134,23 @@ public class BackofficeSchemaService {
column_name VARCHAR2(128) NOT NULL, column_name VARCHAR2(128) NOT NULL,
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL, sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL,
visible_role_id NUMBER, visible_role_id NUMBER,
sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL,
redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL,
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name) CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name)
) )
"""); """);
addColumn(results, "cb_protected_column", "sensitivity_level",
"ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)");
addColumn(results, "cb_protected_column", "redaction_method",
"ALTER TABLE cb_protected_column ADD (redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL)");
jdbcTemplate.update("""
UPDATE cb_protected_column
SET sensitivity_level = CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END,
redaction_method = CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END
WHERE sensitivity_level = 'PUBLIC'
AND redaction_method = 'NONE'
AND sensitive_yn = 'Y'
""");
createTable(results, "cb_ords_probe_audit", """ createTable(results, "cb_ords_probe_audit", """
CREATE TABLE cb_ords_probe_audit ( CREATE TABLE cb_ords_probe_audit (
audit_id NUMBER PRIMARY KEY, audit_id NUMBER PRIMARY KEY,

View File

@@ -0,0 +1,86 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import com.cloudhandson.vpdbackoffice.domain.group.AppGroup;
import com.cloudhandson.vpdbackoffice.domain.group.GroupCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView;
import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView;
import com.cloudhandson.vpdbackoffice.mapper.GroupMapper;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class GroupService {
private final GroupMapper groupMapper;
private final AuditService auditService;
public GroupService(GroupMapper groupMapper, AuditService auditService) {
this.groupMapper = groupMapper;
this.auditService = auditService;
}
public List<AppGroup> findAll() {
return groupMapper.findAll();
}
public List<GroupUserView> findGroupUsers() {
return groupMapper.findGroupUsers();
}
public List<GroupRoleView> findGroupRoles() {
return groupMapper.findGroupRoles();
}
@Transactional
public void createGroup(GroupCreateCommand command) {
long groupId = groupMapper.nextGroupId();
groupMapper.insertGroup(groupId, command);
auditService.record(new AuditEvent("GROUP_CREATED", null, null, "SUCCESS", null, null, command.groupCode()));
}
@Transactional
public void setActive(long groupId, boolean active) {
int updated = groupMapper.updateActive(groupId, active ? "Y" : "N");
if (updated == 0) {
throw new AppException("그룹을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("GROUP_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",active=" + active));
}
@Transactional
public void addUser(long groupId, long userId) {
groupMapper.insertGroupUser(groupId, userId);
auditService.record(new AuditEvent("GROUP_USER_ADDED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",userId=" + userId));
}
@Transactional
public void removeUser(long groupId, long userId) {
int deleted = groupMapper.deleteGroupUser(groupId, userId);
if (deleted == 0) {
throw new AppException("삭제할 그룹 사용자 매핑을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("GROUP_USER_REMOVED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",userId=" + userId));
}
@Transactional
public void addRole(long groupId, long roleId) {
groupMapper.insertGroupRole(groupId, roleId);
auditService.record(new AuditEvent("GROUP_ROLE_ADDED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",roleId=" + roleId));
}
@Transactional
public void removeRole(long groupId, long roleId) {
int deleted = groupMapper.deleteGroupRole(groupId, roleId);
if (deleted == 0) {
throw new AppException("삭제할 그룹 역할 매핑을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("GROUP_ROLE_REMOVED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",roleId=" + roleId));
}
}

View File

@@ -114,7 +114,7 @@ public class McpReasoningService {
private String buildPrompt(String question, McpToolView tool, String evidenceJson) { private String buildPrompt(String question, McpToolView tool, String evidenceJson) {
String normalizedQuestion = question == null || question.isBlank() String normalizedQuestion = question == null || question.isBlank()
? "이 ORDS/VPD 검증 결과를 권한 관점에서 요약해줘." ? "요약부터 작성해줘. 이 ORDS/VPD 검증 결과에서 조회 행 수, 주요 식별자, NULL 처리 여부, 권한 범위, 다음 확인 조치를 정리해줘."
: question.trim(); : question.trim();
return """ return """
질문: 질문:
@@ -131,8 +131,11 @@ public class McpReasoningService {
답변 요구사항: 답변 요구사항:
- 한국어로 답변한다. - 한국어로 답변한다.
- 증거 JSON에 없는 데이터는 추측하지 않는다. - 증거 JSON에 없는 데이터는 추측하지 않는다.
- 첫 섹션은 "## 요약"으로 시작하고 3줄 이내로 쓴다.
- 그 다음 "## 판단 근거" 섹션에 표를 사용해 rowCount, maskedColumns, status, errorCode를 정리한다.
- 그 다음 "## 상세" 섹션에서 반환 행과 권한 범위를 설명한다.
- 마지막 "## 다음 조치" 섹션은 운영자가 확인할 항목만 짧게 쓴다.
- VPD 행 필터, 컬럼 NULL 처리, ORDS 오류 여부를 구분한다. - VPD 행 필터, 컬럼 NULL 처리, ORDS 오류 여부를 구분한다.
- 운영자가 다음에 확인할 액션이 있으면 짧게 제시한다.
""".formatted(normalizedQuestion, tool.name(), tool.displayName(), tool.ordsPath(), evidenceJson); """.formatted(normalizedQuestion, tool.name(), tool.displayName(), tool.ordsPath(), evidenceJson);
} }
@@ -145,12 +148,53 @@ public class McpReasoningService {
private String fallbackAnswer(String question, ProbeResult result) { private String fallbackAnswer(String question, ProbeResult result) {
if (result.status() == ProbeStatus.SUCCESS) { if (result.status() == ProbeStatus.SUCCESS) {
return "AI base URL/API Key 설정이 없어 모델 호출은 건너뛰었습니다. ORDS 호출은 성공했고 " return """
+ result.rowCount() + "건이 반환되었습니다. 질문: " + safeQuestion(question); ## 요약
AI base URL/API Key 설정이 없어 모델 호출은 건너뛰었습니다.
ORDS 호출은 성공했고 %d건이 반환되었습니다.
## 판단 근거
| 항목 | 값 |
| --- | --- |
| status | %s |
| rowCount | %d |
| maskedColumns | %s |
## 상세
질문: %s
## 다음 조치
- 모델 설명이 필요하면 AI 설정을 확인하세요.
- 행/컬럼 결과는 아래 Tool Evidence JSON과 Response Body를 기준으로 확인하세요.
""".formatted(
result.rowCount(),
result.status().name(),
result.rowCount(),
result.maskedColumns().isEmpty() ? "-" : String.join(", ", result.maskedColumns()),
safeQuestion(question)
);
} }
return "AI base URL/API Key 설정이 없어 모델 호출은 건너뛰었습니다. ORDS 도구 실행 상태는 " return """
+ result.status().name() + "입니다. 오류: " ## 요약
+ (result.errorMessage() == null ? "없음" : result.errorMessage()); AI base URL/API Key 설정이 없어 모델 호출은 건너뛰었습니다.
ORDS 도구 실행은 성공 상태가 아닙니다.
## 판단 근거
| 항목 | 값 |
| --- | --- |
| status | %s |
| errorMessage | %s |
## 상세
request/response 증거를 확인해 ORDS path, token, handler 오류를 구분하세요.
## 다음 조치
- ORDS path와 token 만료/폐기 상태를 확인하세요.
- handler 오류면 Response Body의 ORDS error를 확인하세요.
""".formatted(
result.status().name(),
result.errorMessage() == null ? "없음" : result.errorMessage()
);
} }
private String safeQuestion(String question) { private String safeQuestion(String question) {

View File

@@ -0,0 +1,20 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.operation.OperationStatusRow;
import com.cloudhandson.vpdbackoffice.mapper.OperationStatusMapper;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class OperationStatusService {
private final OperationStatusMapper mapper;
public OperationStatusService(OperationStatusMapper mapper) {
this.mapper = mapper;
}
public List<OperationStatusRow> findRows() {
return mapper.findRows();
}
}

View File

@@ -186,9 +186,9 @@ public class OrdsMetadataService {
p_module_name => ?, p_module_name => ?,
p_pattern => ?, p_pattern => ?,
p_method => 'POST', p_method => 'POST',
p_source_type => ORDS.source_type_query, p_source_type => ORDS.source_type_plsql,
p_source => ?, p_source => ?,
p_items_per_page => 25 p_items_per_page => 0
); );
ORDS.DEFINE_PARAMETER( ORDS.DEFINE_PARAMETER(
p_module_name => ?, p_module_name => ?,
@@ -229,10 +229,37 @@ public class OrdsMetadataService {
.reduce((left, right) -> left + ",\n " + right) .reduce((left, right) -> left + ",\n " + right)
.orElseThrow(); .orElseThrow();
return """ return """
SELECT %s DECLARE
FROM %s.%s o v_rows SYS_REFCURSOR;
WHERE (SELECT cb_ords_handler_pkg.set_vpd_context_sql(:auth_header) FROM dual) = 1 BEGIN
AND ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500) cb_ords_handler_pkg.set_vpd_context(:auth_header);
OPEN v_rows FOR
SELECT %s
FROM %s.%s o
WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500);
:status_code := 200;
OWA_UTIL.MIME_HEADER('application/json', FALSE);
HTP.P('Cache-Control: no-store');
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
APEX_JSON.WRITE('items', v_rows);
APEX_JSON.CLOSE_OBJECT;
cb_ords_handler_pkg.clear_vpd_context;
EXCEPTION
WHEN OTHERS THEN
cb_ords_handler_pkg.clear_vpd_context;
:status_code := 403;
OWA_UTIL.MIME_HEADER('application/json', FALSE);
HTP.P('Cache-Control: no-store');
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
APEX_JSON.WRITE('error', SQLERRM);
APEX_JSON.CLOSE_OBJECT;
END;
""".formatted(selectColumns, object.owner(), object.objectName()); """.formatted(selectColumns, object.owner(), object.objectName());
} }

View File

@@ -192,28 +192,27 @@ public class OrdsProbeService {
if (rows.isEmpty()) { if (rows.isEmpty()) {
return List.of(); return List.of();
} }
List<String> sensitiveColumns = protectedObjectService.findColumns(objectId).stream() List<ProtectedColumn> sensitiveColumns = protectedObjectService.findColumns(objectId).stream()
.filter(ProtectedColumn::sensitive) .filter(ProtectedColumn::sensitive)
.map(column -> column.columnName().toLowerCase(Locale.ROOT))
.toList(); .toList();
if (sensitiveColumns.isEmpty()) { if (sensitiveColumns.isEmpty()) {
return List.of(); return List.of();
} }
List<String> masked = new ArrayList<>(); List<String> masked = new ArrayList<>();
for (String column : sensitiveColumns) { for (ProtectedColumn column : sensitiveColumns) {
boolean present = false; boolean present = false;
boolean allNull = true; boolean allNull = true;
for (Map<String, Object> row : rows) { for (Map<String, Object> row : rows) {
for (Map.Entry<String, Object> entry : row.entrySet()) { for (Map.Entry<String, Object> entry : row.entrySet()) {
if (entry.getKey().equalsIgnoreCase(column)) { if (entry.getKey().equalsIgnoreCase(column.columnName())) {
present = true; present = true;
allNull = allNull && entry.getValue() == null; allNull = allNull && entry.getValue() == null;
} }
} }
} }
if (present && allNull) { if (present && allNull) {
masked.add(column); masked.add(column.columnName().toLowerCase(Locale.ROOT) + " [" + column.policyLabel() + "]");
} }
} }
return masked; return masked;

View File

@@ -19,7 +19,12 @@ import org.springframework.transaction.annotation.Transactional;
@Service @Service
public class PermissionService { public class PermissionService {
private static final Set<String> RULE_TYPES = Set.of("ALL", "=", "!=", "MY_DEPT", "SELF"); private static final Set<String> RULE_TYPES = Set.of("ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO");
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of("=", "!=", "DEPT", "EMP_NO");
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of("MY_DEPT", "SELF", "DEPT", "EMP_NO");
private static final Set<String> PERMISSION_EFFECTS = Set.of("ALLOW", "DENY");
private static final Set<String> SENSITIVITY_LEVELS = Set.of(
"PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED");
private final PermissionMapper permissionMapper; private final PermissionMapper permissionMapper;
private final ProtectedObjectService protectedObjectService; private final ProtectedObjectService protectedObjectService;
@@ -45,14 +50,30 @@ public class PermissionService {
@Transactional @Transactional
public void createRole(String roleName, String description) { public void createRole(String roleName, String description) {
createRole(roleName, description, "PUBLIC");
}
@Transactional
public void createRole(String roleName, String description, String maxSensitivityLevel) {
if (roleName == null || roleName.isBlank()) { if (roleName == null || roleName.isBlank()) {
throw new AppException("역할명은 필수입니다."); throw new AppException("역할명은 필수입니다.");
} }
long roleId = permissionMapper.nextRoleId(); long roleId = permissionMapper.nextRoleId();
permissionMapper.insertRole(roleId, roleName.trim(), description); permissionMapper.insertRole(roleId, roleName.trim(), description, normalizeSensitivityLevel(maxSensitivityLevel));
auditService.record(new AuditEvent("ROLE_CREATED", null, null, "SUCCESS", null, null, roleName)); auditService.record(new AuditEvent("ROLE_CREATED", null, null, "SUCCESS", null, null, roleName));
} }
@Transactional
public void updateRoleMaxSensitivity(long roleId, String maxSensitivityLevel) {
String normalized = normalizeSensitivityLevel(maxSensitivityLevel);
int updated = permissionMapper.updateRoleMaxSensitivity(roleId, normalized);
if (updated == 0) {
throw new AppException("수정할 역할을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("ROLE_MAX_SENSITIVITY_UPDATED", null, null, "SUCCESS", null, null,
"roleId=" + roleId + ", max=" + normalized));
}
@Transactional @Transactional
public void deleteRole(long roleId) { public void deleteRole(long roleId) {
int deleted = permissionMapper.deleteRole(roleId); int deleted = permissionMapper.deleteRole(roleId);
@@ -67,6 +88,7 @@ public class PermissionService {
if (!"SELECT".equalsIgnoreCase(command.action())) { if (!"SELECT".equalsIgnoreCase(command.action())) {
throw new AppException("초기 구현에서는 SELECT 권한만 저장할 수 있습니다."); throw new AppException("초기 구현에서는 SELECT 권한만 저장할 수 있습니다.");
} }
String permissionEffect = normalizePermissionEffect(command.permissionEffect());
AppRole role = permissionMapper.findRole(command.roleId()); AppRole role = permissionMapper.findRole(command.roleId());
if (role == null) { if (role == null) {
throw new AppException("역할을 찾을 수 없습니다."); throw new AppException("역할을 찾을 수 없습니다.");
@@ -78,9 +100,10 @@ public class PermissionService {
Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId()); Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId());
long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId; long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId;
if (existingId == null) { if (existingId == null) {
permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT"); permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect);
} else { } else {
permissionMapper.updatePermissionAction(permissionId, "SELECT"); permissionMapper.updatePermissionAction(permissionId, "SELECT");
permissionMapper.updatePermissionEffect(permissionId, permissionEffect);
} }
permissionMapper.deleteRules(permissionId); permissionMapper.deleteRules(permissionId);
@@ -105,7 +128,7 @@ public class PermissionService {
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null, "PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
"roleId=" + command.roleId() "roleId=" + command.roleId()
)); ));
return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", List.of(), List.of()); return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect, List.of(), List.of());
} }
@Transactional @Transactional
@@ -137,17 +160,17 @@ public class PermissionService {
throw new AppException("허용되지 않은 행 규칙입니다: " + type); throw new AppException("허용되지 않은 행 규칙입니다: " + type);
} }
String column = normalizeNullable(rule.ruleColumn()); String column = normalizeNullable(rule.ruleColumn());
if (!"ALL".equals(type) && !allowedColumns.contains(column)) { if (column != null && !allowedColumns.contains(column)) {
throw new AppException("행 규칙 컬럼은 보호 객체 컬럼이어야 합니다: " + column); throw new AppException("행 규칙 컬럼은 보호 객체 컬럼이어야 합니다: " + column);
} }
if (!seen.add(column + ":" + type + ":" + clean(rule.ruleValue()))) { if (!seen.add(column + ":" + type + ":" + clean(rule.ruleValue()))) {
throw new AppException("중복된 행 규칙이 있습니다."); throw new AppException("중복된 행 규칙이 있습니다.");
} }
hasAll = hasAll || "ALL".equals(type); hasAll = hasAll || "ALL".equals(type);
if (!"ALL".equals(type) && column.isBlank()) { if (!"ALL".equals(type) && column == null && !DEFAULT_COLUMN_RULE_TYPES.contains(type)) {
throw new AppException(type + " 규칙에는 컬럼이 필요합니다."); throw new AppException(type + " 규칙에는 컬럼이 필요합니다.");
} }
if (("=".equals(type) || "!=".equals(type)) && clean(rule.ruleValue()).isBlank()) { if (VALUE_REQUIRED_RULE_TYPES.contains(type) && clean(rule.ruleValue()).isBlank()) {
throw new AppException(type + " 규칙에는 값이 필요합니다."); throw new AppException(type + " 규칙에는 값이 필요합니다.");
} }
} }
@@ -183,6 +206,22 @@ public class PermissionService {
return clean(value).toUpperCase(Locale.ROOT); return clean(value).toUpperCase(Locale.ROOT);
} }
private String normalizePermissionEffect(String value) {
String effect = clean(value).isBlank() ? "ALLOW" : normalize(value);
if (!PERMISSION_EFFECTS.contains(effect)) {
throw new AppException("허용되지 않은 권한 효과입니다: " + effect);
}
return effect;
}
private String normalizeSensitivityLevel(String value) {
String level = clean(value).isBlank() ? "PUBLIC" : normalize(value);
if (!SENSITIVITY_LEVELS.contains(level)) {
throw new AppException("허용되지 않은 민감도 등급입니다: " + level);
}
return level;
}
private String normalizeNullable(String value) { private String normalizeNullable(String value) {
String cleaned = clean(value); String cleaned = clean(value);
return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT); return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT);

View File

@@ -25,6 +25,9 @@ public class ProtectedObjectService {
private final Map<String, CacheEntry<List<String>>> databaseColumnsCache = new ConcurrentHashMap<>(); private final Map<String, CacheEntry<List<String>>> databaseColumnsCache = new ConcurrentHashMap<>();
private final Map<Long, CacheEntry<List<ProtectedColumn>>> protectedColumnsCache = new ConcurrentHashMap<>(); private final Map<Long, CacheEntry<List<ProtectedColumn>>> protectedColumnsCache = new ConcurrentHashMap<>();
private static final long CATALOG_CACHE_MILLIS = 60_000L; private static final long CATALOG_CACHE_MILLIS = 60_000L;
private static final Set<String> SENSITIVITY_LEVELS = Set.of(
"PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED");
private static final Set<String> REDACTION_METHODS = Set.of("NONE", "NULLIFY", "PARTIAL", "FULL");
public ProtectedObjectService(ProtectedObjectMapper mapper, AuditService auditService) { public ProtectedObjectService(ProtectedObjectMapper mapper, AuditService auditService) {
this.mapper = mapper; this.mapper = mapper;
@@ -90,7 +93,9 @@ public class ProtectedObjectService {
mapper.insertObject(objectId, normalized); mapper.insertObject(objectId, normalized);
Set<String> sensitive = splitCsv(normalized.sensitiveColumns()); Set<String> sensitive = splitCsv(normalized.sensitiveColumns());
for (String column : splitCsv(normalized.columns())) { for (String column : splitCsv(normalized.columns())) {
mapper.insertColumn(mapper.nextColumnId(), objectId, column, sensitive.contains(column) ? "Y" : "N"); String sensitiveYn = sensitive.contains(column) ? "Y" : "N";
mapper.insertColumn(mapper.nextColumnId(), objectId, column, sensitiveYn,
defaultSensitivityLevel(sensitiveYn), defaultRedactionMethod(sensitiveYn));
} }
databaseObjectsCache = null; databaseObjectsCache = null;
protectedColumnsCache.remove(objectId); protectedColumnsCache.remove(objectId);
@@ -137,7 +142,7 @@ public class ProtectedObjectService {
long objectId = mapper.nextObjectId(); long objectId = mapper.nextObjectId();
mapper.insertObject(objectId, command); mapper.insertObject(objectId, command);
for (String column : columns) { for (String column : columns) {
mapper.insertColumn(mapper.nextColumnId(), objectId, column, "N"); mapper.insertColumn(mapper.nextColumnId(), objectId, column, "N", "PUBLIC", "NONE");
} }
databaseObjectsCache = null; databaseObjectsCache = null;
protectedColumnsCache.remove(objectId); protectedColumnsCache.remove(objectId);
@@ -187,6 +192,22 @@ public class ProtectedObjectService {
ordsPath.trim())); ordsPath.trim()));
} }
@Transactional
public void updateColumnPolicy(long columnId, String sensitivityLevel, String redactionMethod) {
String normalizedLevel = normalizeOption(sensitivityLevel, "PUBLIC", SENSITIVITY_LEVELS, "민감도 등급");
String normalizedMethod = normalizeOption(redactionMethod, "NONE", REDACTION_METHODS, "마스킹 방식");
if ("PUBLIC".equals(normalizedLevel) && !"NONE".equals(normalizedMethod)) {
throw new AppException("PUBLIC 컬럼의 마스킹 방식은 NONE이어야 합니다.");
}
int updated = mapper.updateColumnPolicy(columnId, normalizedLevel, normalizedMethod);
if (updated == 0) {
throw new AppException("수정할 컬럼 정책을 찾을 수 없습니다.");
}
protectedColumnsCache.clear();
auditService.record(new AuditEvent("PROTECTED_COLUMN_POLICY_UPDATED", null, null, "SUCCESS", null, null,
"columnId=" + columnId + ", " + normalizedLevel + "/" + normalizedMethod));
}
@Transactional @Transactional
public void disableObject(long objectId) { public void disableObject(long objectId) {
int updated = mapper.disableObject(objectId); int updated = mapper.disableObject(objectId);
@@ -211,6 +232,24 @@ public class ProtectedObjectService {
return result; return result;
} }
private String defaultSensitivityLevel(String sensitiveYn) {
return "Y".equalsIgnoreCase(sensitiveYn) ? "CONFIDENTIAL" : "PUBLIC";
}
private String defaultRedactionMethod(String sensitiveYn) {
return "Y".equalsIgnoreCase(sensitiveYn) ? "NULLIFY" : "NONE";
}
private String normalizeOption(String value, String defaultValue, Set<String> allowed, String label) {
String normalized = value == null || value.isBlank()
? defaultValue
: value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) {
throw new AppException("허용되지 않은 " + label + "입니다: " + normalized);
}
return normalized;
}
private record CacheEntry<T>(T value, long expiresAt) { private record CacheEntry<T>(T value, long expiresAt) {
boolean expired() { boolean expired() {

View File

@@ -1,22 +1,47 @@
package com.cloudhandson.vpdbackoffice.service; package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdBulkApplyResult;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionSource; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionSource;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdObjectFilterDetail;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyDetail; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyDetail;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyExplanation; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyExplanation;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyFormOptions;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyTemplateOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdTargetView;
import com.cloudhandson.vpdbackoffice.mapper.VpdPolicyMapper; import com.cloudhandson.vpdbackoffice.mapper.VpdPolicyMapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service @Service
public class VpdPolicyService { public class VpdPolicyService {
private final VpdPolicyMapper mapper; private static final Set<String> ALLOWED_STATEMENTS = Set.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX");
private final OpenAiCompatibleClient aiClient; private static final long CATALOG_CACHE_MILLIS = 60_000L;
private static final String COMMON_POLICY_NAME = "CB_PERMISSION_SELECT_POLICY";
private static final String DEFAULT_PERMISSION_FILTER_FUNCTION = "CB_AGENT_DOC_VPD_FILTER";
public VpdPolicyService(VpdPolicyMapper mapper, OpenAiCompatibleClient aiClient) { private final VpdPolicyMapper mapper;
private final JdbcTemplate jdbcTemplate;
private final OpenAiCompatibleClient aiClient;
private final Map<String, CacheEntry<List<VpdTargetView>>> vpdTargetsCache = new ConcurrentHashMap<>();
private volatile CacheEntry<VpdPolicyFormOptions> formOptionsCache;
public VpdPolicyService(VpdPolicyMapper mapper, JdbcTemplate jdbcTemplate, OpenAiCompatibleClient aiClient) {
this.mapper = mapper; this.mapper = mapper;
this.jdbcTemplate = jdbcTemplate;
this.aiClient = aiClient; this.aiClient = aiClient;
} }
@@ -24,6 +49,179 @@ public class VpdPolicyService {
return mapper.findPolicies(); return mapper.findPolicies();
} }
public List<VpdTargetView> findVpdTargets() {
return findVpdTargets(null);
}
public List<VpdTargetView> findVpdTargets(String owner) {
String normalizedOwner = normalizeOptionalIdentifier(owner);
String cacheKey = normalizedOwner == null ? "__MANAGED__" : normalizedOwner;
CacheEntry<List<VpdTargetView>> cached = vpdTargetsCache.get(cacheKey);
if (cached != null && !cached.expired()) {
return cached.value();
}
List<VpdTargetView> targets = List.copyOf(mapper.findVpdTargets(normalizedOwner));
vpdTargetsCache.put(cacheKey, new CacheEntry<>(targets, System.currentTimeMillis() + CATALOG_CACHE_MILLIS));
return targets;
}
public VpdPolicyFormOptions formOptions() {
CacheEntry<VpdPolicyFormOptions> cached = formOptionsCache;
if (cached != null && !cached.expired()) {
return cached.value();
}
List<VpdFunctionOption> functions = mapper.findFunctionOptions();
VpdPolicyFormOptions options = new VpdPolicyFormOptions(
mapper.findPolicyNameOptions(),
mapper.findSchemaOwnerOptions(),
mapper.findOwnerOptions(),
functions,
buildPolicyTemplateOptions(functions, mapper.findPolicyTemplateOptions()),
List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX")
);
formOptionsCache = new CacheEntry<>(options, System.currentTimeMillis() + CATALOG_CACHE_MILLIS);
return options;
}
public VpdPolicyFormOptions emptyFormOptions() {
return new VpdPolicyFormOptions(
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX")
);
}
@Transactional
public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) {
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
String functionOwner = functionOwnerValue == null || functionOwnerValue.isBlank()
? currentUser
: requiredIdentifier(functionOwnerValue, "Function owner");
if (currentUser == null || !currentUser.equalsIgnoreCase(functionOwner)) {
throw new AppException("Filter function 등록/수정은 현재 연결 사용자 스키마에만 가능합니다. 현재 사용자: "
+ currentUser + ", Function owner: " + functionOwner);
}
String functionName = requiredIdentifier(functionNameValue, "Function name");
String filterPredicate = filterPredicateValue == null ? "" : filterPredicateValue.trim();
if (filterPredicate.isBlank()) {
throw new AppException("Filter predicate는 필수입니다.");
}
createFilterFunction(functionName, filterPredicate);
clearCatalogCache();
}
@Transactional
public void replacePolicy(String oldObjectKey, String oldPolicyName, VpdPolicyCreateCommand command) {
String[] objectParts = oldObjectKey == null ? new String[0] : oldObjectKey.split("\\.", 2);
if (objectParts.length != 2) {
throw new AppException("수정할 Policy 대상 형식이 올바르지 않습니다: " + oldObjectKey);
}
String objectOwner = requiredIdentifier(objectParts[0], "Object owner");
String objectName = requiredIdentifier(objectParts[1], "Object name");
String policyName = requiredIdentifier(oldPolicyName, "Policy name");
jdbcTemplate.update("""
BEGIN
DBMS_RLS.DROP_POLICY(
object_schema => ?,
object_name => ?,
policy_name => ?
);
END;
""", objectOwner, objectName, policyName);
createPolicy(command);
clearCatalogCache();
}
public VpdBulkApplyResult bulkApplySchema(
String schemaOwner,
boolean includeTables,
boolean includeViews,
String functionKey,
String functionOwnerValue,
String functionNameValue,
String statementTypesValue,
boolean enabled,
boolean updateCheck,
String filterPredicateValue
) {
String owner = requiredIdentifier(schemaOwner, "Schema");
if (!includeTables && !includeViews) {
throw new AppException("TABLE 또는 VIEW 중 하나 이상 선택해야 합니다.");
}
List<VpdSchemaObjectOption> targets = mapper.findSchemaObjects(
owner,
includeTables ? "Y" : "N",
includeViews ? "Y" : "N"
);
if (targets.isEmpty()) {
throw new AppException("선택한 스키마에서 VPD 적용 대상 TABLE/VIEW를 찾을 수 없습니다: " + owner);
}
FunctionRef functionRef = parseFunctionRef(defaultFunctionKey(functionKey));
String filterPredicate = filterPredicateValue == null ? "" : filterPredicateValue.trim();
if (functionRef == null && filterPredicate.isBlank()) {
throw new AppException("벌크 적용은 기존 Function을 선택하거나 Filter predicate를 입력해야 합니다.");
}
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
String functionOwner;
String packageName;
String functionName;
if (functionRef != null) {
functionOwner = functionRef.owner();
packageName = functionRef.packageName();
functionName = functionRef.functionName();
} else {
functionOwner = functionOwnerValue == null || functionOwnerValue.isBlank()
? currentUser
: requiredIdentifier(functionOwnerValue, "Function owner");
packageName = null;
functionName = functionNameValue == null || functionNameValue.isBlank()
? generatedFunctionName(owner + "_BULK_POLICY")
: requiredIdentifier(functionNameValue, "Function name");
}
if (!filterPredicate.isBlank()) {
if (currentUser == null || !currentUser.equalsIgnoreCase(functionOwner)) {
throw new AppException("필터 함수 자동 생성은 현재 연결 사용자 스키마에만 가능합니다. 현재 사용자: "
+ currentUser + ", Function owner: " + functionOwner);
}
createFilterFunction(functionName, filterPredicate);
}
String statementTypes = normalizeStatementTypes(statementTypesValue);
int created = 0;
int skipped = 0;
int failed = 0;
for (VpdSchemaObjectOption target : targets) {
String policyName = generatedPolicyName(target.objectName());
if (mapper.findAnyPolicy(target.owner(), target.objectName(), policyName) != null) {
skipped++;
continue;
}
try {
addPolicy(
target.owner(),
target.objectName(),
policyName,
functionOwner,
packageName == null ? functionName : packageName + "." + functionName,
statementTypes,
enabled,
updateCheck
);
created++;
} catch (DataAccessException exception) {
failed++;
}
}
clearCatalogCache();
return new VpdBulkApplyResult(targets.size(), created, skipped, failed);
}
public VpdFunctionSource findFunctionSource(String owner, String packageName, String functionName) { public VpdFunctionSource findFunctionSource(String owner, String packageName, String functionName) {
String normalizedOwner = requiredIdentifier(owner, "Function owner"); String normalizedOwner = requiredIdentifier(owner, "Function owner");
String normalizedFunction = requiredIdentifier(functionName, "Function name"); String normalizedFunction = requiredIdentifier(functionName, "Function name");
@@ -45,6 +243,129 @@ public class VpdPolicyService {
return new VpdPolicyDetail(policy, buildAddPolicyBlock(policy)); return new VpdPolicyDetail(policy, buildAddPolicyBlock(policy));
} }
public VpdObjectFilterDetail findObjectFilterDetail(String objectOwner, String objectName) {
String normalizedOwner = requiredIdentifier(objectOwner, "Object owner");
String normalizedObject = requiredIdentifier(objectName, "Object name");
List<VpdObjectFilterDetail.Row> rows = mapper.findPoliciesForObject(normalizedOwner, normalizedObject).stream()
.map(policy -> new VpdObjectFilterDetail.Row(
policy,
findFunctionSource(policy.functionOwner(), policy.packageName(), policy.functionName())
))
.toList();
return new VpdObjectFilterDetail(normalizedOwner, normalizedObject, rows);
}
@Transactional
public void createPolicy(VpdPolicyCreateCommand command) {
String objectOwner = requiredIdentifier(command.objectOwner(), "Object owner");
String objectName = requiredIdentifier(command.objectName(), "Object name");
String policyName = requiredIdentifier(command.policyName(), "Policy name");
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
FunctionRef functionRef = parseFunctionRef(defaultFunctionKey(command.functionKey()));
String functionOwner;
String packageName;
String functionName;
if (functionRef != null) {
functionOwner = functionRef.owner();
packageName = functionRef.packageName();
functionName = functionRef.functionName();
} else {
functionOwner = command.functionOwner() == null || command.functionOwner().isBlank()
? currentUser
: requiredIdentifier(command.functionOwner(), "Function owner");
packageName = null;
functionName = command.functionName() == null || command.functionName().isBlank()
? generatedFunctionName(policyName)
: requiredIdentifier(command.functionName(), "Function name");
}
String statementTypes = normalizeStatementTypes(command.statementTypes());
String filterPredicate = command.filterPredicate() == null ? "" : command.filterPredicate().trim();
if (!filterPredicate.isBlank()) {
if (currentUser == null || !currentUser.equalsIgnoreCase(functionOwner)) {
throw new AppException("필터 함수 자동 생성은 현재 연결 사용자 스키마에만 가능합니다. 현재 사용자: "
+ currentUser + ", Function owner: " + functionOwner);
}
createFilterFunction(functionName, filterPredicate);
}
addPolicy(
objectOwner,
objectName,
policyName,
functionOwner,
packageName == null ? functionName : packageName + "." + functionName,
statementTypes,
command.enabled(),
command.updateCheck()
);
clearCatalogCache();
}
public void clearCatalogCache() {
vpdTargetsCache.clear();
formOptionsCache = null;
}
private List<VpdPolicyTemplateOption> buildPolicyTemplateOptions(
List<VpdFunctionOption> functions,
List<VpdPolicyTemplateOption> existingTemplates
) {
List<VpdPolicyTemplateOption> templates = new ArrayList<>();
functions.stream()
.filter(function -> DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(function.functionName()))
.findFirst()
.ifPresent(function -> templates.add(new VpdPolicyTemplateOption(
COMMON_POLICY_NAME,
function.owner(),
function.packageName(),
function.functionName(),
"SELECT",
"YES",
"NO"
)));
templates.addAll(existingTemplates);
Map<String, VpdPolicyTemplateOption> uniqueTemplates = new LinkedHashMap<>();
for (VpdPolicyTemplateOption template : templates) {
uniqueTemplates.putIfAbsent(template.label().toUpperCase(Locale.ROOT), template);
}
return List.copyOf(uniqueTemplates.values());
}
private void addPolicy(
String objectOwner,
String objectName,
String policyName,
String functionOwner,
String policyFunction,
String statementTypes,
boolean enabled,
boolean updateCheck
) {
jdbcTemplate.update("""
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => ?,
object_name => ?,
policy_name => ?,
function_schema => ?,
policy_function => ?,
statement_types => ?,
update_check => %s,
enable => %s,
policy_type => DBMS_RLS.DYNAMIC
);
END;
""".formatted(updateCheck ? "TRUE" : "FALSE", enabled ? "TRUE" : "FALSE"),
objectOwner,
objectName,
policyName,
functionOwner,
policyFunction,
statementTypes);
}
public VpdPolicyExplanation explainPolicy(String objectOwner, String objectName, String policyName) { public VpdPolicyExplanation explainPolicy(String objectOwner, String objectName, String policyName) {
VpdPolicyDetail detail = findPolicyDetail(objectOwner, objectName, policyName); VpdPolicyDetail detail = findPolicyDetail(objectOwner, objectName, policyName);
VpdPolicyView policy = detail.policy(); VpdPolicyView policy = detail.policy();
@@ -196,6 +517,86 @@ public class VpdPolicyService {
); );
} }
private void createFilterFunction(String functionName, String filterPredicate) {
jdbcTemplate.execute("""
CREATE OR REPLACE FUNCTION %s(
p_schema_name IN VARCHAR2,
p_object_name IN VARCHAR2
) RETURN VARCHAR2
AS
BEGIN
RETURN '%s';
END;
""".formatted(functionName, escapeSqlLiteral(filterPredicate)));
}
private String generatedFunctionName(String policyName) {
String base = policyName.endsWith("_POLICY")
? policyName.substring(0, policyName.length() - "_POLICY".length())
: policyName;
String generated = base + "_FILTER";
return generated.length() > 128 ? generated.substring(0, 128) : generated;
}
private String generatedPolicyName(String objectName) {
return COMMON_POLICY_NAME;
}
private FunctionRef parseFunctionRef(String functionKey) {
if (functionKey == null || functionKey.isBlank()) {
return null;
}
String[] parts = functionKey.trim().toUpperCase(Locale.ROOT).split("\\.");
if (parts.length == 2) {
return new FunctionRef(requiredIdentifier(parts[0], "Function owner"), null,
requiredIdentifier(parts[1], "Function name"));
}
if (parts.length == 3) {
return new FunctionRef(requiredIdentifier(parts[0], "Function owner"),
requiredIdentifier(parts[1], "Package name"), requiredIdentifier(parts[2], "Function name"));
}
throw new AppException("Function 선택 값이 올바르지 않습니다: " + functionKey);
}
private String defaultFunctionKey(String functionKey) {
if (functionKey != null && !functionKey.isBlank()) {
return functionKey;
}
return formOptions().defaultPermissionFunctionKey();
}
private String normalizeStatementTypes(String value) {
String raw = value == null || value.isBlank() ? "SELECT" : value;
List<String> statements = List.of(raw.split(",")).stream()
.map(String::trim)
.filter(token -> !token.isBlank())
.map(token -> token.toUpperCase(Locale.ROOT))
.toList();
if (statements.isEmpty()) {
return "SELECT";
}
for (String statement : statements) {
if (!ALLOWED_STATEMENTS.contains(statement)) {
throw new AppException("지원하지 않는 statement type입니다: " + statement);
}
}
return String.join(",", statements);
}
private String escapeSqlLiteral(String value) {
return value.replace("'", "''");
}
private record CacheEntry<T>(T value, long expiresAt) {
boolean expired() {
return System.currentTimeMillis() > expiresAt;
}
}
private record FunctionRef(String owner, String packageName, String functionName) {
}
private String policyFunctionArgument(VpdPolicyView policy) { private String policyFunctionArgument(VpdPolicyView policy) {
if (policy.packageName() == null || policy.packageName().isBlank()) { if (policy.packageName() == null || policy.packageName().isBlank()) {
return policy.functionName(); return policy.functionName();

View File

@@ -0,0 +1,103 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.group.GroupCreateCommand;
import com.cloudhandson.vpdbackoffice.service.GroupService;
import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.UserService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class GroupController {
private final GroupService groupService;
private final UserService userService;
private final PermissionService permissionService;
public GroupController(GroupService groupService, UserService userService, PermissionService permissionService) {
this.groupService = groupService;
this.userService = userService;
this.permissionService = permissionService;
}
@GetMapping("/groups")
public String groups(Model model) {
model.addAttribute("groups", groupService.findAll());
model.addAttribute("users", userService.findAll());
model.addAttribute("roles", permissionService.findRoles());
model.addAttribute("groupUsers", groupService.findGroupUsers());
model.addAttribute("groupRoles", groupService.findGroupRoles());
return "groups";
}
@PostMapping("/groups")
public String create(
@RequestParam String groupCode,
@RequestParam String groupName,
@RequestParam(required = false) String description,
RedirectAttributes redirectAttributes
) {
groupService.createGroup(new GroupCreateCommand(groupCode, groupName, description));
redirectAttributes.addFlashAttribute("message", "그룹을 추가했습니다.");
return "redirect:/groups";
}
@PostMapping("/groups/active")
public String active(
@RequestParam long groupId,
@RequestParam boolean active,
RedirectAttributes redirectAttributes
) {
groupService.setActive(groupId, active);
redirectAttributes.addFlashAttribute("message", "그룹 상태를 변경했습니다.");
return "redirect:/groups";
}
@PostMapping("/groups/users")
public String addUser(
@RequestParam long groupId,
@RequestParam long userId,
RedirectAttributes redirectAttributes
) {
groupService.addUser(groupId, userId);
redirectAttributes.addFlashAttribute("message", "그룹에 사용자를 추가했습니다.");
return "redirect:/groups";
}
@PostMapping("/groups/users/delete")
public String removeUser(
@RequestParam long groupId,
@RequestParam long userId,
RedirectAttributes redirectAttributes
) {
groupService.removeUser(groupId, userId);
redirectAttributes.addFlashAttribute("message", "그룹 사용자를 해제했습니다.");
return "redirect:/groups";
}
@PostMapping("/groups/roles")
public String addRole(
@RequestParam long groupId,
@RequestParam long roleId,
RedirectAttributes redirectAttributes
) {
groupService.addRole(groupId, roleId);
redirectAttributes.addFlashAttribute("message", "그룹에 역할을 부여했습니다.");
return "redirect:/groups";
}
@PostMapping("/groups/roles/delete")
public String removeRole(
@RequestParam long groupId,
@RequestParam long roleId,
RedirectAttributes redirectAttributes
) {
groupService.removeRole(groupId, roleId);
redirectAttributes.addFlashAttribute("message", "그룹 역할을 해제했습니다.");
return "redirect:/groups";
}
}

View File

@@ -0,0 +1,22 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.OperationStatusService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class OperationStatusController {
private final OperationStatusService service;
public OperationStatusController(OperationStatusService service) {
this.service = service;
}
@GetMapping("/operation-status")
public String status(Model model) {
model.addAttribute("rows", service.findRows());
return "operation-status";
}
}

View File

@@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand; import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand; import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.service.PermissionService; import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import java.util.Arrays; import java.util.Arrays;
@@ -74,6 +75,22 @@ public class PermissionController {
.map(column -> column.columnName()) .map(column -> column.columnName())
.toList() .toList()
)); ));
var maskableColumnsByObject = objects.stream()
.collect(Collectors.toMap(
object -> object.objectId(),
object -> protectedObjectService.findColumns(object.objectId()).stream()
.filter(ProtectedColumn::sensitive)
.map(column -> column.columnName())
.toList()
));
var maskableColumnLabelsByObject = objects.stream()
.collect(Collectors.toMap(
object -> object.objectId(),
object -> protectedObjectService.findColumns(object.objectId()).stream()
.filter(ProtectedColumn::sensitive)
.map(column -> column.columnName() + " [" + column.policyLabel() + "]")
.toList()
));
long columnsAt = System.nanoTime(); long columnsAt = System.nanoTime();
var dbObjects = protectedObjectService.findDatabaseObjects(); var dbObjects = protectedObjectService.findDatabaseObjects();
long dbObjectsAt = System.nanoTime(); long dbObjectsAt = System.nanoTime();
@@ -89,6 +106,8 @@ public class PermissionController {
model.addAttribute("roles", roles); model.addAttribute("roles", roles);
model.addAttribute("objects", objects); model.addAttribute("objects", objects);
model.addAttribute("columnsByObject", columnsByObject); model.addAttribute("columnsByObject", columnsByObject);
model.addAttribute("maskableColumnsByObject", maskableColumnsByObject);
model.addAttribute("maskableColumnLabelsByObject", maskableColumnLabelsByObject);
model.addAttribute("dbObjects", dbObjects); model.addAttribute("dbObjects", dbObjects);
model.addAttribute("permissions", permissions); model.addAttribute("permissions", permissions);
return "permissions"; return "permissions";
@@ -102,6 +121,7 @@ public class PermissionController {
public String save( public String save(
@RequestParam long roleId, @RequestParam long roleId,
@RequestParam String objectRef, @RequestParam String objectRef,
@RequestParam(defaultValue = "ALLOW") String permissionEffect,
@RequestParam(required = false) List<String> ruleColumn, @RequestParam(required = false) List<String> ruleColumn,
@RequestParam List<String> ruleType, @RequestParam List<String> ruleType,
@RequestParam(required = false) List<String> ruleValue, @RequestParam(required = false) List<String> ruleValue,
@@ -113,6 +133,7 @@ public class PermissionController {
roleId, roleId,
objectId, objectId,
"SELECT", "SELECT",
permissionEffect,
buildRules(ruleColumn, ruleType, ruleValue), buildRules(ruleColumn, ruleType, ruleValue),
splitColumns(visibleColumns) splitColumns(visibleColumns)
)); ));

View File

@@ -4,6 +4,7 @@ import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObjectCrea
import com.cloudhandson.vpdbackoffice.service.AppException; import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.OrdsMetadataService; import com.cloudhandson.vpdbackoffice.service.OrdsMetadataService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import java.util.stream.Collectors;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
@@ -28,8 +29,14 @@ public class ProtectedObjectController {
@GetMapping("/objects") @GetMapping("/objects")
public String objects(Model model) { public String objects(Model model) {
model.addAttribute("objects", protectedObjectService.findEnabled()); var objects = protectedObjectService.findEnabled();
model.addAttribute("objects", objects);
model.addAttribute("dbObjects", protectedObjectService.findDatabaseObjects()); model.addAttribute("dbObjects", protectedObjectService.findDatabaseObjects());
model.addAttribute("columnsByObject", objects.stream()
.collect(Collectors.toMap(
object -> object.objectId(),
object -> protectedObjectService.findColumns(object.objectId())
)));
return "objects"; return "objects";
} }
@@ -65,6 +72,22 @@ public class ProtectedObjectController {
return "redirect:/objects"; return "redirect:/objects";
} }
@PostMapping("/objects/column-policy")
public String updateColumnPolicy(
@RequestParam long columnId,
@RequestParam String sensitivityLevel,
@RequestParam String redactionMethod,
RedirectAttributes redirectAttributes
) {
try {
protectedObjectService.updateColumnPolicy(columnId, sensitivityLevel, redactionMethod);
redirectAttributes.addFlashAttribute("message", "컬럼 민감도/마스킹 정책을 수정했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/objects";
}
@PostMapping("/objects/ords-handler") @PostMapping("/objects/ords-handler")
public String createOrdsHandler(@RequestParam long objectId, RedirectAttributes redirectAttributes) { public String createOrdsHandler(@RequestParam long objectId, RedirectAttributes redirectAttributes) {
try { try {

View File

@@ -27,13 +27,25 @@ public class RoleController {
public String create( public String create(
@RequestParam String roleName, @RequestParam String roleName,
@RequestParam(required = false) String description, @RequestParam(required = false) String description,
@RequestParam(defaultValue = "PUBLIC") String maxSensitivityLevel,
RedirectAttributes redirectAttributes RedirectAttributes redirectAttributes
) { ) {
permissionService.createRole(roleName, description); permissionService.createRole(roleName, description, maxSensitivityLevel);
redirectAttributes.addFlashAttribute("message", "역할을 추가했습니다."); redirectAttributes.addFlashAttribute("message", "역할을 추가했습니다.");
return "redirect:/roles"; return "redirect:/roles";
} }
@PostMapping("/roles/max-sensitivity")
public String updateMaxSensitivity(
@RequestParam long roleId,
@RequestParam String maxSensitivityLevel,
RedirectAttributes redirectAttributes
) {
permissionService.updateRoleMaxSensitivity(roleId, maxSensitivityLevel);
redirectAttributes.addFlashAttribute("message", "역할 민감도 허용 상한을 수정했습니다.");
return "redirect:/roles";
}
@PostMapping("/roles/delete") @PostMapping("/roles/delete")
public String delete(@RequestParam long roleId, RedirectAttributes redirectAttributes) { public String delete(@RequestParam long roleId, RedirectAttributes redirectAttributes) {
permissionService.deleteRole(roleId); permissionService.deleteRole(roleId);

View File

@@ -1,5 +1,6 @@
package com.cloudhandson.vpdbackoffice.web; package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
import com.cloudhandson.vpdbackoffice.service.AppException; import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService; import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
import java.util.List; import java.util.List;
@@ -7,7 +8,9 @@ import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller @Controller
public class VpdPolicyController { public class VpdPolicyController {
@@ -19,15 +22,215 @@ public class VpdPolicyController {
} }
@GetMapping("/vpd-policies") @GetMapping("/vpd-policies")
public String policies(Model model) { public String policies(
@RequestParam(required = false) String schemaOwner,
Model model
) {
populatePolicyModel(schemaOwner, model);
return "vpd-policies";
}
@GetMapping("/vpd-filter-policies")
public String filterPolicies(
@RequestParam(required = false) String schemaOwner,
Model model
) {
populatePolicyModel(schemaOwner, model);
return "vpd-filter-policies";
}
private void populatePolicyModel(String schemaOwner, Model model) {
try { try {
String selectedSchemaOwner = schemaOwner == null ? "" : schemaOwner.trim().toUpperCase();
model.addAttribute("policies", vpdPolicyService.findPolicies()); model.addAttribute("policies", vpdPolicyService.findPolicies());
model.addAttribute("vpdTargets", vpdPolicyService.findVpdTargets(selectedSchemaOwner));
model.addAttribute("selectedSchemaOwner", selectedSchemaOwner);
model.addAttribute("formOptions", vpdPolicyService.formOptions());
} catch (DataAccessException exception) { } catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception); RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("runtimeError", message); model.addAttribute("runtimeError", message);
model.addAttribute("policies", List.of()); model.addAttribute("policies", List.of());
model.addAttribute("vpdTargets", List.of());
model.addAttribute("selectedSchemaOwner", "");
model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions());
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
model.addAttribute("policies", List.of());
model.addAttribute("vpdTargets", List.of());
model.addAttribute("selectedSchemaOwner", "");
model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions());
}
}
@PostMapping("/vpd-policies")
public String createPolicy(
@RequestParam String objectKey,
@RequestParam String policyName,
@RequestParam(required = false) String functionKey,
@RequestParam(required = false) String functionOwner,
@RequestParam(required = false) String functionName,
@RequestParam(defaultValue = "SELECT") List<String> statementTypes,
@RequestParam(defaultValue = "false") boolean enabled,
@RequestParam(defaultValue = "false") boolean updateCheck,
@RequestParam(required = false) String filterPredicate,
RedirectAttributes redirectAttributes
) {
createPolicyInternal(objectKey, policyName, functionKey, functionOwner, functionName, statementTypes, enabled,
updateCheck, filterPredicate, redirectAttributes);
return "redirect:/vpd-policies";
}
@PostMapping("/vpd-filter-policies/filters")
public String saveFilter(
@RequestParam(required = false) String functionOwner,
@RequestParam String functionName,
@RequestParam String filterPredicate,
RedirectAttributes redirectAttributes
) {
try {
vpdPolicyService.saveFilterFunction(functionOwner, functionName, filterPredicate);
redirectAttributes.addFlashAttribute("successMessage", "Filter function을 저장했습니다: " + functionName);
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
}
return "redirect:/vpd-filter-policies";
}
@PostMapping("/vpd-filter-policies/replace")
public String replaceFilterPolicy(
@RequestParam String oldObjectKey,
@RequestParam String oldPolicyName,
@RequestParam String objectKey,
@RequestParam String policyName,
@RequestParam(required = false) String functionKey,
@RequestParam(required = false) String functionOwner,
@RequestParam(required = false) String functionName,
@RequestParam(defaultValue = "SELECT") List<String> statementTypes,
@RequestParam(defaultValue = "false") boolean enabled,
@RequestParam(defaultValue = "false") boolean updateCheck,
@RequestParam(required = false) String filterPredicate,
RedirectAttributes redirectAttributes
) {
try {
String[] objectParts = objectKey.split("\\.", 2);
if (objectParts.length != 2) {
throw new AppException("조회 대상 형식이 올바르지 않습니다: " + objectKey);
}
vpdPolicyService.replacePolicy(oldObjectKey, oldPolicyName, new VpdPolicyCreateCommand(
objectParts[0],
objectParts[1],
policyName,
functionKey,
functionOwner,
functionName,
String.join(",", statementTypes),
enabled,
updateCheck,
filterPredicate
));
redirectAttributes.addFlashAttribute("successMessage", "Policy를 수정했습니다: " + policyName);
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
}
return "redirect:/vpd-filter-policies";
}
private void createPolicyInternal(
String objectKey,
String policyName,
String functionKey,
String functionOwner,
String functionName,
List<String> statementTypes,
boolean enabled,
boolean updateCheck,
String filterPredicate,
RedirectAttributes redirectAttributes
) {
try {
String[] objectParts = objectKey.split("\\.", 2);
if (objectParts.length != 2) {
throw new AppException("조회 대상 형식이 올바르지 않습니다: " + objectKey);
}
vpdPolicyService.createPolicy(new VpdPolicyCreateCommand(
objectParts[0],
objectParts[1],
policyName,
functionKey,
functionOwner,
functionName,
String.join(",", statementTypes),
enabled,
updateCheck,
filterPredicate
));
redirectAttributes.addFlashAttribute("successMessage", "VPD policy를 등록했습니다: " + policyName);
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
}
}
@PostMapping("/vpd-policies/bulk")
public String bulkApplyPolicy(
@RequestParam String schemaOwner,
@RequestParam(defaultValue = "false") boolean includeTables,
@RequestParam(defaultValue = "false") boolean includeViews,
@RequestParam(required = false) String functionKey,
@RequestParam(required = false) String functionOwner,
@RequestParam(required = false) String functionName,
@RequestParam(defaultValue = "SELECT") List<String> statementTypes,
@RequestParam(defaultValue = "false") boolean enabled,
@RequestParam(defaultValue = "false") boolean updateCheck,
@RequestParam(required = false) String filterPredicate,
RedirectAttributes redirectAttributes
) {
bulkApplyPolicyInternal(schemaOwner, includeTables, includeViews, functionKey, functionOwner, functionName,
statementTypes, enabled, updateCheck, filterPredicate, redirectAttributes);
return "redirect:/vpd-policies";
}
private void bulkApplyPolicyInternal(
String schemaOwner,
boolean includeTables,
boolean includeViews,
String functionKey,
String functionOwner,
String functionName,
List<String> statementTypes,
boolean enabled,
boolean updateCheck,
String filterPredicate,
RedirectAttributes redirectAttributes
) {
try {
var result = vpdPolicyService.bulkApplySchema(
schemaOwner,
includeTables,
includeViews,
functionKey,
functionOwner,
functionName,
String.join(",", statementTypes),
enabled,
updateCheck,
filterPredicate
);
redirectAttributes.addFlashAttribute("successMessage", result.summary());
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
} }
return "vpd-policies";
} }
@GetMapping("/vpd-policies/function-source") @GetMapping("/vpd-policies/function-source")
@@ -66,6 +269,23 @@ public class VpdPolicyController {
return "fragments/vpd-policy-detail :: detail"; return "fragments/vpd-policy-detail :: detail";
} }
@GetMapping("/vpd-policies/object-filter-detail")
public String objectFilterDetail(
@RequestParam String objectOwner,
@RequestParam String objectName,
Model model
) {
try {
model.addAttribute("detail", vpdPolicyService.findObjectFilterDetail(objectOwner, objectName));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("errorMessage", message.message());
}
return "fragments/vpd-object-filter-detail :: detail";
}
@GetMapping("/vpd-policies/policy-explanation") @GetMapping("/vpd-policies/policy-explanation")
public String policyExplanation( public String policyExplanation(
@RequestParam String objectOwner, @RequestParam String objectOwner,

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.GroupMapper">
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.group.AppGroup">
SELECT group_id,
group_code,
group_name,
description,
active_yn
FROM cb_app_group
ORDER BY group_code
</select>
<select id="findGroupUsers" resultType="com.cloudhandson.vpdbackoffice.domain.group.GroupUserView">
SELECT g.group_id,
g.group_code,
g.group_name,
u.user_id,
u.user_name AS username
FROM cb_user_group ug
JOIN cb_app_group g ON g.group_id = ug.group_id
JOIN cb_app_user u ON u.user_id = ug.user_id
ORDER BY g.group_code, u.user_name
</select>
<select id="findGroupRoles" resultType="com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView">
SELECT g.group_id,
g.group_code,
g.group_name,
r.role_id,
r.role_name
FROM cb_group_role gr
JOIN cb_app_group g ON g.group_id = gr.group_id
JOIN cb_app_role r ON r.role_id = gr.role_id
ORDER BY g.group_code, r.role_name
</select>
<select id="nextGroupId" resultType="long">
SELECT NVL(MAX(group_id), 0) + 1 FROM cb_app_group
</select>
<insert id="insertGroup">
INSERT INTO cb_app_group (
group_id, group_code, group_name, description, active_yn
) VALUES (
#{groupId,jdbcType=NUMERIC},
UPPER(#{command.groupCode,jdbcType=VARCHAR}),
#{command.groupName,jdbcType=VARCHAR},
#{command.description,jdbcType=VARCHAR},
'Y'
)
</insert>
<update id="updateActive">
UPDATE cb_app_group
SET active_yn = #{activeYn,jdbcType=VARCHAR}
WHERE group_id = #{groupId,jdbcType=NUMERIC}
</update>
<insert id="insertGroupUser">
INSERT INTO cb_user_group (group_id, user_id)
VALUES (#{groupId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC})
</insert>
<delete id="deleteGroupUser">
DELETE FROM cb_user_group
WHERE group_id = #{groupId,jdbcType=NUMERIC}
AND user_id = #{userId,jdbcType=NUMERIC}
</delete>
<insert id="insertGroupRole">
INSERT INTO cb_group_role (group_id, role_id)
VALUES (#{groupId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC})
</insert>
<delete id="deleteGroupRole">
DELETE FROM cb_group_role
WHERE group_id = #{groupId,jdbcType=NUMERIC}
AND role_id = #{roleId,jdbcType=NUMERIC}
</delete>
</mapper>

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.OperationStatusMapper">
<select id="findRows" resultType="com.cloudhandson.vpdbackoffice.domain.operation.OperationStatusRow">
WITH permission_counts AS (
SELECT p.target_name,
COUNT(DISTINCT p.perm_id) AS permission_count,
COUNT(r.rule_id) AS rule_count
FROM cb_permission p
LEFT JOIN cb_permission_rule r ON r.perm_id = p.perm_id
GROUP BY p.target_name
),
policy_status AS (
SELECT p.object_owner,
p.object_name,
LISTAGG(p.policy_name, ', ') WITHIN GROUP (ORDER BY p.policy_name) AS policy_names,
LISTAGG(p.enable, ', ') WITHIN GROUP (ORDER BY p.policy_name) AS policy_enabled,
LISTAGG(p.pf_owner || '.' || NVL2(p.package, p.package || '.', '') || p.function, ', ')
WITHIN GROUP (ORDER BY p.policy_name) AS function_name,
LISTAGG(NVL(o.status, 'UNKNOWN'), ', ') WITHIN GROUP (ORDER BY p.policy_name) AS function_status
FROM all_policies p
LEFT JOIN all_objects o
ON o.owner = p.pf_owner
AND o.object_name = NVL(p.package, p.function)
AND o.object_type IN ('FUNCTION', 'PACKAGE', 'PACKAGE BODY')
GROUP BY p.object_owner, p.object_name
),
ords_handlers AS (
SELECT h.id AS handler_id,
h.method AS handler_method,
h.source_type AS handler_source_type,
TRIM(BOTH '/' FROM s.pattern) || '/' ||
TRIM(BOTH '/' FROM m.uri_prefix) || '/' ||
TRIM(BOTH '/' FROM t.uri_template) AS handler_full_path
FROM dba_ords_schemas s
JOIN dba_ords_modules m ON m.schema_id = s.id
JOIN dba_ords_templates t ON t.module_id = m.id
JOIN dba_ords_handlers h ON h.template_id = t.id
WHERE s.parsing_schema = 'CB_ORDS'
),
latest_probe AS (
SELECT object_id,
status AS last_probe_status,
row_count AS last_probe_row_count,
error_code AS last_probe_error_code,
TO_CHAR(created_at, 'YYYY-MM-DD HH24:MI:SS') AS last_probe_at
FROM (
SELECT a.*,
ROW_NUMBER() OVER (PARTITION BY a.object_id ORDER BY a.created_at DESC, a.audit_id DESC) AS rn
FROM cb_ords_probe_audit a
WHERE a.object_id IS NOT NULL
)
WHERE rn = 1
)
SELECT po.object_id,
po.owner,
po.object_name,
po.ords_path,
po.enabled_yn,
NVL(pc.permission_count, 0) AS permission_count,
NVL(pc.rule_count, 0) AS rule_count,
ps.policy_names,
ps.policy_enabled,
ps.function_name,
ps.function_status,
oh.handler_id,
oh.handler_method,
oh.handler_source_type,
oh.handler_full_path,
lp.last_probe_status,
lp.last_probe_row_count,
lp.last_probe_error_code,
lp.last_probe_at
FROM cb_protected_object po
LEFT JOIN permission_counts pc ON pc.target_name = po.object_name
LEFT JOIN policy_status ps ON ps.object_owner = po.owner AND ps.object_name = po.object_name
LEFT JOIN ords_handlers oh ON oh.handler_full_path = TRIM(BOTH '/' FROM po.ords_path)
LEFT JOIN latest_probe lp ON lp.object_id = po.object_id
WHERE po.enabled_yn = 'Y'
ORDER BY po.owner, po.object_name
</select>
</mapper>

View File

@@ -3,13 +3,19 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd"> "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.PermissionMapper"> <mapper namespace="com.cloudhandson.vpdbackoffice.mapper.PermissionMapper">
<select id="findRoles" resultType="com.cloudhandson.vpdbackoffice.domain.permission.AppRole"> <select id="findRoles" resultType="com.cloudhandson.vpdbackoffice.domain.permission.AppRole">
SELECT role_id, role_name, CAST(NULL AS VARCHAR2(200)) AS description SELECT role_id,
role_name,
CAST(NULL AS VARCHAR2(200)) AS description,
NVL(max_sensitivity_level, 'PUBLIC') AS max_sensitivity_level
FROM cb_app_role FROM cb_app_role
ORDER BY role_name ORDER BY role_name
</select> </select>
<select id="findRole" resultType="com.cloudhandson.vpdbackoffice.domain.permission.AppRole"> <select id="findRole" resultType="com.cloudhandson.vpdbackoffice.domain.permission.AppRole">
SELECT role_id, role_name, CAST(NULL AS VARCHAR2(200)) AS description SELECT role_id,
role_name,
CAST(NULL AS VARCHAR2(200)) AS description,
NVL(max_sensitivity_level, 'PUBLIC') AS max_sensitivity_level
FROM cb_app_role FROM cb_app_role
WHERE role_id = #{roleId} WHERE role_id = #{roleId}
</select> </select>
@@ -19,10 +25,20 @@
</select> </select>
<insert id="insertRole"> <insert id="insertRole">
INSERT INTO cb_app_role (role_id, role_name) INSERT INTO cb_app_role (role_id, role_name, max_sensitivity_level)
VALUES (#{roleId,jdbcType=NUMERIC}, UPPER(#{roleName,jdbcType=VARCHAR})) VALUES (
#{roleId,jdbcType=NUMERIC},
UPPER(#{roleName,jdbcType=VARCHAR}),
#{maxSensitivityLevel,jdbcType=VARCHAR}
)
</insert> </insert>
<update id="updateRoleMaxSensitivity">
UPDATE cb_app_role
SET max_sensitivity_level = #{maxSensitivityLevel,jdbcType=VARCHAR}
WHERE role_id = #{roleId,jdbcType=NUMERIC}
</update>
<delete id="deleteRole"> <delete id="deleteRole">
DELETE FROM cb_app_role DELETE FROM cb_app_role
WHERE role_id = #{roleId,jdbcType=NUMERIC} WHERE role_id = #{roleId,jdbcType=NUMERIC}
@@ -35,6 +51,7 @@
o.object_id, o.object_id,
p.target_name AS object_name, p.target_name AS object_name,
p.action_name AS action, p.action_name AS action,
NVL(p.permission_effect, 'ALLOW') AS permission_effect,
LISTAGG( LISTAGG(
NVL2(pr.rule_column, pr.rule_column || ' ', '') || NVL2(pr.rule_column, pr.rule_column || ' ', '') ||
pr.rule_type || pr.rule_type ||
@@ -49,10 +66,12 @@
) AS visible_columns, ) AS visible_columns,
( (
SELECT LISTAGG( SELECT LISTAGG(
CASE CASE
WHEN pr2.rule_type = 'ALL' THEN 'ALL ROWS' WHEN pr2.rule_type = 'ALL' THEN 'ALL ROWS'
WHEN pr2.rule_type = 'MY_DEPT' THEN pr2.rule_column || ' = SYS_CONTEXT(CB_AGENT_CTX.DEPT_CODE)' WHEN pr2.rule_type = 'MY_DEPT' THEN NVL(pr2.rule_column, 'DEPT_CODE') || ' = SYS_CONTEXT(CB_AGENT_CTX.DEPT_CODE)'
WHEN pr2.rule_type = 'SELF' THEN pr2.rule_column || ' = SYS_CONTEXT(CB_AGENT_CTX.EMP_NO)' WHEN pr2.rule_type = 'SELF' THEN NVL(pr2.rule_column, 'OWNER_EMP_NO') || ' = SYS_CONTEXT(CB_AGENT_CTX.EMP_NO)'
WHEN pr2.rule_type = 'DEPT' THEN NVL(pr2.rule_column, 'DEPT_CODE') || ' = ' || pr2.rule_value
WHEN pr2.rule_type = 'EMP_NO' THEN NVL(pr2.rule_column, 'OWNER_EMP_NO') || ' = ' || pr2.rule_value
ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value
END, END,
CHR(10) || 'AND ' CHR(10) || 'AND '
@@ -72,7 +91,7 @@
JOIN cb_app_role r ON r.role_id = p.role_id JOIN cb_app_role r ON r.role_id = p.role_id
LEFT JOIN cb_protected_object o ON o.object_name = p.target_name LEFT JOIN cb_protected_object o ON o.object_name = p.target_name
LEFT JOIN cb_permission_rule pr ON pr.perm_id = p.perm_id LEFT JOIN cb_permission_rule pr ON pr.perm_id = p.perm_id
GROUP BY p.perm_id, r.role_id, r.role_name, o.object_id, p.target_name, p.action_name GROUP BY p.perm_id, r.role_id, r.role_name, o.object_id, p.target_name, p.action_name, p.permission_effect
ORDER BY r.role_name, p.target_name ORDER BY r.role_name, p.target_name
</select> </select>
@@ -102,7 +121,8 @@
SELECT p.perm_id AS permission_id, SELECT p.perm_id AS permission_id,
p.role_id, p.role_id,
o.object_id, o.object_id,
p.action_name AS action p.action_name AS action,
NVL(p.permission_effect, 'ALLOW') AS permission_effect
FROM cb_permission p FROM cb_permission p
JOIN cb_protected_object o ON o.object_name = p.target_name JOIN cb_protected_object o ON o.object_name = p.target_name
WHERE p.role_id = #{roleId} WHERE p.role_id = #{roleId}
@@ -118,8 +138,8 @@
</select> </select>
<insert id="insertPermission"> <insert id="insertPermission">
INSERT INTO cb_permission (perm_id, role_id, target_name, action_name) INSERT INTO cb_permission (perm_id, role_id, target_name, action_name, permission_effect)
SELECT #{permissionId}, #{roleId}, object_name, #{action} SELECT #{permissionId}, #{roleId}, object_name, #{action}, #{permissionEffect}
FROM cb_protected_object FROM cb_protected_object
WHERE object_id = #{objectId} WHERE object_id = #{objectId}
</insert> </insert>
@@ -130,6 +150,12 @@
WHERE perm_id = #{permissionId} WHERE perm_id = #{permissionId}
</update> </update>
<update id="updatePermissionEffect">
UPDATE cb_permission
SET permission_effect = #{permissionEffect}
WHERE perm_id = #{permissionId}
</update>
<delete id="deleteRules"> <delete id="deleteRules">
DELETE FROM cb_permission_rule DELETE FROM cb_permission_rule
WHERE perm_id = #{permissionId} WHERE perm_id = #{permissionId}

View File

@@ -57,7 +57,13 @@
</select> </select>
<select id="findColumns" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn"> <select id="findColumns" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn">
SELECT column_id, object_id, column_name, sensitive_yn, visible_role_id SELECT column_id,
object_id,
column_name,
sensitive_yn,
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM cb_protected_column FROM cb_protected_column
WHERE object_id = #{objectId} WHERE object_id = #{objectId}
ORDER BY column_id ORDER BY column_id
@@ -83,10 +89,26 @@
</insert> </insert>
<insert id="insertColumn"> <insert id="insertColumn">
INSERT INTO cb_protected_column (column_id, object_id, column_name, sensitive_yn) INSERT INTO cb_protected_column (
VALUES (#{columnId}, #{objectId}, UPPER(#{columnName}), #{sensitiveYn}) column_id, object_id, column_name, sensitive_yn, sensitivity_level, redaction_method
)
VALUES (
#{columnId}, #{objectId}, UPPER(#{columnName}), #{sensitiveYn}, #{sensitivityLevel}, #{redactionMethod}
)
</insert> </insert>
<update id="updateColumnPolicy">
UPDATE cb_protected_column
SET sensitivity_level = #{sensitivityLevel,jdbcType=VARCHAR},
redaction_method = #{redactionMethod,jdbcType=VARCHAR},
sensitive_yn = CASE
WHEN #{sensitivityLevel,jdbcType=VARCHAR} = 'PUBLIC'
AND #{redactionMethod,jdbcType=VARCHAR} = 'NONE' THEN 'N'
ELSE 'Y'
END
WHERE column_id = #{columnId,jdbcType=NUMERIC}
</update>
<update id="updateOrdsPath"> <update id="updateOrdsPath">
UPDATE cb_protected_object UPDATE cb_protected_object
SET ords_path = #{ordsPath,jdbcType=VARCHAR} SET ords_path = #{ordsPath,jdbcType=VARCHAR}

View File

@@ -38,6 +38,208 @@
ORDER BY p.object_owner, p.object_name, p.policy_name ORDER BY p.object_owner, p.object_name, p.policy_name
</select> </select>
<select id="findVpdTargets" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdTargetView">
WITH managed_owners AS (
SELECT USER AS owner FROM dual
UNION
SELECT owner FROM cb_protected_object
),
managed_objects AS (
SELECT owner, object_name, object_type
FROM all_objects
WHERE object_type IN ('TABLE', 'VIEW')
AND (
(#{owner,jdbcType=VARCHAR} IS NULL AND owner IN (SELECT owner FROM managed_owners))
OR owner = UPPER(#{owner,jdbcType=VARCHAR})
)
AND owner NOT IN ('SYS', 'SYSTEM', 'ORDS_METADATA', 'ORDS_PUBLIC_USER')
AND owner NOT LIKE 'APEX\_%' ESCAPE '\'
AND owner NOT LIKE 'C##%'
AND object_name NOT LIKE 'BIN$%'
)
SELECT o.owner,
o.object_name,
o.object_type,
CASE WHEN po.object_id IS NULL THEN 'N' ELSE po.enabled_yn END AS protected_yn,
po.ords_path,
COUNT(p.policy_name) AS policy_count,
LISTAGG(p.policy_name, ', ') WITHIN GROUP (ORDER BY p.policy_name) AS policy_names,
LISTAGG(
p.pf_owner ||
CASE WHEN p.package IS NULL THEN '.' ELSE '.' || p.package || '.' END ||
p.function,
', '
) WITHIN GROUP (ORDER BY p.policy_name) AS filter_names
FROM managed_objects o
LEFT JOIN cb_protected_object po
ON po.owner = o.owner
AND po.object_name = o.object_name
AND po.enabled_yn = 'Y'
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
ORDER BY CASE WHEN COUNT(p.policy_name) > 0 THEN 0 ELSE 1 END,
o.owner,
o.object_type,
o.object_name
FETCH FIRST 500 ROWS ONLY
</select>
<select id="findPolicyNameOptions" resultType="string">
SELECT DISTINCT policy_name
FROM all_policies
WHERE policy_name IS NOT NULL
AND EXISTS (
SELECT 1
FROM cb_protected_object o
WHERE o.enabled_yn = 'Y'
AND o.owner = all_policies.object_owner
AND o.object_name = all_policies.object_name
)
UNION
SELECT object_name || '_POLICY'
FROM cb_protected_object
WHERE enabled_yn = 'Y'
ORDER BY 1
</select>
<select id="findSchemaOwnerOptions" resultType="string">
SELECT DISTINCT o.owner
FROM all_objects o
JOIN all_users u ON u.username = o.owner
WHERE o.object_type IN ('TABLE', 'VIEW')
AND NVL(u.oracle_maintained, 'N') = 'N'
AND o.owner NOT IN ('SYS', 'SYSTEM', 'ORDS_METADATA', 'ORDS_PUBLIC_USER')
AND o.owner NOT LIKE 'APEX\_%' ESCAPE '\'
AND o.owner NOT LIKE 'C##%'
AND o.object_name NOT LIKE 'BIN$%'
ORDER BY o.owner
FETCH FIRST 200 ROWS ONLY
</select>
<select id="findOwnerOptions" resultType="string">
SELECT USER FROM dual
UNION
SELECT owner FROM cb_protected_object WHERE enabled_yn = 'Y'
UNION
SELECT pf_owner
FROM all_policies p
WHERE pf_owner IS NOT NULL
AND EXISTS (
SELECT 1
FROM cb_protected_object o
WHERE o.enabled_yn = 'Y'
AND o.owner = p.object_owner
AND o.object_name = p.object_name
)
ORDER BY 1
</select>
<select id="findFunctionOptions" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption">
WITH allowed_owners AS (
SELECT USER AS owner FROM dual
UNION
SELECT owner FROM cb_protected_object WHERE enabled_yn = 'Y'
UNION
SELECT pf_owner
FROM all_policies p
WHERE pf_owner IS NOT NULL
AND EXISTS (
SELECT 1
FROM cb_protected_object o
WHERE o.enabled_yn = 'Y'
AND o.owner = p.object_owner
AND o.object_name = p.object_name
)
),
policy_functions AS (
SELECT pf_owner AS owner,
package AS package_name,
function AS function_name
FROM all_policies p
WHERE EXISTS (
SELECT 1
FROM cb_protected_object o
WHERE o.enabled_yn = 'Y'
AND o.owner = p.object_owner
AND o.object_name = p.object_name
)
)
SELECT owner,
package_name,
object_name AS function_name,
CASE WHEN package_name IS NULL THEN 'FUNCTION' ELSE 'PACKAGE FUNCTION' END AS object_type
FROM all_arguments
WHERE owner IN (SELECT owner FROM allowed_owners)
AND position = 0
AND data_type = 'VARCHAR2'
AND object_name IS NOT NULL
AND (
REGEXP_LIKE(COALESCE(package_name, '') || '.' || object_name, '(VPD|FILTER|POLICY|PREDICATE|RLS)', 'i')
OR EXISTS (
SELECT 1
FROM policy_functions pf
WHERE pf.owner = all_arguments.owner
AND NVL(pf.package_name, '-') = NVL(all_arguments.package_name, '-')
AND pf.function_name = all_arguments.object_name
)
)
ORDER BY owner, package_name, function_name
FETCH FIRST 300 ROWS ONLY
</select>
<select id="findPolicyTemplateOptions" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyTemplateOption">
SELECT policy_name,
function_owner,
package_name,
function_name,
statement_types,
enabled,
check_option
FROM (
SELECT DISTINCT
p.policy_name,
p.pf_owner AS function_owner,
p.package AS package_name,
p.function AS function_name,
RTRIM(
CASE WHEN p.sel = 'YES' THEN 'SELECT,' ELSE '' END ||
CASE WHEN p.ins = 'YES' THEN 'INSERT,' ELSE '' END ||
CASE WHEN p.upd = 'YES' THEN 'UPDATE,' ELSE '' END ||
CASE WHEN p.del = 'YES' THEN 'DELETE,' ELSE '' END ||
CASE WHEN p.idx = 'YES' THEN 'INDEX,' ELSE '' END,
','
) AS statement_types,
p.enable AS enabled,
p.chk_option AS check_option
FROM all_policies p
WHERE p.policy_name IS NOT NULL
AND p.pf_owner IS NOT NULL
AND p.function IS NOT NULL
AND EXISTS (
SELECT 1
FROM cb_protected_object o
WHERE o.enabled_yn = 'Y'
AND o.owner = p.object_owner
AND o.object_name = p.object_name
)
)
ORDER BY policy_name, function_owner, package_name, function_name
</select>
<select id="findSchemaObjects" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption">
SELECT owner, object_name, object_type
FROM all_objects
WHERE owner = UPPER(#{owner,jdbcType=VARCHAR})
AND object_name NOT LIKE 'BIN$%'
AND (
(#{includeTablesYn,jdbcType=VARCHAR} = 'Y' AND object_type = 'TABLE')
OR (#{includeViewsYn,jdbcType=VARCHAR} = 'Y' AND object_type = 'VIEW')
)
ORDER BY object_type, object_name
</select>
<select id="findPolicy" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView"> <select id="findPolicy" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView">
SELECT <include refid="policyColumns"><property name="alias" value="p."/></include> SELECT <include refid="policyColumns"><property name="alias" value="p."/></include>
FROM all_policies p FROM all_policies p
@@ -53,6 +255,22 @@
) )
</select> </select>
<select id="findPoliciesForObject" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView">
SELECT <include refid="policyColumns"><property name="alias" value="p."/></include>
FROM all_policies p
WHERE p.object_owner = UPPER(#{objectOwner,jdbcType=VARCHAR})
AND p.object_name = UPPER(#{objectName,jdbcType=VARCHAR})
ORDER BY p.policy_name
</select>
<select id="findAnyPolicy" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView">
SELECT <include refid="policyColumns"><property name="alias" value="p."/></include>
FROM all_policies p
WHERE p.object_owner = UPPER(#{objectOwner,jdbcType=VARCHAR})
AND p.object_name = UPPER(#{objectName,jdbcType=VARCHAR})
AND p.policy_name = UPPER(#{policyName,jdbcType=VARCHAR})
</select>
<select id="findFunctionSource" resultType="string"> <select id="findFunctionSource" resultType="string">
SELECT LISTAGG(text, '') WITHIN GROUP (ORDER BY line) SELECT LISTAGG(text, '') WITHIN GROUP (ORDER BY line)
FROM all_source FROM all_source

View File

@@ -129,7 +129,8 @@ body {
} }
.rw-menu-trigger:hover, .rw-menu-trigger:hover,
.rw-menu-trigger:focus { .rw-menu-trigger:focus,
.rw-menu-group.open .rw-menu-trigger {
background: var(--rw-surface-muted); background: var(--rw-surface-muted);
border-color: var(--rw-border); border-color: var(--rw-border);
color: var(--rw-text); color: var(--rw-text);
@@ -149,8 +150,7 @@ body {
z-index: 30; z-index: 30;
} }
.rw-menu-group:hover .rw-menu-panel, .rw-menu-group.open .rw-menu-panel {
.rw-menu-group:focus-within .rw-menu-panel {
display: grid; display: grid;
gap: .15rem; gap: .15rem;
} }
@@ -181,9 +181,17 @@ body {
position: static; position: static;
} }
.rw-menu-group:focus-within .rw-menu-panel { .rw-menu-group.open .rw-menu-panel {
display: grid; display: grid;
} }
.architecture-strip {
grid-template-columns: 1fr;
}
.architecture-arrow {
display: none;
}
} }
.page-title { .page-title {
@@ -201,6 +209,55 @@ body {
margin: .35rem 0 0; margin: .35rem 0 0;
} }
.architecture-strip {
align-items: stretch;
display: grid;
gap: .5rem;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
margin-bottom: 1rem;
min-width: 0;
}
.architecture-step {
background: var(--rw-surface);
border: 1px solid var(--rw-border);
border-radius: 8px;
min-width: 0;
padding: .85rem;
}
.architecture-step.active {
border-color: var(--rw-primary);
box-shadow: inset 0 0 0 1px var(--rw-primary);
}
.architecture-kicker {
color: var(--rw-muted);
display: block;
font-size: .72rem;
font-weight: 800;
text-transform: uppercase;
}
.architecture-step strong {
display: block;
font-size: .95rem;
margin-top: .15rem;
}
.architecture-step p {
color: var(--rw-muted);
font-size: .82rem;
margin: .25rem 0 0;
overflow-wrap: anywhere;
}
.architecture-arrow {
align-self: center;
color: var(--rw-muted);
font-weight: 800;
}
.rw-card, .rw-card,
.content-band, .content-band,
.summary-tile { .summary-tile {
@@ -244,6 +301,7 @@ body {
.content-band { .content-band {
margin-bottom: 1rem; margin-bottom: 1rem;
min-width: 0;
padding: 1rem; padding: 1rem;
} }
@@ -251,21 +309,30 @@ body {
.section-heading h2 { .section-heading h2 {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
margin: 0 0 1rem; margin: 0;
} }
.section-heading { .section-heading {
align-items: center; align-items: center;
display: flex; display: flex;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem;
}
.section-subtitle {
color: var(--rw-muted);
font-size: .875rem;
margin: .2rem 0 0;
} }
.form-grid { .form-grid {
align-items: end; align-items: end;
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
gap: 1rem; gap: 1rem;
min-width: 0;
} }
.form-grid label { .form-grid label {
@@ -333,7 +400,154 @@ body {
.rule-row { .rule-row {
display: grid; display: grid;
gap: .5rem; gap: .5rem;
grid-template-columns: 1fr 1fr 1fr auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto;
min-width: 0;
}
.permission-wizard {
display: grid;
gap: 1rem;
min-width: 0;
}
.wizard-progress {
display: flex;
flex-wrap: wrap;
gap: .4rem;
}
.wizard-step-indicator {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 999px;
color: var(--rw-muted);
font-size: .82rem;
font-weight: 700;
min-height: 2rem;
padding: .3rem .7rem;
}
.wizard-step-indicator.active {
background: var(--rw-primary);
border-color: var(--rw-primary);
color: #fff;
}
.wizard-summary {
display: grid;
gap: .75rem;
grid-template-columns: repeat(auto-fit, minmax(min(160px, 100%), 1fr));
min-width: 0;
}
.wizard-summary > div,
.policy-preview dl > div {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 8px;
padding: .75rem;
}
.wizard-summary span,
.policy-preview dt {
color: var(--rw-muted);
display: block;
font-size: .78rem;
font-weight: 700;
}
.wizard-summary strong,
.policy-preview dd {
display: block;
margin: .25rem 0 0;
overflow-wrap: anywhere;
}
.wizard-panel {
border: 1px solid var(--rw-border);
border-radius: 8px;
display: none;
min-width: 0;
padding: 1rem;
}
.wizard-panel.active {
display: grid;
gap: 1rem;
}
.wizard-panel-heading {
align-items: flex-start;
display: flex;
gap: .75rem;
}
.wizard-panel-heading h3 {
font-size: 1rem;
font-weight: 700;
margin: 0;
}
.wizard-panel-heading p,
.wizard-hint {
color: var(--rw-muted);
margin: .15rem 0 0;
}
.wizard-step-number {
align-items: center;
background: var(--rw-primary-soft);
border-radius: 999px;
color: var(--rw-primary);
display: inline-flex;
font-weight: 800;
height: 2rem;
justify-content: center;
min-width: 2rem;
}
.wizard-actions {
align-items: center;
display: flex;
gap: .5rem;
justify-content: flex-end;
}
.policy-preview dl {
display: grid;
gap: .75rem;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
margin: 0;
}
.bulk-apply-summary {
cursor: pointer;
font-weight: 700;
min-height: 2rem;
}
.question-presets {
display: flex;
flex-wrap: wrap;
gap: .5rem;
}
.question-preset {
max-width: 100%;
white-space: normal;
text-align: left;
}
.checkbox-row {
display: flex;
flex-wrap: wrap;
gap: .75rem 1rem;
min-height: 2.45rem;
align-items: center;
}
.checkbox-row .form-check {
margin: 0;
} }
.rw-btn, .rw-btn,
@@ -650,11 +864,35 @@ body {
text-align: left; text-align: left;
} }
.policy-function-button.active,
.policy-function-button[aria-expanded="true"] {
background: var(--rw-accent-soft);
border-color: var(--rw-accent);
color: var(--rw-accent-strong);
}
.policy-source-cell { .policy-source-cell {
background: var(--rw-surface) !important; background: var(--rw-surface) !important;
padding: 0 0 .75rem !important; padding: 0 0 .75rem !important;
} }
.filter-edit-grid {
display: grid;
gap: 1rem;
}
.filter-edit-form {
display: grid;
gap: .75rem;
padding: .75rem;
}
.filter-detail-stack + .filter-detail-stack {
border-top: 1px solid var(--rw-border);
margin-top: 1rem;
padding-top: 1rem;
}
.policy-detail-list { .policy-detail-list {
display: grid; display: grid;
grid-template-columns: minmax(120px, 34%) 1fr; grid-template-columns: minmax(120px, 34%) 1fr;
@@ -814,18 +1052,119 @@ body {
font-weight: 800; font-weight: 800;
} }
.table-responsive {
max-width: 100%;
min-width: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-responsive > .table {
margin-bottom: 0;
}
.table th,
.table td {
min-width: 0;
vertical-align: top;
}
.table-responsive > .table th,
.table-responsive > .table td {
white-space: nowrap;
}
.table code,
.table-responsive > .table code {
overflow-wrap: normal;
white-space: nowrap;
word-break: normal;
}
.table-pre {
max-height: 8rem;
overflow: auto;
white-space: pre-wrap;
}
@media (max-width: 920px) {
.architecture-strip {
grid-template-columns: 1fr;
}
.architecture-arrow {
display: none;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
main.container {
max-width: 100%;
min-width: 0;
padding-left: .75rem;
padding-right: .75rem;
}
.page-title {
margin-bottom: 1rem;
}
.page-title h1 {
font-size: 1.45rem;
}
.content-band {
padding: .85rem;
}
.section-heading {
align-items: flex-start;
gap: .5rem;
}
.form-grid .span-2 { .form-grid .span-2 {
grid-column: span 1; grid-column: span 1;
} }
.rule-row {
grid-template-columns: 1fr;
}
.rule-row .btn {
width: 100%;
}
.wizard-panel {
padding: .85rem;
}
.wizard-actions {
flex-wrap: wrap;
}
.wizard-actions .btn {
flex: 1 1 auto;
}
.table-responsive > .table {
min-width: 960px;
}
.navbar .container { .navbar .container {
align-items: flex-start; align-items: flex-start;
gap: .75rem; gap: .75rem;
} }
.navbar-nav { .rw-menu {
flex-wrap: wrap; width: 100%;
gap: .25rem; }
.rw-menu-group {
min-width: 0;
}
.rw-menu-trigger {
padding-left: .65rem;
padding-right: .65rem;
} }
} }

View File

@@ -5,6 +5,102 @@ document.body.addEventListener('htmx:responseError', (event) => {
} }
}); });
function resetFilterToggle(button, target) {
if (!button || !target) {
return;
}
button.setAttribute('aria-expanded', 'false');
button.classList.remove('active');
target.innerHTML = button.dataset.toggleDefaultText || 'Filter 이름을 클릭하면 policy/filter source가 표시됩니다.';
}
function setFilterToggleLoading(button, target) {
if (!button || !target) {
return;
}
button.setAttribute('aria-expanded', 'true');
button.classList.add('active');
target.innerHTML = button.dataset.toggleLoadingText || 'policy/filter source를 조회하는 중입니다.';
}
function findHtmxTarget(button) {
const selector = button?.getAttribute('hx-target') || button?.getAttribute('data-hx-target');
return selector ? document.querySelector(selector) : null;
}
function syncPolicyTemplate(select) {
const form = select?.closest('form');
if (!form) {
return;
}
const option = select.options[select.selectedIndex];
const policyName = form.querySelector('[data-policy-template-field="policyName"]');
const functionKey = form.querySelector('[data-policy-template-field="functionKey"]');
const enabled = form.querySelector('[data-policy-template-field="enabled"]');
const updateCheck = form.querySelector('[data-policy-template-field="updateCheck"]');
if (policyName) {
policyName.value = option?.dataset.policyName || '';
}
if (functionKey) {
functionKey.value = option?.dataset.functionKey || '';
}
if (enabled) {
enabled.checked = option?.dataset.enabled === 'true';
}
if (updateCheck) {
updateCheck.checked = option?.dataset.updateCheck === 'true';
}
const selectedStatements = (option?.dataset.statementTypes || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
form.querySelectorAll('[data-policy-template-statement]').forEach((checkbox) => {
checkbox.checked = selectedStatements.includes(checkbox.value);
});
}
function closeMenuGroup(group) {
if (!group) {
return;
}
group.classList.remove('open');
group.querySelector('.rw-menu-trigger')?.setAttribute('aria-expanded', 'false');
}
function closeAllMenuGroups(exceptGroup = null) {
document.querySelectorAll('.rw-menu-group.open').forEach((group) => {
if (group !== exceptGroup) {
closeMenuGroup(group);
}
});
}
function initPersistentMenus() {
document.querySelectorAll('.rw-menu-trigger').forEach((button) => {
button.addEventListener('click', (event) => {
event.stopPropagation();
const group = button.closest('.rw-menu-group');
if (!group) {
return;
}
const nextOpen = !group.classList.contains('open');
closeAllMenuGroups(group);
group.classList.toggle('open', nextOpen);
button.setAttribute('aria-expanded', String(nextOpen));
});
});
document.addEventListener('click', (event) => {
if (!event.target.closest('.rw-menu')) {
closeAllMenuGroups();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeAllMenuGroups();
}
});
}
function escapeHtml(value) { function escapeHtml(value) {
return value return value
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')
@@ -147,10 +243,33 @@ function filterUserRoleDetail() {
empty.hidden = shown !== 0; empty.hidden = shown !== 0;
} }
function filterGroupDetail(masterId) {
const master = document.getElementById(masterId);
const table = document.querySelector(`[data-group-detail-table="${masterId}"]`);
if (!master || !table) {
return;
}
const selected = master.value;
let shown = 0;
table.querySelectorAll('tbody tr[data-group-id]').forEach((row) => {
const visible = row.dataset.groupId === selected;
row.hidden = !visible;
shown += visible ? 1 : 0;
});
let empty = table.querySelector('tbody tr.empty-group-detail-runtime');
if (!empty) {
empty = document.createElement('tr');
empty.className = 'empty-group-detail-runtime';
empty.innerHTML = `<td colspan="3" class="text-muted">${escapeHtml(table.dataset.emptyMessage || '선택한 그룹에 등록된 항목이 없습니다.')}</td>`;
table.querySelector('tbody').appendChild(empty);
}
empty.hidden = shown !== 0;
}
function renderRuleColumnOptions(columns) { function renderRuleColumnOptions(columns) {
document.querySelectorAll('.rule-column-select').forEach((select) => { document.querySelectorAll('.rule-column-select').forEach((select) => {
const current = select.value; const current = select.value;
select.innerHTML = '<option value="">전체 행</option>' + columns select.innerHTML = '<option value="">기본 컬럼</option>' + columns
.map((column) => `<option value="${column}">${column}</option>`) .map((column) => `<option value="${column}">${column}</option>`)
.join(''); .join('');
if (columns.includes(current)) { if (columns.includes(current)) {
@@ -159,6 +278,70 @@ function renderRuleColumnOptions(columns) {
}); });
} }
function renderMaskableColumnOptions(option) {
const list = document.querySelector('[data-maskable-column-list]');
if (!list) {
return;
}
const columns = (option?.dataset.maskableColumns || '').split(',').filter(Boolean);
const labels = (option?.dataset.maskableLabels || '').split('|').filter(Boolean);
if (!columns.length) {
list.innerHTML = '<span class="text-muted small">선택한 객체에 권한별로 허용할 마스킹 컬럼이 없습니다.</span>';
return;
}
list.innerHTML = columns.map((column, index) => (
`<button class="btn btn-sm btn-outline-secondary question-preset" type="button" data-mask-column="${column}">`
+ `${escapeHtml(labels[index] || column)}</button>`
)).join('');
list.querySelectorAll('[data-mask-column]').forEach((button) => {
button.addEventListener('click', () => {
const input = document.querySelector('input[name="visibleColumns"]');
if (!input) {
return;
}
const current = input.value.split(',').map((value) => value.trim()).filter(Boolean);
const column = button.dataset.maskColumn;
if (!current.includes(column)) {
current.push(column);
}
input.value = current.join(', ');
updatePermissionWizardPreview(document);
});
});
}
function syncRuleTypeHints(root = document) {
root.querySelectorAll('.rule-row').forEach((row) => {
const typeSelect = row.querySelector('.rule-type-select');
const columnSelect = row.querySelector('.rule-column-select');
const valueInput = row.querySelector('input[name="ruleValue"]');
if (!typeSelect || !columnSelect || !valueInput) {
return;
}
const type = typeSelect.value;
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO'].includes(type);
const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO'].includes(type);
const placeholderByType = {
ALL: '값 불필요',
MY_DEPT: '값 불필요',
SELF: '값 불필요',
DEPT: '예: HR',
EMP_NO: '예: E2001',
'=': '비교 값',
'!=': '비교 값'
};
columnSelect.required = !columnOptional;
valueInput.required = valueRequired;
valueInput.disabled = ['ALL', 'MY_DEPT', 'SELF'].includes(type);
valueInput.placeholder = placeholderByType[type] || '비교 값';
if (valueInput.disabled) {
valueInput.value = '';
}
});
}
async function syncRuleColumnOptions() { async function syncRuleColumnOptions() {
const objectSelect = document.querySelector('select[name="objectRef"]'); const objectSelect = document.querySelector('select[name="objectRef"]');
if (!objectSelect) { if (!objectSelect) {
@@ -166,6 +349,7 @@ async function syncRuleColumnOptions() {
} }
const selectedOption = objectSelect.options[objectSelect.selectedIndex]; const selectedOption = objectSelect.options[objectSelect.selectedIndex];
const fallbackColumns = (selectedOption?.dataset.columns || '').split(',').filter(Boolean); const fallbackColumns = (selectedOption?.dataset.columns || '').split(',').filter(Boolean);
renderMaskableColumnOptions(selectedOption);
renderRuleColumnOptions(fallbackColumns); renderRuleColumnOptions(fallbackColumns);
try { try {
const response = await fetch(`/permissions/object-columns?objectRef=${encodeURIComponent(objectSelect.value)}`); const response = await fetch(`/permissions/object-columns?objectRef=${encodeURIComponent(objectSelect.value)}`);
@@ -201,22 +385,149 @@ async function syncObjectCatalogSelection() {
} }
} }
function selectedText(select) {
if (!select || select.selectedIndex < 0) {
return '-';
}
return select.options[select.selectedIndex].textContent.trim();
}
function collectWizardRules(root) {
return Array.from(root.querySelectorAll('.rule-row')).map((row) => {
const column = row.querySelector('[name="ruleColumn"]')?.value || '';
const type = row.querySelector('[name="ruleType"]')?.value || '';
const value = row.querySelector('[name="ruleValue"]')?.value || '';
const displayColumn = column || {
MY_DEPT: 'DEPT_CODE',
SELF: 'OWNER_EMP_NO',
DEPT: 'DEPT_CODE',
EMP_NO: 'OWNER_EMP_NO',
ALL: ''
}[type] || '';
if (type === 'ALL') {
return 'ALL';
}
if (['MY_DEPT', 'SELF'].includes(type)) {
return `${displayColumn} ${type}`;
}
return [displayColumn, type, value].filter(Boolean).join(' ');
}).filter(Boolean);
}
function updatePermissionWizardPreview(root = document) {
const wizard = root.querySelector('[data-permission-wizard]');
if (!wizard) {
return;
}
const roleSelect = wizard.querySelector('[name="roleId"]');
const objectSelect = wizard.querySelector('[name="objectRef"]');
const effectSelect = wizard.querySelector('[name="permissionEffect"]');
const visibleColumns = wizard.querySelector('[name="visibleColumns"]')?.value.trim() || '';
const roleOption = roleSelect?.options[roleSelect.selectedIndex];
const effect = effectSelect?.value || 'ALLOW';
const rules = collectWizardRules(wizard);
const ruleText = rules.length ? rules.join(', ') : '행 규칙 없음';
const rowPolicy = effect === 'DENY'
? `거부 규칙: ${ruleText}`
: `허용 규칙: ${ruleText}`;
const columnPolicy = visibleColumns
? `이 권한에서 원문 표시 허용: ${visibleColumns}`
: '마스킹 대상 컬럼은 기본 정책대로 NULL/마스킹 처리';
wizard.querySelector('[data-wizard-summary="role"]').textContent = selectedText(roleSelect);
wizard.querySelector('[data-wizard-summary="object"]').textContent = selectedText(objectSelect);
wizard.querySelector('[data-wizard-summary="effect"]').textContent = effect;
wizard.querySelector('[data-wizard-summary="rules"]').textContent = ruleText;
wizard.querySelector('[data-preview="role"]').textContent = selectedText(roleSelect);
wizard.querySelector('[data-preview="sensitivity"]').textContent = roleOption?.dataset.maxSensitivity || 'PUBLIC';
wizard.querySelector('[data-preview="object"]').textContent = selectedText(objectSelect);
wizard.querySelector('[data-preview="rowPolicy"]').textContent = rowPolicy;
wizard.querySelector('[data-preview="columnPolicy"]').textContent = columnPolicy;
}
function activatePermissionWizardStep(wizard, step) {
const panels = Array.from(wizard.querySelectorAll('[data-wizard-step]'));
const maxStep = panels.length;
const nextStep = Math.max(1, Math.min(step, maxStep));
panels.forEach((panel) => {
panel.classList.toggle('active', Number(panel.dataset.wizardStep) === nextStep);
});
document.querySelectorAll('[data-wizard-target]').forEach((button) => {
button.classList.toggle('active', Number(button.dataset.wizardTarget) === nextStep);
});
wizard.dataset.currentStep = String(nextStep);
const prev = wizard.querySelector('[data-wizard-prev]');
const next = wizard.querySelector('[data-wizard-next]');
const submit = wizard.querySelector('[data-wizard-submit]');
if (prev) {
prev.disabled = nextStep === 1;
}
if (next) {
next.hidden = nextStep === maxStep;
}
if (submit) {
submit.hidden = nextStep !== maxStep;
}
updatePermissionWizardPreview(document);
}
function initPermissionWizard() {
const wizard = document.querySelector('[data-permission-wizard]');
if (!wizard) {
return;
}
wizard.dataset.currentStep = wizard.dataset.currentStep || '1';
wizard.querySelector('[data-wizard-prev]')?.addEventListener('click', () => {
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') - 1);
});
wizard.querySelector('[data-wizard-next]')?.addEventListener('click', () => {
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') + 1);
});
document.querySelectorAll('[data-wizard-target]').forEach((button) => {
button.addEventListener('click', () => activatePermissionWizardStep(wizard, Number(button.dataset.wizardTarget)));
});
wizard.addEventListener('input', () => updatePermissionWizardPreview(document));
wizard.addEventListener('change', () => updatePermissionWizardPreview(document));
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1'));
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initPersistentMenus();
renderMarkdownViews(); renderMarkdownViews();
const master = document.getElementById('userRoleMaster'); const master = document.getElementById('userRoleMaster');
if (master) { if (master) {
master.addEventListener('change', filterUserRoleDetail); master.addEventListener('change', filterUserRoleDetail);
filterUserRoleDetail(); filterUserRoleDetail();
} }
document.querySelectorAll('.group-master-select').forEach((select) => {
select.addEventListener('change', () => filterGroupDetail(select.id));
filterGroupDetail(select.id);
});
const objectSelect = document.querySelector('select[name="objectRef"]'); const objectSelect = document.querySelector('select[name="objectRef"]');
if (objectSelect) { if (objectSelect) {
objectSelect.addEventListener('change', syncRuleColumnOptions); objectSelect.addEventListener('change', () => {
syncRuleColumnOptions();
updatePermissionWizardPreview(document);
});
syncRuleColumnOptions(); syncRuleColumnOptions();
} }
initPermissionWizard();
document.querySelectorAll('.rule-type-select').forEach((select) => {
select.addEventListener('change', () => {
syncRuleTypeHints();
updatePermissionWizardPreview(document);
});
});
syncRuleTypeHints();
const catalog = document.getElementById('objectCatalogSelect'); const catalog = document.getElementById('objectCatalogSelect');
if (catalog) { if (catalog) {
catalog.addEventListener('change', syncObjectCatalogSelection); catalog.addEventListener('change', syncObjectCatalogSelection);
} }
document.querySelectorAll('[data-policy-template-select]').forEach((select) => {
select.addEventListener('change', () => syncPolicyTemplate(select));
syncPolicyTemplate(select);
});
document.querySelectorAll('[data-rule-add]').forEach((button) => { document.querySelectorAll('[data-rule-add]').forEach((button) => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
const row = button.closest('.rule-row'); const row = button.closest('.rule-row');
@@ -228,13 +539,44 @@ document.addEventListener('DOMContentLoaded', () => {
clone.querySelectorAll('input').forEach((input) => input.value = ''); clone.querySelectorAll('input').forEach((input) => input.value = '');
const cloneButton = clone.querySelector('[data-rule-add]'); const cloneButton = clone.querySelector('[data-rule-add]');
cloneButton.textContent = '삭제'; cloneButton.textContent = '삭제';
cloneButton.addEventListener('click', () => clone.remove()); cloneButton.addEventListener('click', () => {
clone.remove();
updatePermissionWizardPreview(document);
});
clone.querySelectorAll('.rule-type-select').forEach((select) => {
select.addEventListener('change', () => {
syncRuleTypeHints();
updatePermissionWizardPreview(document);
});
});
list.appendChild(clone); list.appendChild(clone);
syncRuleColumnOptions(); syncRuleColumnOptions();
syncRuleTypeHints(clone);
updatePermissionWizardPreview(document);
}); });
}); });
updatePermissionWizardPreview(document);
});
document.body.addEventListener('htmx:beforeRequest', (event) => {
const button = event.detail.elt;
if (!button?.matches?.('[data-filter-toggle]')) {
return;
}
const target = findHtmxTarget(button);
if (button.getAttribute('aria-expanded') === 'true') {
event.preventDefault();
resetFilterToggle(button, target);
return;
}
setFilterToggleLoading(button, target);
}); });
document.body.addEventListener('htmx:afterSwap', (event) => { document.body.addEventListener('htmx:afterSwap', (event) => {
const button = event.detail.elt;
if (button?.matches?.('[data-filter-toggle]')) {
button.setAttribute('aria-expanded', 'true');
button.classList.add('active');
}
renderMarkdownViews(event.detail.target || document); renderMarkdownViews(event.detail.target || document);
}); });

View File

@@ -6,9 +6,11 @@
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>VPD 권한 백오피스</h1> <h1>VPD 권한 백오피스</h1>
<p>보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다.</p> <p>Oracle Database의 VPD/ORDS 기능을 권한 테이블과 운영 UI로 제어하고 검증합니다.</p>
</div> </div>
<section th:replace="~{fragments/layout :: architectureStrip('')}"></section>
<div class="alert alert-warning" th:if="${runtimeErrorMessage}"> <div class="alert alert-warning" th:if="${runtimeErrorMessage}">
<div class="fw-semibold" th:text="${runtimeErrorTitle}">데이터 처리 오류가 발생했습니다.</div> <div class="fw-semibold" th:text="${runtimeErrorTitle}">데이터 처리 오류가 발생했습니다.</div>
<div th:text="${runtimeErrorMessage}"></div> <div th:text="${runtimeErrorMessage}"></div>

View File

@@ -16,16 +16,17 @@
<a class="navbar-brand" href="/">VPD Backoffice</a> <a class="navbar-brand" href="/">VPD Backoffice</a>
<div class="rw-menu"> <div class="rw-menu">
<div class="rw-menu-group"> <div class="rw-menu-group">
<button class="rw-menu-trigger" type="button">권한 관리</button> <button class="rw-menu-trigger" type="button" aria-expanded="false">권한 관리</button>
<div class="rw-menu-panel"> <div class="rw-menu-panel">
<a class="nav-link" href="/users">사용자</a> <a class="nav-link" href="/users">사용자</a>
<a class="nav-link" href="/groups">그룹</a>
<a class="nav-link" href="/roles">역할</a> <a class="nav-link" href="/roles">역할</a>
<a class="nav-link" href="/permissions">권한</a> <a class="nav-link" href="/permissions">권한</a>
<a class="nav-link" href="/tokens">토큰</a> <a class="nav-link" href="/tokens">토큰</a>
</div> </div>
</div> </div>
<div class="rw-menu-group"> <div class="rw-menu-group">
<button class="rw-menu-trigger" type="button">ORDS</button> <button class="rw-menu-trigger" type="button" aria-expanded="false">ORDS 서빙</button>
<div class="rw-menu-panel"> <div class="rw-menu-panel">
<a class="nav-link" href="/objects">조회 Handler 생성</a> <a class="nav-link" href="/objects">조회 Handler 생성</a>
<a class="nav-link" href="/probe">ORDS 검증</a> <a class="nav-link" href="/probe">ORDS 검증</a>
@@ -33,7 +34,7 @@
</div> </div>
</div> </div>
<div class="rw-menu-group"> <div class="rw-menu-group">
<button class="rw-menu-trigger" type="button">MCP</button> <button class="rw-menu-trigger" type="button" aria-expanded="false">MCP</button>
<div class="rw-menu-panel"> <div class="rw-menu-panel">
<a class="nav-link" href="/mcp-chatbot">Chatbot</a> <a class="nav-link" href="/mcp-chatbot">Chatbot</a>
<a class="nav-link" href="/mcp-reasoning">Reasoning</a> <a class="nav-link" href="/mcp-reasoning">Reasoning</a>
@@ -42,9 +43,11 @@
</div> </div>
</div> </div>
<div class="rw-menu-group"> <div class="rw-menu-group">
<button class="rw-menu-trigger" type="button">운영</button> <button class="rw-menu-trigger" type="button" aria-expanded="false">운영</button>
<div class="rw-menu-panel"> <div class="rw-menu-panel">
<a class="nav-link" href="/operation-status">운영 상태</a>
<a class="nav-link" href="/vpd-policies">VPD 설정</a> <a class="nav-link" href="/vpd-policies">VPD 설정</a>
<a class="nav-link" href="/vpd-filter-policies">Filter Policy 관리</a>
<a class="nav-link" href="/settings">설정</a> <a class="nav-link" href="/settings">설정</a>
</div> </div>
</div> </div>
@@ -55,5 +58,25 @@
</form> </form>
</div> </div>
</nav> </nav>
<section th:fragment="architectureStrip(activeLayer)" class="architecture-strip" aria-label="VPD Backoffice architecture">
<div class="architecture-step" th:classappend="${activeLayer == 'permission'} ? ' active'">
<span class="architecture-kicker">Backoffice Tables</span>
<strong>권한 테이블</strong>
<p>사용자, 그룹, 역할, 행 규칙, 컬럼 원문 허용을 저장합니다.</p>
</div>
<div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'vpd'} ? ' active'">
<span class="architecture-kicker">Oracle Database</span>
<strong>VPD Policy</strong>
<p>TABLE/VIEW에 policy function을 붙여 DB에서 행 접근을 제한합니다.</p>
</div>
<div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'ords'} ? ' active'">
<span class="architecture-kicker">Oracle REST Data Services</span>
<strong>ORDS 서빙/검증</strong>
<p>VPD가 적용된 TABLE/VIEW를 HTTP API로 호출해 결과를 확인합니다.</p>
</div>
</section>
</body> </body>
</html> </html>

View File

@@ -13,11 +13,14 @@
<span>Tool: <code th:text="${result.toolName()}">tool</code></span> <span>Tool: <code th:text="${result.toolName()}">tool</code></span>
<span>ORDS Status: <strong th:text="${result.probeResult().status()}">SUCCESS</strong></span> <span>ORDS Status: <strong th:text="${result.probeResult().status()}">SUCCESS</strong></span>
<span>Rows: <strong th:text="${result.probeResult().rowCount()}">0</strong></span> <span>Rows: <strong th:text="${result.probeResult().rowCount()}">0</strong></span>
<span th:if="${!#lists.isEmpty(result.probeResult().maskedColumns())}">
Masked: <code th:text="${#strings.listJoin(result.probeResult().maskedColumns(), ', ')}"></code>
</span>
</div> </div>
<section class="ai-answer"> <section class="ai-answer">
<h3>Answer</h3> <h3>요약 및 판단</h3>
<pre th:text="${result.answer()}">answer</pre> <div class="markdown-view" data-markdown-view th:text="${result.answer()}">answer</div>
</section> </section>
<div class="probe-exchange-grid"> <div class="probe-exchange-grid">

View File

@@ -0,0 +1,31 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="detail">
<div class="alert alert-warning mb-0" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<section class="probe-exchange" th:if="${detail}">
<h3>
<span th:text="${detail.objectDisplayName()}">ADMIN.TABLE</span>
<span class="text-muted"> / Policy Filter</span>
</h3>
<div th:if="${#lists.isEmpty(detail.rows())}" class="text-muted">
이 TABLE/VIEW에 적용된 VPD policy/filter가 없습니다.
</div>
<div class="filter-detail-stack" th:each="row : ${detail.rows()}">
<dl class="policy-detail-list">
<dt>Policy</dt>
<dd><code th:text="${row.policy().policyName()}">POLICY</code></dd>
<dt>Filter</dt>
<dd><code th:text="${row.policy().functionDisplayName()}">ADMIN.FILTER</code></dd>
<dt>Statements</dt>
<dd th:text="${row.policy().statementTypes()} ?: '-'">SELECT</dd>
<dt>Status</dt>
<dd th:text="${row.policy().enabled()}">YES</dd>
</dl>
<pre th:if="${row.source().found()}" th:text="${row.source().source()}"></pre>
<pre th:unless="${row.source().found()}">ALL_SOURCE에서 조회 가능한 filter source가 없습니다.</pre>
</div>
</section>
</div>
</body>
</html>

View File

@@ -13,7 +13,7 @@
<dd th:text="${detail.policy().policyGroup()}">SYS_DEFAULT</dd> <dd th:text="${detail.policy().policyGroup()}">SYS_DEFAULT</dd>
<dt>Policy Name</dt> <dt>Policy Name</dt>
<dd><code th:text="${detail.policy().policyName()}">POLICY</code></dd> <dd><code th:text="${detail.policy().policyName()}">POLICY</code></dd>
<dt>Function</dt> <dt>Filter Function</dt>
<dd><code th:text="${detail.policy().functionDisplayName()}">ADMIN.FUNC</code></dd> <dd><code th:text="${detail.policy().functionDisplayName()}">ADMIN.FUNC</code></dd>
<dt>Statement Types</dt> <dt>Statement Types</dt>
<dd th:text="${detail.policy().statementTypes()} ?: '-'">SELECT</dd> <dd th:text="${detail.policy().statementTypes()} ?: '-'">SELECT</dd>

View File

@@ -0,0 +1,173 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('그룹 관리')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>그룹 관리</h1>
<p>사용자 그룹을 만들고 그룹에 사용자와 역할을 부여합니다. 권한은 역할에 연결되고, 사용자는 직접 역할과 그룹 역할을 함께 상속합니다.</p>
</div>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<section class="content-band">
<h2>그룹 추가</h2>
<form method="post" action="/groups" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
그룹 코드
<input class="form-control" name="groupCode" placeholder="SALES_TEAM" required>
</label>
<label>
그룹명
<input class="form-control" name="groupName" placeholder="영업팀" required>
</label>
<label class="span-2">
설명
<input class="form-control" name="description" maxlength="200">
</label>
<button class="btn rw-btn-primary" type="submit">추가</button>
</form>
</section>
<section class="content-band">
<h2>그룹 목록</h2>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>ID</th>
<th>코드</th>
<th>그룹명</th>
<th>설명</th>
<th>상태</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="group : ${groups}">
<td th:text="${group.groupId()}">1</td>
<td><code th:text="${group.groupCode()}">SALES_TEAM</code></td>
<td th:text="${group.groupName()}">영업팀</td>
<td th:text="${group.description()}">설명</td>
<td><span class="badge" th:classappend="${group.active()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${group.activeYn()}">Y</span></td>
<td>
<form method="post" action="/groups/active" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="groupId" th:value="${group.groupId()}">
<input type="hidden" name="active" th:value="${!group.active()}">
<button class="btn btn-sm btn-outline-secondary" type="submit" th:text="${group.active()} ? '비활성화' : '활성화'">변경</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(groups)}">
<td colspan="6" class="text-muted">등록된 그룹이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<h2>그룹 사용자</h2>
<form method="post" action="/groups/users" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
그룹
<select class="form-select group-master-select" id="groupUserMaster" name="groupId" required>
<option th:each="group : ${groups}" th:value="${group.groupId()}" th:text="${group.groupCode() + ' / ' + group.groupName()}"></option>
</select>
</label>
<label>
추가할 사용자
<select class="form-select" name="userId" required>
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
</select>
</label>
<button class="btn rw-btn-primary" type="submit">추가</button>
</form>
<h2 class="mt-4">선택 그룹 사용자</h2>
<div class="table-responsive">
<table class="table table-sm align-middle" data-group-detail-table="groupUserMaster" data-empty-message="선택한 그룹에 사용자가 없습니다.">
<thead>
<tr>
<th>그룹</th>
<th>사용자</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="mapping : ${groupUsers}" th:attr="data-group-id=${mapping.groupId()}">
<td><code th:text="${mapping.groupCode()}">SALES_TEAM</code></td>
<td th:text="${mapping.username()}">agent_sales</td>
<td>
<form method="post" action="/groups/users/delete" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="groupId" th:value="${mapping.groupId()}">
<input type="hidden" name="userId" th:value="${mapping.userId()}">
<button class="btn btn-sm btn-outline-danger" type="submit">해제</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(groupUsers)}">
<td colspan="3" class="text-muted">등록된 그룹 사용자가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<h2>그룹 역할</h2>
<form method="post" action="/groups/roles" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
그룹
<select class="form-select group-master-select" id="groupRoleMaster" name="groupId" required>
<option th:each="group : ${groups}" th:value="${group.groupId()}" th:text="${group.groupCode() + ' / ' + group.groupName()}"></option>
</select>
</label>
<label>
부여할 역할
<select class="form-select" name="roleId" required>
<option th:each="role : ${roles}" th:value="${role.roleId()}" th:text="${role.roleName()}"></option>
</select>
</label>
<button class="btn rw-btn-primary" type="submit">부여</button>
</form>
<h2 class="mt-4">선택 그룹 역할</h2>
<div class="table-responsive">
<table class="table table-sm align-middle" data-group-detail-table="groupRoleMaster" data-empty-message="선택한 그룹에 부여된 역할이 없습니다.">
<thead>
<tr>
<th>그룹</th>
<th>역할</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="mapping : ${groupRoles}" th:attr="data-group-id=${mapping.groupId()}">
<td><code th:text="${mapping.groupCode()}">SALES_TEAM</code></td>
<td th:text="${mapping.roleName()}">SALES_ROLE</td>
<td>
<form method="post" action="/groups/roles/delete" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="groupId" th:value="${mapping.groupId()}">
<input type="hidden" name="roleId" th:value="${mapping.roleId()}">
<button class="btn btn-sm btn-outline-danger" type="submit">해제</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(groupRoles)}">
<td colspan="3" class="text-muted">등록된 그룹 역할이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -11,8 +11,8 @@
<section class="content-band chat-shell"> <section class="content-band chat-shell">
<div class="chat-message assistant-message"> <div class="chat-message assistant-message">
<strong>Router</strong> <strong>질문 실행</strong>
<p>보호 객체명이나 업무명을 질문에 넣으면 가장 가까운 MCP tool을 선택합니다. Bearer Token을 입력하면 실제 ORDS 호출까지 실행합니다.</p> <p>아래 예시를 누르거나 직접 질문을 입력하세요. Bearer Token을 입력하면 실제 ORDS/VPD 결과까지 조회합니다.</p>
</div> </div>
<div id="mcp-chatbot-result" class="chat-thread"> <div id="mcp-chatbot-result" class="chat-thread">
@@ -21,11 +21,8 @@
<form hx-post="/mcp-chatbot" hx-target="#mcp-chatbot-result" hx-swap="beforeend" class="chat-form"> <form hx-post="/mcp-chatbot" hx-target="#mcp-chatbot-result" hx-swap="beforeend" class="chat-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="contextPath" value="vpd-live">
<div class="chat-form-row"> <div class="chat-form-row">
<label>
Context
<input class="form-control" name="contextPath" value="vpd-live" pattern="[A-Za-z0-9][A-Za-z0-9_-]{0,63}" required>
</label>
<label> <label>
Limit Limit
<input class="form-control" name="limit" type="number" min="1" max="500" value="50"> <input class="form-control" name="limit" type="number" min="1" max="500" value="50">
@@ -38,12 +35,39 @@
</label> </label>
<label> <label>
질문 질문
<textarea class="form-control" name="question" rows="3" <textarea class="form-control" id="mcp-chat-question" name="question" rows="3"
placeholder="예: BOARD_POSTS에서 이 토큰으로 보이는 행과 NULL 처리 컬럼을 요약해줘." required></textarea> placeholder="예: BOARD_POSTS에서 이 토큰으로 보이는 행과 NULL 처리 컬럼을 요약해줘." required></textarea>
</label> </label>
<div class="question-presets" aria-label="질문 예시">
<button class="btn rw-btn-secondary question-preset" type="button"
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를 정리해줘.">
민감 컬럼 확인
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="BOARD_POSTS에서 이 토큰으로 조회 가능한 게시글 행과 VPD 적용 결과를 요약해줘.">
BOARD_POSTS 조회
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="등록된 보호 객체 중 질문과 가장 가까운 ORDS/VPD MCP tool을 선택하고, 토큰 없이 라우팅 결과만 보여줘.">
라우팅만 확인
</button>
</div>
<button class="btn rw-btn-primary" type="submit">질문 보내기</button> <button class="btn rw-btn-primary" type="submit">질문 보내기</button>
</form> </form>
</section> </section>
</main> </main>
<script>
document.querySelectorAll('.question-preset').forEach((button) => {
button.addEventListener('click', () => {
const question = document.getElementById('mcp-chat-question');
question.value = button.dataset.question || '';
question.focus();
});
});
</script>
</body> </body>
</html> </html>

View File

@@ -6,7 +6,7 @@
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>MCP Reasoning</h1> <h1>MCP Reasoning</h1>
<p>보호 객체 ORDS 호출을 내부 도구로 실행하고, 실행 증거를 OpenAI 호환 모델에 전달해 권한 결과를 해석합니다.</p> <p>Bearer Token으로 보호 객체를 조회한 뒤, 반환된 행과 NULL 처리 결과를 모델이 설명합니다.</p>
</div> </div>
<div class="alert alert-warning" th:if="${runtimeError}"> <div class="alert alert-warning" th:if="${runtimeError}">
@@ -19,7 +19,7 @@
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<h2>도구 실행</h2> <h2>권한 결과 해석</h2>
<a class="btn btn-sm btn-outline-secondary" href="/mcp/tools" target="_blank" rel="noreferrer">도구 JSON</a> <a class="btn btn-sm btn-outline-secondary" href="/mcp/tools" target="_blank" rel="noreferrer">도구 JSON</a>
</div> </div>
<form hx-post="/mcp-reasoning" hx-target="#mcp-result" hx-swap="innerHTML" class="form-grid"> <form hx-post="/mcp-reasoning" hx-target="#mcp-result" hx-swap="innerHTML" class="form-grid">
@@ -29,11 +29,11 @@
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required> <input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
</label> </label>
<label> <label>
도구 조회 대상
<select class="form-select" name="objectId" required> <select class="form-select" id="mcp-reasoning-object" name="objectId" required>
<option th:each="tool : ${tools}" <option th:each="tool : ${tools}"
th:value="${tool.objectId()}" th:value="${tool.objectId()}"
th:text="${tool.name() + ' / ' + tool.displayName()}"></option> th:text="${tool.displayName() + ' / ' + tool.ordsPath()}"></option>
</select> </select>
</label> </label>
<label> <label>
@@ -42,35 +42,61 @@
</label> </label>
<label class="span-2"> <label class="span-2">
질문 질문
<textarea class="form-control" name="question" rows="3" <textarea class="form-control" id="mcp-reasoning-question" name="question" rows="3"
placeholder="예: 이 토큰으로 어떤 행이 보이고 어떤 컬럼이 NULL 처리됐는지 요약해줘."></textarea> placeholder="비워도 조회 행, NULL 처리, 권한 범위, 다음 조치를 요약합니다."></textarea>
</label> </label>
<div class="question-presets span-2" aria-label="질문 예시">
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="요약부터 작성해줘. 이 토큰으로 조회 가능한 행 수, 주요 식별자, NULL 처리 여부, 권한 범위를 표로 정리하고 상세 근거를 이어서 설명해줘.">
기본 분석
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="반환된 컬럼과 maskedColumns를 기준으로 민감 컬럼이 NULL 처리 또는 미노출됐는지 먼저 요약하고, role/permission 설정에서 확인할 항목을 정리해줘.">
민감 컬럼 점검
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="반환된 행 수와 행의 부서/사번 값을 근거로 이 토큰이 전체 조회, 부서 제한, 본인 제한, 조건 제한 중 어디에 가까운지 판단해줘. 추정이면 추정이라고 표시해줘.">
권한 범위 판단
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="운영자가 확인해야 할 이상 징후를 먼저 bullet로 요약하고, ORDS path, VPD policy, permission rule, token 상태 중 어디를 봐야 하는지 제시해줘.">
운영 점검 요약
</button>
</div>
<button class="btn rw-btn-primary" type="submit">Reasoning 실행</button> <button class="btn rw-btn-primary" type="submit">Reasoning 실행</button>
</form> </form>
</section> </section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<h2>등록된 도구</h2> <h2>조회 가능 대상</h2>
<span class="badge text-bg-secondary" th:text="${#lists.size(tools)}">0</span> <span class="badge text-bg-secondary" th:text="${#lists.size(tools)}">0</span>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm align-middle"> <table class="table table-sm align-middle">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>선택</th>
<th>Object</th> <th>Object</th>
<th>ORDS Path</th> <th>ORDS Path</th>
<th>MCP Tool</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="tool : ${tools}"> <tr th:each="tool : ${tools}">
<td><code th:text="${tool.name()}">tool</code></td> <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 th:text="${tool.displayName()}">ADMIN.TABLE</td>
<td><code th:text="${tool.ordsPath()}">path</code></td> <td><code th:text="${tool.ordsPath()}">path</code></td>
<td><code th:text="${tool.name()}">tool</code></td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(tools)}"> <tr th:if="${#lists.isEmpty(tools)}">
<td colspan="3" class="text-muted">등록된 보호 객체 도구가 없습니다.</td> <td colspan="4" class="text-muted">등록된 보호 객체 도구가 없습니다.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -78,8 +104,27 @@
</section> </section>
<section id="mcp-result" class="content-band"> <section id="mcp-result" class="content-band">
<div class="text-muted">Reasoning 결과가 여기에 표시됩니다.</div> <div class="text-muted">실행하면 ORDS 호출 결과, 모델 요약, request/response 증거가 한 번에 표시됩니다.</div>
</section> </section>
</main> </main>
<script>
document.querySelectorAll('.question-preset').forEach((button) => {
button.addEventListener('click', () => {
const question = document.getElementById('mcp-reasoning-question');
question.value = button.dataset.question || '';
question.focus();
});
});
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> </body>
</html> </html>

View File

@@ -6,9 +6,11 @@
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>ORDS 조회 Handler 생성</h1> <h1>ORDS 조회 Handler 생성</h1>
<p>DB 테이블/뷰를 선택해 Bearer Token 기반 VPD 검증용 ORDS 조회 Handler 경로를 등록하고 생성합니다.</p> <p>VPD가 적용된 TABLE/VIEW를 ORDS HTTP 경로로 서빙하기 위한 조회 Handler를 등록합니다.</p>
</div> </div>
<section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div> <div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div> <div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
@@ -86,6 +88,65 @@
</tr> </tr>
<tr> <tr>
<td colspan="5" class="policy-source-cell"> <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"> <div th:id="${'handler-source-' + object.objectId()}" class="text-muted small">
소스 보기를 누르면 기본 Handler PL/SQL이 표시됩니다. 소스 보기를 누르면 기본 Handler PL/SQL이 표시됩니다.
</div> </div>

View File

@@ -0,0 +1,77 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('운영 상태')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>운영 상태</h1>
<p>보호 객체별 ORDS handler, VPD policy, 권한 rule, 최근 검증 상태를 확인합니다.</p>
</div>
<section class="content-band">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Health</th>
<th>Object</th>
<th>ORDS</th>
<th>VPD</th>
<th>권한</th>
<th>최근 검증</th>
<th>조치</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${rows}">
<td>
<span class="badge"
th:classappend="${row.healthLevel() == 'OK'} ? ' text-bg-success' : (${row.healthLevel() == 'ERROR'} ? ' text-bg-danger' : ' text-bg-warning')"
th:text="${row.healthLevel()}">OK</span>
</td>
<td>
<strong th:text="${row.displayName()}">ADMIN.TABLE</strong>
<div class="text-muted small" th:text="${row.enabledYn()}">Y</div>
</td>
<td>
<code th:text="${row.ordsPath()}">cb-ords/path</code>
<div class="small mt-1">
<span th:if="${row.handlerId() != null}">
<span class="badge text-bg-secondary" th:text="${row.handlerMethod()}">POST</span>
<span th:text="${row.handlerSourceType()}">plsql/block</span>
</span>
<span th:unless="${row.handlerId() != null}" class="text-danger">handler 없음</span>
</div>
</td>
<td>
<div><code th:text="${row.policyNames()} ?: '-'">POLICY</code></div>
<div class="small">
<span th:text="${row.policyEnabled()} ?: '-'">YES</span>
<span> / </span>
<span th:text="${row.functionStatus()} ?: '-'">VALID</span>
</div>
<div class="text-muted small" th:text="${row.functionName()} ?: '-'">ADMIN.FILTER</div>
</td>
<td th:text="${row.permissionSummary()}">1 permissions / 1 rules</td>
<td>
<div th:text="${row.lastProbeStatus()} ?: '검증 이력 없음'">SUCCESS</div>
<div class="text-muted small">
<span th:text="${row.lastProbeRowCount()} ?: '-'">0</span>
<span> rows</span>
<span th:if="${row.lastProbeAt() != null}" th:text="${' / ' + row.lastProbeAt()}"></span>
</div>
<div class="text-danger small" th:if="${row.lastProbeErrorCode() != null}" th:text="${row.lastProbeErrorCode()}"></div>
</td>
<td class="small" th:text="${row.actionText()}">조치 없음</td>
</tr>
<tr th:if="${#lists.isEmpty(rows)}">
<td colspan="7" class="text-muted">등록된 활성 보호 객체가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -6,65 +6,181 @@
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>권한 관리</h1> <h1>권한 관리</h1>
<p>역할별 보호 객체 접근과 행 규칙을 저장합니다.</p> <p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다.</p>
</div> </div>
<section th:replace="~{fragments/layout :: architectureStrip('permission')}"></section>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div> <div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<section class="content-band"> <section class="content-band">
<h2>권한 추가</h2> <div class="section-heading">
<form method="post" action="/permissions" class="form-grid"> <h2>권한 추가 Wizard</h2>
<div class="wizard-progress" aria-label="권한 추가 단계">
<button class="wizard-step-indicator active" type="button" data-wizard-target="1">1 역할</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="2">2 객체</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="3">3 행 규칙</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="4">4 컬럼</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="5">5 검토</button>
</div>
</div>
<form method="post" action="/permissions" class="permission-wizard" data-permission-wizard>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label> <aside class="wizard-summary" aria-live="polite">
역할 <div>
<select class="form-select" name="roleId" required> <span>역할</span>
<option th:each="role : ${roles}" th:value="${role.roleId()}" th:text="${role.roleName()}"></option> <strong data-wizard-summary="role">선택 전</strong>
</select> </div>
</label> <div>
<label> <span>객체</span>
보호 객체 <strong data-wizard-summary="object">선택 전</strong>
<select class="form-select" name="objectRef" required> </div>
<optgroup label="등록된 보호 객체"> <div>
<option th:each="object : ${objects}" <span>효과</span>
th:value="${'protected:' + object.objectId()}" <strong data-wizard-summary="effect">ALLOW</strong>
th:attr="data-columns=${#strings.listJoin(columnsByObject[object.objectId()], ',')}" </div>
th:text="${object.displayName()}"></option> <div>
</optgroup> <span>행 규칙</span>
<optgroup label="DB 스키마 객체"> <strong data-wizard-summary="rules">ALL</strong>
<option th:each="dbObject : ${dbObjects}" </div>
th:value="${'db:' + dbObject.value()}" </aside>
th:text="${dbObject.label()}"></option>
</optgroup> <div class="wizard-panel active" data-wizard-step="1">
</select> <div class="wizard-panel-heading">
</label> <span class="wizard-step-number">1</span>
<div class="span-2"> <div>
<div class="field-block-title">행 규칙</div> <h3>역할 선택</h3>
<div id="rowRuleList" class="rule-list"> <p>권한을 부여하거나 거부할 역할을 선택합니다.</p>
<div class="rule-row"> </div>
<select class="form-select rule-column-select" name="ruleColumn"> </div>
<option value="">전체 행</option> <label>
<option value="DEPT_CODE">DEPT_CODE</option> 역할
<option value="OWNER_EMP_NO">OWNER_EMP_NO</option> <select class="form-select" name="roleId" required>
<option value="DOC_ID">DOC_ID</option> <option th:each="role : ${roles}"
<option value="TITLE">TITLE</option> th:value="${role.roleId()}"
</select> th:text="${role.roleName()}"
<select class="form-select" name="ruleType"> th:attr="data-max-sensitivity=${role.maxSensitivityLevel()}"></option>
<option value="ALL">ALL</option> </select>
<option value="=">=</option> </label>
<option value="!=">!=</option> <p class="wizard-hint">선택한 역할의 민감도 허용 상한도 preview에 반영됩니다.</p>
<option value="MY_DEPT">현재 사용자 부서</option> </div>
<option value="SELF">현재 사용자 사번</option>
</select> <div class="wizard-panel" data-wizard-step="2">
<input class="form-control" name="ruleValue" placeholder="비교 값"> <div class="wizard-panel-heading">
<button class="btn btn-outline-secondary" type="button" data-rule-add>추가</button> <span class="wizard-step-number">2</span>
<div>
<h3>보호 객체 선택</h3>
<p>등록된 보호 객체 또는 DB 스키마 객체를 선택합니다.</p>
</div>
</div>
<label>
보호 객체
<select class="form-select" name="objectRef" required>
<optgroup label="등록된 보호 객체">
<option th:each="object : ${objects}"
th:value="${'protected:' + object.objectId()}"
th:attr="data-columns=${#strings.listJoin(columnsByObject[object.objectId()], ',')},
data-maskable-columns=${#strings.listJoin(maskableColumnsByObject[object.objectId()], ',')},
data-maskable-labels=${#strings.listJoin(maskableColumnLabelsByObject[object.objectId()], '|')}"
th:text="${object.displayName()}"></option>
</optgroup>
<optgroup label="DB 스키마 객체">
<option th:each="dbObject : ${dbObjects}"
th:value="${'db:' + dbObject.value()}"
th:text="${dbObject.label()}"></option>
</optgroup>
</select>
</label>
<p class="wizard-hint">DB 스키마 객체를 선택하면 저장 시 보호 객체가 자동 등록됩니다.</p>
</div>
<div class="wizard-panel" data-wizard-step="3">
<div class="wizard-panel-heading">
<span class="wizard-step-number">3</span>
<div>
<h3>권한 효과와 행 규칙</h3>
<p>허용/거부 방향과 VPD 행 필터 조건을 설정합니다.</p>
</div>
</div>
<label>
권한 효과
<select class="form-select" name="permissionEffect" required>
<option value="ALLOW">허용</option>
<option value="DENY">거부</option>
</select>
</label>
<div>
<div class="field-block-title">행 규칙</div>
<div id="rowRuleList" class="rule-list">
<div class="rule-row">
<select class="form-select rule-column-select" name="ruleColumn">
<option value="">기본 컬럼</option>
<option value="DEPT_CODE">DEPT_CODE</option>
<option value="OWNER_EMP_NO">OWNER_EMP_NO</option>
<option value="DOC_ID">DOC_ID</option>
<option value="TITLE">TITLE</option>
</select>
<select class="form-select rule-type-select" name="ruleType">
<option value="ALL">ALL</option>
<option value="MY_DEPT">현재 사용자 부서</option>
<option value="SELF">현재 사용자 사번</option>
<option value="DEPT">지정 부서</option>
<option value="EMP_NO">지정 사번</option>
<option value="=">=</option>
<option value="!=">!=</option>
</select>
<input class="form-control" name="ruleValue" placeholder="비교 값">
<button class="btn btn-outline-secondary" type="button" data-rule-add>추가</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="span-2">
<div class="field-block-title">컬럼 NULL 처리 예외</div> <div class="wizard-panel" data-wizard-step="4">
<input class="form-control" name="visibleColumns" placeholder="CONTENTS"> <div class="wizard-panel-heading">
<span class="wizard-step-number">4</span>
<div>
<h3>권한별 컬럼 마스킹</h3>
<p>이 역할이 이 테이블/뷰를 조회할 때 원문 표시를 허용할 마스킹 컬럼을 선택합니다.</p>
</div>
</div>
<div class="masking-column-picker">
<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">
</label>
<p class="wizard-hint">객체의 기본 민감도/마스킹 정책은 ORDS 조회 Handler 대상에서 관리하고, 여기서는 역할 권한에 붙일 원문 표시 예외만 정합니다.</p>
</div>
<div class="wizard-panel" data-wizard-step="5">
<div class="wizard-panel-heading">
<span class="wizard-step-number">5</span>
<div>
<h3>저장 전 검토</h3>
<p>저장될 권한과 예상 effective policy를 확인합니다.</p>
</div>
</div>
<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="object">-</dd></div>
<div><dt>행 접근</dt><dd data-preview="rowPolicy">-</dd></div>
<div><dt>권한별 컬럼 마스킹</dt><dd data-preview="columnPolicy">-</dd></div>
</dl>
</div>
</div>
<div class="wizard-actions">
<button class="btn btn-outline-secondary" type="button" data-wizard-prev disabled>이전</button>
<button class="btn btn-outline-primary" type="button" data-wizard-next>다음</button>
<button class="btn btn-primary" type="submit" data-wizard-submit hidden>저장</button>
</div> </div>
<button class="btn btn-primary" type="submit">저장</button>
</form> </form>
</section> </section>
@@ -78,9 +194,10 @@
<th>역할</th> <th>역할</th>
<th>테이블/뷰</th> <th>테이블/뷰</th>
<th>Action</th> <th>Action</th>
<th>Effect</th>
<th>행 규칙</th> <th>행 규칙</th>
<th>적용 필터</th> <th>적용 필터</th>
<th>NULL 제외 컬럼</th> <th>원문 표시 허용 컬럼</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -90,6 +207,11 @@
<td th:text="${permission.roleName()}">HR_DEPT_ROLE</td> <td th:text="${permission.roleName()}">HR_DEPT_ROLE</td>
<td th:text="${permission.objectName()}">CB_V_SEARCH_DOCUMENTS</td> <td th:text="${permission.objectName()}">CB_V_SEARCH_DOCUMENTS</td>
<td th:text="${permission.action()}">SELECT</td> <td th:text="${permission.action()}">SELECT</td>
<td>
<span class="badge"
th:classappend="${permission.permissionEffect() == 'DENY'} ? ' text-bg-danger' : ' text-bg-success'"
th:text="${permission.permissionEffect()}">ALLOW</span>
</td>
<td th:text="${permission.rules()} ?: '-'">ALL</td> <td th:text="${permission.rules()} ?: '-'">ALL</td>
<td><pre class="table-pre" th:text="${permission.filterPreview()} ?: '-'">ALL ROWS</pre></td> <td><pre class="table-pre" th:text="${permission.filterPreview()} ?: '-'">ALL ROWS</pre></td>
<td th:text="${permission.visibleColumns()} ?: '-'">CONTENTS</td> <td th:text="${permission.visibleColumns()} ?: '-'">CONTENTS</td>
@@ -102,7 +224,7 @@
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(permissions)}"> <tr th:if="${#lists.isEmpty(permissions)}">
<td colspan="8" class="text-muted">등록된 권한이 없습니다.</td> <td colspan="9" class="text-muted">등록된 권한이 없습니다.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -6,10 +6,16 @@
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>ORDS 검증</h1> <h1>ORDS 검증</h1>
<p>Bearer Token과 보호 객체를 선택해 VPD/Redaction 적용 결과를 확인합니다.</p> <p>ORDS가 서빙하는 TABLE/VIEW 호출 결과로 VPD/Redaction 적용 여부를 확인합니다.</p>
</div> </div>
<section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section>
<section class="content-band"> <section class="content-band">
<div class="section-heading">
<h2>ORDS 호출</h2>
<a class="btn btn-sm rw-btn-secondary" href="/mcp-reasoning">MCP Reasoning으로 해석</a>
</div>
<form hx-post="/probe" hx-target="#probe-result" hx-swap="innerHTML" class="form-grid"> <form hx-post="/probe" hx-target="#probe-result" hx-swap="innerHTML" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label> <label>

View File

@@ -23,6 +23,15 @@
설명 설명
<input class="form-control" name="description" maxlength="200"> <input class="form-control" name="description" maxlength="200">
</label> </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>
</label>
<button class="btn rw-btn-primary" type="submit">추가</button> <button class="btn rw-btn-primary" type="submit">추가</button>
</form> </form>
</section> </section>
@@ -35,6 +44,7 @@
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>역할명</th> <th>역할명</th>
<th>민감도 허용 상한</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -42,6 +52,19 @@
<tr th:each="role : ${roles}"> <tr th:each="role : ${roles}">
<td th:text="${role.roleId()}">10</td> <td th:text="${role.roleId()}">10</td>
<td th:text="${role.roleName()}">HR_DEPT_ROLE</td> <td th:text="${role.roleName()}">HR_DEPT_ROLE</td>
<td>
<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()}">
<select class="form-select form-select-sm" name="maxSensitivityLevel">
<option value="PUBLIC" th:selected="${role.maxSensitivityLevel() == 'PUBLIC'}">PUBLIC</option>
<option value="INTERNAL" th:selected="${role.maxSensitivityLevel() == 'INTERNAL'}">INTERNAL</option>
<option value="CONFIDENTIAL" th:selected="${role.maxSensitivityLevel() == 'CONFIDENTIAL'}">CONFIDENTIAL</option>
<option value="RESTRICTED" th:selected="${role.maxSensitivityLevel() == 'RESTRICTED'}">RESTRICTED</option>
</select>
<button class="btn btn-sm btn-outline-primary" type="submit">저장</button>
</form>
</td>
<td> <td>
<form method="post" action="/roles/delete" class="inline-form"> <form method="post" action="/roles/delete" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
@@ -51,7 +74,7 @@
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(roles)}"> <tr th:if="${#lists.isEmpty(roles)}">
<td colspan="3" class="text-muted">등록된 역할이 없습니다.</td> <td colspan="4" class="text-muted">등록된 역할이 없습니다.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -0,0 +1,188 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('Filter Policy 관리')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>Filter Policy 관리</h1>
<p>Filter function과 적용된 VPD policy를 관리합니다. TABLE/VIEW 개별/벌크 적용은 VPD 설정에서 처리합니다.</p>
</div>
<div class="alert alert-warning" th:if="${runtimeError}">
<strong th:text="${runtimeError.title()}">조회할 수 없습니다.</strong>
<span th:text="${runtimeError.message()}">message</span>
</div>
<div class="alert alert-success" th:if="${successMessage}" th:text="${successMessage}">등록되었습니다.</div>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}">처리할 수 없습니다.</div>
<section class="content-band">
<div class="section-heading">
<h2>Filter 등록/수정</h2>
<a class="btn btn-sm rw-btn-secondary" href="/vpd-policies">VPD 적용 화면</a>
</div>
<form method="post" action="/vpd-filter-policies/filters" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
Function Owner
<select class="form-select" name="functionOwner">
<option value="">현재 연결 사용자</option>
<option th:each="owner : ${formOptions.owners()}" th:value="${owner}" th:text="${owner}"></option>
</select>
</label>
<label>
Function 이름
<input class="form-control" name="functionName" list="filter-function-names"
placeholder="예: BOARD_POSTS_FILTER" required>
</label>
<label class="span-2">
Filter predicate
<textarea class="form-control" id="filter-only-predicate" name="filterPredicate" rows="4"
placeholder="예: dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')" required></textarea>
</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="1=1">전체 허용</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="owner_emp_no = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')">본인 소유</button>
</div>
<button class="btn rw-btn-primary" type="submit">Filter 저장</button>
</form>
<datalist id="filter-function-names">
<option th:each="function : ${formOptions.functions()}" th:value="${function.functionName()}"></option>
</datalist>
</section>
<section class="content-band">
<div class="section-heading">
<h2>Filter 목록</h2>
<span class="badge text-bg-secondary" th:text="${#lists.size(formOptions.functions())}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Function</th>
<th>Type</th>
<th>Source</th>
</tr>
</thead>
<tbody>
<th:block th:each="function, iter : ${formOptions.functions()}">
<tr>
<td><code th:text="${function.value()}">ADMIN.FILTER</code></td>
<td th:text="${function.objectType()}">FUNCTION</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()})}"
th:hx-target="${'#filter-source-' + iter.index}"
hx-swap="innerHTML">
Source 보기
</button>
</td>
</tr>
<tr>
<td colspan="3" class="policy-source-cell">
<div class="filter-edit-grid">
<div th:id="${'filter-source-' + iter.index}" class="text-muted small">
Source 보기를 누르면 현재 function source가 표시됩니다.
</div>
<form method="post" action="/vpd-filter-policies/filters" class="filter-edit-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="functionOwner" th:value="${function.owner()}">
<input type="hidden" name="functionName" th:value="${function.functionName()}">
<label>
수정할 Filter predicate
<textarea class="form-control" name="filterPredicate" rows="3"
th:id="${'filter-edit-predicate-' + iter.index}"
placeholder="예: dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')" required></textarea>
</label>
<div class="question-presets" aria-label="Filter predicate 예시">
<button class="btn rw-btn-secondary question-preset" type="button"
th:attr="data-target=${'filter-edit-predicate-' + iter.index}"
data-question="1=0">전체 차단</button>
<button class="btn rw-btn-secondary question-preset" type="button"
th:attr="data-target=${'filter-edit-predicate-' + iter.index}"
data-question="1=1">전체 허용</button>
<button class="btn rw-btn-secondary question-preset" type="button"
th:attr="data-target=${'filter-edit-predicate-' + iter.index}"
data-question="dept_code = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')">부서 일치</button>
</div>
<button class="btn btn-sm rw-btn-primary" type="submit">Filter 수정</button>
</form>
</div>
</td>
</tr>
</th:block>
<tr th:if="${#lists.isEmpty(formOptions.functions())}">
<td colspan="3" class="text-muted">등록된 VPD filter function 후보가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<h2>적용된 Policy 목록/수정</h2>
<span class="badge text-bg-secondary" th:text="${#lists.size(policies)}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Object</th>
<th>Policy</th>
<th>Function</th>
<th>Status</th>
<th>수정</th>
</tr>
</thead>
<tbody>
<tr th:each="policy : ${policies}">
<td><code th:text="${policy.objectDisplayName()}">ADMIN.TABLE</code></td>
<td><code th:text="${policy.policyName()}">POLICY</code></td>
<td><code th:text="${policy.functionDisplayName()}">ADMIN.FILTER</code></td>
<td th:text="${policy.enabled()}">YES</td>
<td>
<form method="post" action="/vpd-filter-policies/replace" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="oldObjectKey" th:value="${policy.objectDisplayName()}">
<input type="hidden" name="oldPolicyName" th:value="${policy.policyName()}">
<input type="hidden" name="objectKey" th:value="${policy.objectDisplayName()}">
<input class="form-control form-control-sm" name="policyName" th:value="${policy.policyName()}">
<select class="form-select form-select-sm" name="functionKey">
<option th:each="function : ${formOptions.functions()}"
th:value="${function.value()}"
th:text="${function.value()}"
th:selected="${function.value() == policy.functionDisplayName()}"></option>
</select>
<input type="hidden" name="enabled" value="true">
<input type="hidden" name="statementTypes" th:value="${policy.statementTypes()} ?: 'SELECT'">
<button class="btn btn-sm rw-btn-secondary" type="submit">수정</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(policies)}">
<td colspan="5" class="text-muted">등록된 VPD policy가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<script>
document.querySelectorAll('.question-preset').forEach((button) => {
button.addEventListener('click', () => {
const target = document.getElementById(button.dataset.target || '');
if (target) {
target.value = button.dataset.question || '';
target.focus();
}
});
});
</script>
</body>
</html>

View File

@@ -6,13 +6,239 @@
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>VPD 설정</h1> <h1>VPD 설정</h1>
<p>ORDS 조회 Handler 대상에 등록된 보호 객체에 적용된 Oracle VPD policy 설정을 확인합니다.</p> <p>Oracle Database의 TABLE/VIEW에 VPD policy와 policy function/filter predicate를 적용합니다.</p>
</div> </div>
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
<div class="alert alert-warning" th:if="${runtimeError}"> <div class="alert alert-warning" th:if="${runtimeError}">
<strong th:text="${runtimeError.title()}">조회할 수 없습니다.</strong> <strong th:text="${runtimeError.title()}">조회할 수 없습니다.</strong>
<span th:text="${runtimeError.message()}">message</span> <span th:text="${runtimeError.message()}">message</span>
</div> </div>
<div class="alert alert-success" th:if="${successMessage}" th:text="${successMessage}">등록되었습니다.</div>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}">처리할 수 없습니다.</div>
<section class="content-band">
<div class="section-heading">
<h2>VPD 적용</h2>
<a class="btn btn-sm rw-btn-primary" href="/vpd-filter-policies">Filter Policy 관리</a>
</div>
<p class="text-muted mb-0">VPD는 개별 보호 객체 적용을 기본으로 하고, 같은 정책을 스키마 TABLE/VIEW에 확장할 때만 벌크 적용을 사용합니다.</p>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>VPD 적용 대상 TABLE/VIEW</h2>
<p class="section-subtitle">Oracle DB catalog 기준의 TABLE/VIEW 목록입니다. ORDS handler는 여러 객체를 조합할 수 있으므로 이 표의 ORDS 경로는 사용 여부가 아니라 백오피스에 등록된 검증용 경로입니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(vpdTargets)}">0</span>
</div>
<form method="get" action="/vpd-policies" class="inline-form mb-3">
<label>
Schema
<input class="form-control form-control-sm" name="schemaOwner" list="vpd-schema-owner-options"
th:value="${selectedSchemaOwner}" placeholder="관리 대상 스키마 또는 직접 입력">
</label>
<button class="btn btn-sm rw-btn-secondary" type="submit">조회</button>
</form>
<datalist id="vpd-schema-owner-options">
<option value="">관리 대상 스키마</option>
<option th:each="owner : ${formOptions.schemaOwners()}" th:value="${owner}"></option>
</datalist>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Object</th>
<th>Type</th>
<th>VPD 상태</th>
<th>Policy</th>
<th>Filter</th>
<th>백오피스 권한 테이블</th>
<th>검증용 ORDS 경로</th>
</tr>
</thead>
<tbody>
<th:block th:each="target, iter : ${vpdTargets}">
<tr>
<td><code th:text="${target.objectDisplayName()}">ADMIN.TABLE</code></td>
<td th:text="${target.objectType()}">TABLE</td>
<td>
<span class="badge"
th:classappend="${target.vpdApplied()} ? ' text-bg-success' : ' text-bg-secondary'"
th:text="${target.vpdApplied()} ? 'VPD 적용됨' : '미적용'">미적용</span>
</td>
<td>
<span th:if="${target.vpdApplied()}" th:text="${target.policyNames()}">POLICY</span>
<span th:unless="${target.vpdApplied()}" class="text-muted">-</span>
</td>
<td>
<button th:if="${target.vpdApplied()}"
class="btn btn-sm btn-outline-secondary policy-function-button"
type="button"
aria-expanded="false"
data-filter-toggle="true"
data-toggle-default-text="Filter 이름을 클릭하면 policy/filter source가 표시됩니다."
data-toggle-loading-text="policy/filter source를 조회하는 중입니다."
th:hx-get="@{/vpd-policies/object-filter-detail(objectOwner=${target.owner()},objectName=${target.objectName()})}"
th:hx-target="${'#target-filter-detail-' + iter.index}"
hx-swap="innerHTML">
<code th:text="${target.filterNames()}">ADMIN.FILTER</code>
</button>
<span th:unless="${target.vpdApplied()}" class="text-muted">-</span>
</td>
<td>
<span class="badge"
th:classappend="${target.protectedObject()} ? ' text-bg-primary' : ' text-bg-light'"
th:text="${target.protectedObject()} ? '등록됨' : '미등록'">미등록</span>
</td>
<td>
<code th:if="${target.ordsPath()}" th:text="${target.ordsPath()}">cb-ords/path</code>
<span th:unless="${target.ordsPath()}" class="text-muted">검증 경로 미등록</span>
</td>
</tr>
<tr th:if="${target.vpdApplied()}">
<td colspan="7" class="policy-source-cell">
<div th:id="${'target-filter-detail-' + iter.index}" class="text-muted small">
Filter 이름을 클릭하면 policy/filter source가 표시됩니다.
</div>
</td>
</tr>
</th:block>
<tr th:if="${#lists.isEmpty(vpdTargets)}">
<td colspan="7" class="text-muted">
<span th:if="${selectedSchemaOwner != null && selectedSchemaOwner != ''}"
th:text="${selectedSchemaOwner + ' 스키마에서 현재 DB 연결 사용자로 조회 가능한 TABLE/VIEW가 없습니다. 스키마명, ALL_OBJECTS 조회 권한, 객체 권한을 확인하세요.'}">
선택한 스키마에서 조회 가능한 TABLE/VIEW가 없습니다.
</span>
<span th:unless="${selectedSchemaOwner != null && selectedSchemaOwner != ''}">
조회 가능한 TABLE/VIEW가 없습니다. DB 연결 사용자 권한과 스키마 객체를 확인하세요.
</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<h2>개별 적용</h2>
</div>
<form method="post" action="/vpd-policies" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
VPD 적용 대상 TABLE/VIEW
<select class="form-select" name="objectKey" required>
<option th:each="target : ${vpdTargets}"
th:value="${target.objectDisplayName()}"
th:text="${target.objectDisplayName() + ' / ' + target.objectType() + (target.vpdApplied() ? ' / VPD 적용됨' : ' / 미적용')}"></option>
</select>
</label>
<label class="span-2">
적용할 Policy / Filter
<select class="form-select" data-policy-template-select required>
<option value="">등록된 policy/filter 조합 선택</option>
<option th:each="template : ${formOptions.policyTemplates()}"
th:value="${template.label()}"
th:data-policy-name="${template.policyName()}"
th:data-function-key="${template.functionKey()}"
th:data-statement-types="${template.statementTypes()}"
th:data-enabled="${template.enabledValue()}"
th:data-update-check="${template.updateCheckValue()}"
th:text="${template.label()}"></option>
</select>
</label>
<input type="hidden" name="policyName" data-policy-template-field="policyName">
<input type="hidden" name="functionKey" data-policy-template-field="functionKey">
<div>
Statement Types
<div class="checkbox-row">
<label class="form-check" th:each="statement : ${formOptions.statementTypes()}">
<input class="form-check-input" type="checkbox" name="statementTypes"
th:value="${statement}" th:checked="${statement == 'SELECT'}"
data-policy-template-statement>
<span class="form-check-label" th:text="${statement}">SELECT</span>
</label>
</div>
</div>
<div class="form-check align-self-end">
<input class="form-check-input" id="vpd-enabled" type="checkbox" name="enabled" value="true" checked
data-policy-template-field="enabled">
<label class="form-check-label" for="vpd-enabled">등록 즉시 활성화</label>
</div>
<div class="form-check span-2">
<input class="form-check-input" id="vpd-update-check" type="checkbox" name="updateCheck" value="true"
data-policy-template-field="updateCheck">
<label class="form-check-label" for="vpd-update-check">INSERT/UPDATE에도 predicate check 적용</label>
</div>
<p class="text-muted small span-2 mb-0">Policy/Filter 조합은 Filter Policy 관리에서 등록/수정합니다. 여기서는 선택한 조합을 대상 TABLE/VIEW에 적용합니다.</p>
<button class="btn rw-btn-primary" type="submit">개별 객체에 VPD 적용</button>
</form>
</section>
<section class="content-band">
<details>
<summary class="bulk-apply-summary">벌크 적용</summary>
<form method="post" action="/vpd-policies/bulk" class="form-grid mt-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
Schema
<select class="form-select" name="schemaOwner" required>
<option th:each="owner : ${formOptions.schemaOwners()}" th:value="${owner}" th:text="${owner}"></option>
</select>
</label>
<div>
Object Types
<div class="checkbox-row">
<label class="form-check">
<input class="form-check-input" type="checkbox" name="includeTables" value="true" checked>
<span class="form-check-label">TABLE</span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="includeViews" value="true" checked>
<span class="form-check-label">VIEW</span>
</label>
</div>
</div>
<label class="span-2">
VPD Filter Function
<select class="form-select" name="functionKey">
<option value="">기본 권한 함수가 없으면 Filter predicate로 벌크 function 자동 생성</option>
<option th:each="function : ${formOptions.functions()}"
th:value="${function.value()}"
th:text="${function.functionName() == 'CB_AGENT_DOC_VPD_FILTER' ? function.label() + ' / 기본 권한 테이블 필터' : function.label()}"
th:selected="${function.value() == formOptions.defaultPermissionFunctionKey()}"></option>
</select>
</label>
<div>
Statement Types
<div class="checkbox-row">
<label class="form-check" th:each="statement : ${formOptions.statementTypes()}">
<input class="form-check-input" type="checkbox" name="statementTypes"
th:value="${statement}" th:checked="${statement == 'SELECT'}">
<span class="form-check-label" th:text="${statement}">SELECT</span>
</label>
</div>
</div>
<div class="form-check align-self-end">
<input class="form-check-input" id="bulk-vpd-enabled" type="checkbox" name="enabled" value="true" checked>
<label class="form-check-label" for="bulk-vpd-enabled">등록 즉시 활성화</label>
</div>
<div class="form-check span-2">
<input class="form-check-input" id="bulk-vpd-update-check" type="checkbox" name="updateCheck" value="true">
<label class="form-check-label" for="bulk-vpd-update-check">INSERT/UPDATE에도 predicate check 적용</label>
</div>
<label class="span-2">
Filter predicate
<textarea class="form-control" id="bulk-vpd-filter-predicate" name="filterPredicate" rows="4"
placeholder="기존 Function을 선택하지 않으면 이 predicate로 스키마 공통 function을 생성합니다."></textarea>
</label>
<button class="btn rw-btn-primary" type="submit">스키마 TABLE/VIEW에 VPD 일괄 적용</button>
</form>
</details>
</section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
@@ -26,7 +252,7 @@
<tr> <tr>
<th>Object</th> <th>Object</th>
<th>Policy</th> <th>Policy</th>
<th>Function</th> <th>Filter</th>
<th>Statements</th> <th>Statements</th>
<th>Status</th> <th>Status</th>
<th>Type</th> <th>Type</th>
@@ -58,6 +284,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
<code th:text="${policy.functionDisplayName()}">OWNER.FUNC</code> <code th:text="${policy.functionDisplayName()}">OWNER.FUNC</code>
</button> </button>
<div class="text-muted small">클릭하면 filter source를 봅니다.</div>
</td> </td>
<td th:text="${policy.statementTypes()} ?: '-'">SELECT</td> <td th:text="${policy.statementTypes()} ?: '-'">SELECT</td>
<td> <td>
@@ -91,14 +318,14 @@
<tr> <tr>
<td colspan="8" class="policy-source-cell"> <td colspan="8" class="policy-source-cell">
<div th:id="${'policy-source-' + iter.index}" class="text-muted small"> <div th:id="${'policy-source-' + iter.index}" class="text-muted small">
Policy, policy function, LLM 설명을 클릭하면 상세 내역이 표시됩니다. Policy, Filter, LLM 설명을 클릭하면 상세 내역이 표시됩니다.
</div> </div>
</td> </td>
</tr> </tr>
</th:block> </th:block>
<tr th:if="${#lists.isEmpty(policies)}"> <tr th:if="${#lists.isEmpty(policies)}">
<td colspan="8" class="text-muted"> <td colspan="8" class="text-muted">
등록된 보호 객체에 적용된 VPD policy가 없습니다. ORDS 조회 Handler 대상 등록 상태와 DB policy 적용 상태를 확인하세요. 등록된 TABLE/VIEW에 적용된 VPD policy가 없습니다. 보호 객체 등록 상태와 DB policy 적용 상태를 확인하세요.
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -106,5 +333,16 @@
</div> </div>
</section> </section>
</main> </main>
<script>
document.querySelectorAll('.question-preset').forEach((button) => {
button.addEventListener('click', () => {
const target = document.getElementById(button.dataset.target || '');
if (target) {
target.value = button.dataset.question || '';
target.focus();
}
});
});
</script>
</body> </body>
</html> </html>

View File

@@ -14,6 +14,7 @@ import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.mapper.AuditMapper; import com.cloudhandson.vpdbackoffice.mapper.AuditMapper;
import com.cloudhandson.vpdbackoffice.mapper.PermissionMapper; import com.cloudhandson.vpdbackoffice.mapper.PermissionMapper;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -37,8 +38,8 @@ class PermissionServiceTest {
@Override @Override
public List<ProtectedColumn> findColumns(long objectId) { public List<ProtectedColumn> findColumns(long objectId) {
return List.of( return List.of(
new ProtectedColumn(1L, 1L, "DEPT_CODE", "N", null), new ProtectedColumn(1L, 1L, "DEPT_CODE", "N", null, "INTERNAL", "NONE"),
new ProtectedColumn(2L, 1L, "OWNER_EMP_NO", "N", null) new ProtectedColumn(2L, 1L, "OWNER_EMP_NO", "N", null, "INTERNAL", "NONE")
); );
} }
}; };
@@ -51,6 +52,7 @@ class PermissionServiceTest {
10L, 10L,
1L, 1L,
"SELECT", "SELECT",
"ALLOW",
List.of(new RuleCommand(null, "ALL", null), new RuleCommand("DEPT_CODE", "=", "APAC")), List.of(new RuleCommand(null, "ALL", null), new RuleCommand("DEPT_CODE", "=", "APAC")),
List.of() List.of()
); );
@@ -66,6 +68,7 @@ class PermissionServiceTest {
10L, 10L,
1L, 1L,
"SELECT", "SELECT",
"ALLOW",
List.of(new RuleCommand("DEPT_CODE", "CUSTOM_PREDICATE", "1=1")), List.of(new RuleCommand("DEPT_CODE", "CUSTOM_PREDICATE", "1=1")),
List.of() List.of()
); );
@@ -75,6 +78,83 @@ class PermissionServiceTest {
.hasMessageContaining("허용되지 않은"); .hasMessageContaining("허용되지 않은");
} }
@Test
void allowsDefaultColumnsForContextRules() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
"ALLOW",
List.of(new RuleCommand(null, "MY_DEPT", null), new RuleCommand(null, "SELF", null)),
List.of()
);
permissionService.savePermissionSet(command);
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleType)
.containsExactly("MY_DEPT", "SELF");
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleColumn)
.containsExactly(null, null);
}
@Test
void acceptsDeptAndEmpNoRulesWithDefaultColumnsAndValues() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
"ALLOW",
List.of(new RuleCommand(null, "DEPT", "HR"), new RuleCommand(null, "EMP_NO", "E2001")),
List.of()
);
permissionService.savePermissionSet(command);
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleType)
.containsExactly("DEPT", "EMP_NO");
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleValue)
.containsExactly("HR", "E2001");
}
@Test
void rejectsDeptRuleWithoutValue() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
"ALLOW",
List.of(new RuleCommand(null, "DEPT", "")),
List.of()
);
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
.isInstanceOf(AppException.class)
.hasMessageContaining("값이 필요");
}
@Test
void acceptsDenyPermissionEffect() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
"DENY",
List.of(new RuleCommand("DEPT_CODE", "=", "HR")),
List.of()
);
permissionService.savePermissionSet(command);
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
assertThat(mapper.insertedEffect).isEqualTo("DENY");
}
@Test @Test
void disablesProtectedObjectWhenLastPermissionIsDeleted() { void disablesProtectedObjectWhenLastPermissionIsDeleted() {
var mapper = new FakePermissionMapper(); var mapper = new FakePermissionMapper();
@@ -100,14 +180,16 @@ class PermissionServiceTest {
} }
private static class FakePermissionMapper implements PermissionMapper { private static class FakePermissionMapper implements PermissionMapper {
private final List<PermissionRule> insertedRules = new ArrayList<>();
@Override @Override
public List<AppRole> findRoles() { public List<AppRole> findRoles() {
return List.of(new AppRole(10L, "HR_DEPT_ROLE", null)); return List.of(new AppRole(10L, "HR_DEPT_ROLE", null, "INTERNAL"));
} }
@Override @Override
public AppRole findRole(long roleId) { public AppRole findRole(long roleId) {
return roleId == 10L ? new AppRole(10L, "HR_DEPT_ROLE", null) : null; return roleId == 10L ? new AppRole(10L, "HR_DEPT_ROLE", null, "INTERNAL") : null;
} }
@Override @Override
@@ -116,7 +198,12 @@ class PermissionServiceTest {
} }
@Override @Override
public void insertRole(long roleId, String roleName, String description) { public void insertRole(long roleId, String roleName, String description, String maxSensitivityLevel) {
}
@Override
public int updateRoleMaxSensitivity(long roleId, String maxSensitivityLevel) {
return 1;
} }
@Override @Override
@@ -149,20 +236,29 @@ class PermissionServiceTest {
return 0; return 0;
} }
private String insertedEffect;
@Override @Override
public void insertPermission(long permissionId, long roleId, long objectId, String action) { public void insertPermission(long permissionId, long roleId, long objectId, String action, String permissionEffect) {
insertedEffect = permissionEffect;
} }
@Override @Override
public void updatePermissionAction(long permissionId, String action) { public void updatePermissionAction(long permissionId, String action) {
} }
@Override
public void updatePermissionEffect(long permissionId, String permissionEffect) {
insertedEffect = permissionEffect;
}
@Override @Override
public void deleteRules(long permissionId) { public void deleteRules(long permissionId) {
} }
@Override @Override
public void insertRule(PermissionRule rule) { public void insertRule(PermissionRule rule) {
insertedRules.add(rule);
} }
@Override @Override