Compare commits
41 Commits
079f5a9678
...
a906dd0128
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a906dd0128 | ||
|
|
1ea8d34f31 | ||
|
|
3c9bcfdc0a | ||
|
|
74fbd9a2b2 | ||
|
|
453dac6e5d | ||
|
|
ef47893324 | ||
|
|
49feccb659 | ||
|
|
696813ea13 | ||
|
|
d010f2659a | ||
|
|
7fe8d14a40 | ||
|
|
0b81d5b1a1 | ||
|
|
cf46b4ddd1 | ||
|
|
f0782a10d9 | ||
|
|
e806a92e98 | ||
|
|
91b03ed952 | ||
|
|
6e4a306dcd | ||
|
|
8d2909e589 | ||
|
|
b64f06d5d5 | ||
|
|
62f8abce5f | ||
|
|
3629c84861 | ||
|
|
4961083f06 | ||
|
|
d6c7533edd | ||
|
|
1f924f67de | ||
|
|
8fddf1033e | ||
|
|
2379e2c9ec | ||
|
|
1869fe46a6 | ||
|
|
ab190bf6fa | ||
|
|
76227552d7 | ||
|
|
e347c1bc2f | ||
|
|
d97dab34bd | ||
|
|
5eaffd6229 | ||
|
|
fab166e309 | ||
|
|
de179e47d5 | ||
|
|
effd29c534 | ||
|
|
f98729aa78 | ||
|
|
6957dc05b9 | ||
|
|
9a546b7997 | ||
|
|
1bcb6b6a43 | ||
|
|
7f12530edb | ||
|
|
63d2af6d80 | ||
|
|
4d662f7a36 |
93
docs/design/456-vpd-predicate-rule-tests/README.md
Normal file
93
docs/design/456-vpd-predicate-rule-tests/README.md
Normal 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에서 다룬다.
|
||||
@@ -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: 없음.
|
||||
82
docs/design/457-permission-rule-ui-alignment/README.md
Normal file
82
docs/design/457-permission-rule-ui-alignment/README.md
Normal 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는 별도 이슈로 분리할 수 있다.
|
||||
87
docs/design/458-vpd-redaction-regression/README.md
Normal file
87
docs/design/458-vpd-redaction-regression/README.md
Normal 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 운영 런북에서 더 정리한다.
|
||||
75
docs/design/459-permission-edge-test-scenarios/README.md
Normal file
75
docs/design/459-permission-edge-test-scenarios/README.md
Normal 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 도입 시 다룬다.
|
||||
82
docs/design/460-sqlcl-runbook-env-check/README.md
Normal file
82
docs/design/460-sqlcl-runbook-env-check/README.md
Normal 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 외부 어디에 표준 설치할지는 사용자 로컬/운영 환경 정책에 따른다.
|
||||
89
docs/design/461-operational-observability/README.md
Normal file
89
docs/design/461-operational-observability/README.md
Normal 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 테이블에 없으므로 별도 이슈로 확장할 수 있다.
|
||||
82
docs/design/462-predicate-injection-defense/README.md
Normal file
82
docs/design/462-predicate-injection-defense/README.md
Normal 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은 별도 보안 테스트 체계가 필요할 때 확장한다.
|
||||
83
docs/design/463-mcp-reasoning-flow/README.md
Normal file
83
docs/design/463-mcp-reasoning-flow/README.md
Normal 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 원문 자동 복호화는 보안 정책상 별도 검토가 필요하다.
|
||||
30
docs/design/466-permission-deny-priority/README.md
Normal file
30
docs/design/466-permission-deny-priority/README.md
Normal 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`이다.
|
||||
45
docs/design/467-column-sensitivity-redaction/README.md
Normal file
45
docs/design/467-column-sensitivity-redaction/README.md
Normal 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과 호환되도록 표시 예외 구조를 유지한다.
|
||||
34
docs/design/468-permission-wizard/README.md
Normal file
34
docs/design/468-permission-wizard/README.md
Normal 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 테스트와 인증 화면 렌더링 확인을 통과한다.
|
||||
22
docs/design/475-vpd-apply-consolidation/README.md
Normal file
22
docs/design/475-vpd-apply-consolidation/README.md
Normal 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는 유지한다.
|
||||
26
docs/design/477-oracle-vpd-ords-control-ux/README.md
Normal file
26
docs/design/477-oracle-vpd-ords-control-ux/README.md
Normal 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는 서빙 상태 참고 정보로 함께 표시한다.
|
||||
43
docs/design/488-ux-mobile-responsive/README.md
Normal file
43
docs/design/488-ux-mobile-responsive/README.md
Normal 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`
|
||||
78
docs/runbooks/460-sqlcl-vpd-deploy-runbook.md
Normal file
78
docs/runbooks/460-sqlcl-vpd-deploy-runbook.md
Normal 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 회귀 검증을 다시 실행한다.
|
||||
56
docs/testing/459-permission-edge-scenarios.md
Normal file
56
docs/testing/459-permission-edge-scenarios.md
Normal 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
18
run.sh
@@ -10,6 +10,8 @@
|
||||
# ./run.sh tests # 4-user (my/pg/both/none) 로 접속해서 행 필터 검증
|
||||
# ./run.sh audit # admin 으로 정책/뷰/유저 상태 점검
|
||||
# ./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 teardown # ADB 측 객체 + 원격 link/cred 만 정리 (원격 PG/MySQL 데이터는 보존)
|
||||
#
|
||||
@@ -174,6 +176,18 @@ do_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() {
|
||||
log "=== teardown: ADB 측 객체 + dblink/credential 정리 ==="
|
||||
warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)"
|
||||
@@ -226,6 +240,8 @@ case "$CMD" in
|
||||
tests) do_prereq; do_tests ;;
|
||||
audit) do_prereq; do_audit ;;
|
||||
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 ;;
|
||||
all)
|
||||
do_prereq
|
||||
@@ -245,6 +261,6 @@ case "$CMD" in
|
||||
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
|
||||
|
||||
82
scripts/check-sqlcl-backoffice-env.sh
Normal file
82
scripts/check-sqlcl-backoffice-env.sh
Normal 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"
|
||||
180
scripts/vpd-ords-regression-test.sh
Normal file
180
scripts/vpd-ords-regression-test.sh
Normal 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"
|
||||
@@ -52,7 +52,8 @@ CREATE TABLE cb_app_user (
|
||||
|
||||
CREATE TABLE cb_app_role (
|
||||
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 (
|
||||
@@ -65,7 +66,8 @@ CREATE TABLE cb_permission (
|
||||
perm_id NUMBER PRIMARY KEY,
|
||||
role_id NUMBER NOT NULL REFERENCES cb_app_role(role_id),
|
||||
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 (
|
||||
@@ -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 (103, 'agent_all', 'E99999', 'HQ', 'Y', 'Y');
|
||||
|
||||
INSERT INTO cb_app_role VALUES (10, 'HR_DEPT_ROLE');
|
||||
INSERT INTO cb_app_role VALUES (20, 'FIN_SELF_ROLE');
|
||||
INSERT INTO cb_app_role VALUES (30, 'ALL_DOC_ROLE');
|
||||
INSERT INTO cb_app_role VALUES (10, 'HR_DEPT_ROLE', 'INTERNAL');
|
||||
INSERT INTO cb_app_role VALUES (20, 'FIN_SELF_ROLE', 'INTERNAL');
|
||||
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 (102, 20);
|
||||
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 (200, 20, 'CB_V_SEARCH_DOCUMENTS', 'SELECT');
|
||||
INSERT INTO cb_permission VALUES (300, 30, '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', 'ALLOW');
|
||||
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 (2000, 200, 'SELF', NULL);
|
||||
@@ -254,66 +256,148 @@ CREATE OR REPLACE FUNCTION cb_agent_doc_vpd_filter(
|
||||
) RETURN VARCHAR2
|
||||
AUTHID DEFINER
|
||||
AS
|
||||
v_user_id VARCHAR2(30);
|
||||
v_target_name VARCHAR2(128);
|
||||
BEGIN
|
||||
v_user_id := SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID');
|
||||
v_user_id NUMBER;
|
||||
v_target VARCHAR2(128);
|
||||
v_allow_predicate VARCHAR2(32767);
|
||||
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';
|
||||
END IF;
|
||||
|
||||
v_target_name := REPLACE(UPPER(p_object), '''', '''''');
|
||||
v_target := UPPER(TRIM(p_object));
|
||||
|
||||
RETURN
|
||||
'EXISTS (
|
||||
SELECT 1
|
||||
FROM admin.cb_user_role ur
|
||||
JOIN admin.cb_permission p
|
||||
FOR r IN (
|
||||
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 cb_user_role ur
|
||||
JOIN cb_permission p
|
||||
ON p.role_id = ur.role_id
|
||||
JOIN admin.cb_permission_rule r
|
||||
JOIN cb_permission_rule r
|
||||
ON r.perm_id = p.perm_id
|
||||
WHERE ur.user_id = TO_NUMBER(SYS_CONTEXT(''CB_AGENT_CTX'', ''USER_ID''))
|
||||
AND p.target_name = ''' || v_target_name || '''
|
||||
AND p.action_name = ''SELECT''
|
||||
AND (
|
||||
r.rule_type = ''ALL''
|
||||
OR (
|
||||
r.rule_type = ''MY_DEPT''
|
||||
AND dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')
|
||||
)
|
||||
OR (
|
||||
r.rule_type = ''DEPT''
|
||||
AND r.rule_value = dept_code
|
||||
)
|
||||
OR (
|
||||
r.rule_type = ''SELF''
|
||||
AND owner_emp_no = SYS_CONTEXT(''CB_AGENT_CTX'', ''EMP_NO'')
|
||||
)
|
||||
OR (
|
||||
r.rule_type = ''EMP_NO''
|
||||
AND r.rule_value = owner_emp_no
|
||||
)
|
||||
OR (
|
||||
r.rule_type = ''=''
|
||||
AND (
|
||||
(r.rule_column = ''DOC_ID'' AND TO_CHAR(doc_id) = r.rule_value)
|
||||
OR (r.rule_column = ''TITLE'' AND title = r.rule_value)
|
||||
OR (r.rule_column = ''OWNER_EMP_NO'' AND owner_emp_no = r.rule_value)
|
||||
OR (r.rule_column = ''DEPT_CODE'' AND dept_code = r.rule_value)
|
||||
)
|
||||
)
|
||||
OR (
|
||||
r.rule_type = ''!=''
|
||||
AND (
|
||||
(r.rule_column = ''DOC_ID'' AND TO_CHAR(doc_id) != r.rule_value)
|
||||
OR (r.rule_column = ''TITLE'' AND title != r.rule_value)
|
||||
OR (r.rule_column = ''OWNER_EMP_NO'' AND owner_emp_no != r.rule_value)
|
||||
OR (r.rule_column = ''DEPT_CODE'' AND dept_code != r.rule_value)
|
||||
)
|
||||
)
|
||||
)
|
||||
)';
|
||||
WHERE ur.user_id = v_user_id
|
||||
AND 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
|
||||
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
|
||||
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;
|
||||
/
|
||||
|
||||
|
||||
@@ -86,17 +86,45 @@ BEGIN
|
||||
p_module_name => 'cb.agent.security',
|
||||
p_pattern => 'vpd/documents',
|
||||
p_method => 'POST',
|
||||
p_source_type => ORDS.source_type_query,
|
||||
p_source_type => ORDS.source_type_plsql,
|
||||
p_source => q'!
|
||||
DECLARE
|
||||
v_rows SYS_REFCURSOR;
|
||||
BEGIN
|
||||
cb_ords_handler_pkg.set_vpd_context(:auth_header);
|
||||
|
||||
OPEN v_rows FOR
|
||||
SELECT d.doc_id,
|
||||
d.title,
|
||||
d.owner_emp_no,
|
||||
d.dept_code,
|
||||
d.contents
|
||||
FROM admin.cb_v_search_documents d
|
||||
WHERE (SELECT cb_ords_handler_pkg.set_vpd_context_sql(:auth_header) FROM dual) = 1
|
||||
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(
|
||||
@@ -109,6 +137,16 @@ WHERE (SELECT cb_ords_handler_pkg.set_vpd_context_sql(:auth_header) FROM dual)
|
||||
p_param_type => 'STRING',
|
||||
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;
|
||||
END;
|
||||
|
||||
@@ -52,6 +52,74 @@ EXCEPTION
|
||||
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 (
|
||||
object_id NUMBER PRIMARY KEY,
|
||||
owner VARCHAR2(128) NOT NULL,
|
||||
@@ -66,9 +134,38 @@ CREATE TABLE cb_protected_column (
|
||||
column_name VARCHAR2(128) NOT NULL,
|
||||
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL,
|
||||
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)
|
||||
);
|
||||
|
||||
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 (
|
||||
permission_id NUMBER 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
|
||||
USING (
|
||||
SELECT 1 column_id, 1 object_id, 'DOC_ID' column_name, 'N' sensitive_yn FROM dual UNION ALL
|
||||
SELECT 2, 1, 'TITLE', 'N' FROM dual UNION ALL
|
||||
SELECT 3, 1, 'OWNER_EMP_NO', 'N' FROM dual UNION ALL
|
||||
SELECT 4, 1, 'DEPT_CODE', 'N' FROM dual UNION ALL
|
||||
SELECT 5, 1, 'CONTENTS', 'Y' FROM dual
|
||||
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', 'PUBLIC', 'NONE' FROM dual UNION ALL
|
||||
SELECT 3, 1, 'OWNER_EMP_NO', 'N', 'INTERNAL', 'NONE' FROM dual UNION ALL
|
||||
SELECT 4, 1, 'DEPT_CODE', 'N', 'INTERNAL', 'NONE' FROM dual UNION ALL
|
||||
SELECT 5, 1, 'CONTENTS', 'Y', 'CONFIDENTIAL', 'NULLIFY' FROM dual
|
||||
) src
|
||||
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 NOT MATCHED THEN INSERT (column_id, object_id, column_name, sensitive_yn)
|
||||
VALUES (src.column_id, src.object_id, src.column_name, src.sensitive_yn);
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
dst.sensitive_yn = 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
|
||||
USING (
|
||||
@@ -170,13 +274,25 @@ BEGIN
|
||||
|
||||
SELECT COUNT(*)
|
||||
INTO v_allowed
|
||||
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
|
||||
ON p.role_id = ur.role_id
|
||||
ON p.role_id = er.role_id
|
||||
JOIN cb_permission_column pc
|
||||
ON pc.permission_id = p.perm_id
|
||||
WHERE ur.user_id = TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'))
|
||||
AND p.target_name = UPPER(p_target_name)
|
||||
WHERE p.target_name = UPPER(p_target_name)
|
||||
AND p.action_name = 'SELECT'
|
||||
AND pc.column_name = UPPER(p_column_name);
|
||||
|
||||
|
||||
186
sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
Normal file
186
sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
Normal 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 ===
|
||||
291
sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql
Normal file
291
sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql
Normal 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 ===
|
||||
@@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.PermissionService;
|
||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
|
||||
import java.sql.Connection;
|
||||
import javax.sql.DataSource;
|
||||
import org.slf4j.Logger;
|
||||
@@ -17,15 +18,18 @@ public class DbPoolWarmup {
|
||||
private final DataSource dataSource;
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
private final PermissionService permissionService;
|
||||
private final VpdPolicyService vpdPolicyService;
|
||||
|
||||
public DbPoolWarmup(
|
||||
DataSource dataSource,
|
||||
ProtectedObjectService protectedObjectService,
|
||||
PermissionService permissionService
|
||||
PermissionService permissionService,
|
||||
VpdPolicyService vpdPolicyService
|
||||
) {
|
||||
this.dataSource = dataSource;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
this.permissionService = permissionService;
|
||||
this.vpdPolicyService = vpdPolicyService;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@@ -45,6 +49,8 @@ public class DbPoolWarmup {
|
||||
protectedObjectService.findDatabaseObjects();
|
||||
permissionService.findRoles();
|
||||
permissionService.findPermissionViews();
|
||||
vpdPolicyService.findVpdTargets();
|
||||
vpdPolicyService.formOptions();
|
||||
log.info("Backoffice DB catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.group;
|
||||
|
||||
public record GroupCreateCommand(
|
||||
String groupCode,
|
||||
String groupName,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.group;
|
||||
|
||||
public record GroupRoleView(
|
||||
long groupId,
|
||||
String groupCode,
|
||||
String groupName,
|
||||
long roleId,
|
||||
String roleName
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.group;
|
||||
|
||||
public record GroupUserView(
|
||||
long groupId,
|
||||
String groupCode,
|
||||
String groupName,
|
||||
long userId,
|
||||
String username
|
||||
) {
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.cloudhandson.vpdbackoffice.domain.permission;
|
||||
public record AppRole(
|
||||
long roleId,
|
||||
String roleName,
|
||||
String description
|
||||
String description,
|
||||
String maxSensitivityLevel
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ public record PermissionSet(
|
||||
long roleId,
|
||||
long objectId,
|
||||
String action,
|
||||
String permissionEffect,
|
||||
List<PermissionRule> rules,
|
||||
List<String> visibleColumns
|
||||
) {
|
||||
|
||||
@@ -9,6 +9,7 @@ public record PermissionSetCommand(
|
||||
@Positive long roleId,
|
||||
@Positive long objectId,
|
||||
@NotBlank String action,
|
||||
String permissionEffect,
|
||||
@NotEmpty List<RuleCommand> rules,
|
||||
List<String> visibleColumns
|
||||
) {
|
||||
|
||||
@@ -7,6 +7,7 @@ public record PermissionView(
|
||||
long objectId,
|
||||
String objectName,
|
||||
String action,
|
||||
String permissionEffect,
|
||||
String rules,
|
||||
String visibleColumns,
|
||||
String filterPreview,
|
||||
|
||||
@@ -5,10 +5,19 @@ public record ProtectedColumn(
|
||||
long objectId,
|
||||
String columnName,
|
||||
String sensitiveYn,
|
||||
Long visibleRoleId
|
||||
Long visibleRoleId,
|
||||
String sensitivityLevel,
|
||||
String redactionMethod
|
||||
) {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + "개";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.vpd;
|
||||
|
||||
public record VpdSchemaObjectOption(
|
||||
String owner,
|
||||
String objectName,
|
||||
String objectType
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -19,7 +19,11 @@ public interface PermissionMapper {
|
||||
|
||||
void insertRole(@Param("roleId") long roleId,
|
||||
@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);
|
||||
|
||||
@@ -36,10 +40,14 @@ public interface PermissionMapper {
|
||||
void insertPermission(@Param("permissionId") long permissionId,
|
||||
@Param("roleId") long roleId,
|
||||
@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 updatePermissionEffect(@Param("permissionId") long permissionId,
|
||||
@Param("permissionEffect") String permissionEffect);
|
||||
|
||||
void deleteRules(@Param("permissionId") long permissionId);
|
||||
|
||||
void insertRule(PermissionRule rule);
|
||||
|
||||
@@ -34,7 +34,13 @@ public interface ProtectedObjectMapper {
|
||||
void insertColumn(@Param("columnId") long columnId,
|
||||
@Param("objectId") long objectId,
|
||||
@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);
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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.VpdTargetView;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
@@ -10,12 +14,41 @@ public interface VpdPolicyMapper {
|
||||
|
||||
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(
|
||||
@Param("objectOwner") String objectOwner,
|
||||
@Param("objectName") String objectName,
|
||||
@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(
|
||||
@Param("owner") String owner,
|
||||
@Param("objectName") String objectName,
|
||||
|
||||
@@ -41,7 +41,19 @@ public class BackofficeSchemaService {
|
||||
createTable(results, "cb_app_role", """
|
||||
CREATE TABLE cb_app_role (
|
||||
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", """
|
||||
@@ -51,14 +63,31 @@ public class BackofficeSchemaService {
|
||||
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", """
|
||||
CREATE TABLE cb_permission (
|
||||
perm_id NUMBER PRIMARY KEY,
|
||||
role_id NUMBER 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", """
|
||||
CREATE TABLE cb_permission_rule (
|
||||
rule_id NUMBER PRIMARY KEY,
|
||||
@@ -105,9 +134,23 @@ public class BackofficeSchemaService {
|
||||
column_name VARCHAR2(128) NOT NULL,
|
||||
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL,
|
||||
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)
|
||||
)
|
||||
""");
|
||||
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", """
|
||||
CREATE TABLE cb_ords_probe_audit (
|
||||
audit_id NUMBER PRIMARY KEY,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ public class McpReasoningService {
|
||||
|
||||
private String buildPrompt(String question, McpToolView tool, String evidenceJson) {
|
||||
String normalizedQuestion = question == null || question.isBlank()
|
||||
? "이 ORDS/VPD 검증 결과를 권한 관점에서 요약해줘."
|
||||
? "요약부터 작성해줘. 이 ORDS/VPD 검증 결과에서 조회 행 수, 주요 식별자, NULL 처리 여부, 권한 범위, 다음 확인 조치를 정리해줘."
|
||||
: question.trim();
|
||||
return """
|
||||
질문:
|
||||
@@ -131,8 +131,11 @@ public class McpReasoningService {
|
||||
답변 요구사항:
|
||||
- 한국어로 답변한다.
|
||||
- 증거 JSON에 없는 데이터는 추측하지 않는다.
|
||||
- 첫 섹션은 "## 요약"으로 시작하고 3줄 이내로 쓴다.
|
||||
- 그 다음 "## 판단 근거" 섹션에 표를 사용해 rowCount, maskedColumns, status, errorCode를 정리한다.
|
||||
- 그 다음 "## 상세" 섹션에서 반환 행과 권한 범위를 설명한다.
|
||||
- 마지막 "## 다음 조치" 섹션은 운영자가 확인할 항목만 짧게 쓴다.
|
||||
- VPD 행 필터, 컬럼 NULL 처리, ORDS 오류 여부를 구분한다.
|
||||
- 운영자가 다음에 확인할 액션이 있으면 짧게 제시한다.
|
||||
""".formatted(normalizedQuestion, tool.name(), tool.displayName(), tool.ordsPath(), evidenceJson);
|
||||
}
|
||||
|
||||
@@ -145,12 +148,53 @@ public class McpReasoningService {
|
||||
|
||||
private String fallbackAnswer(String question, ProbeResult result) {
|
||||
if (result.status() == ProbeStatus.SUCCESS) {
|
||||
return "AI base URL/API Key 설정이 없어 모델 호출은 건너뛰었습니다. ORDS 호출은 성공했고 "
|
||||
+ result.rowCount() + "건이 반환되었습니다. 질문: " + safeQuestion(question);
|
||||
return """
|
||||
## 요약
|
||||
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 도구 실행 상태는 "
|
||||
+ result.status().name() + "입니다. 오류: "
|
||||
+ (result.errorMessage() == null ? "없음" : result.errorMessage());
|
||||
return """
|
||||
## 요약
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -186,9 +186,9 @@ public class OrdsMetadataService {
|
||||
p_module_name => ?,
|
||||
p_pattern => ?,
|
||||
p_method => 'POST',
|
||||
p_source_type => ORDS.source_type_query,
|
||||
p_source_type => ORDS.source_type_plsql,
|
||||
p_source => ?,
|
||||
p_items_per_page => 25
|
||||
p_items_per_page => 0
|
||||
);
|
||||
ORDS.DEFINE_PARAMETER(
|
||||
p_module_name => ?,
|
||||
@@ -229,10 +229,37 @@ public class OrdsMetadataService {
|
||||
.reduce((left, right) -> left + ",\n " + right)
|
||||
.orElseThrow();
|
||||
return """
|
||||
DECLARE
|
||||
v_rows SYS_REFCURSOR;
|
||||
BEGIN
|
||||
cb_ords_handler_pkg.set_vpd_context(:auth_header);
|
||||
|
||||
OPEN v_rows FOR
|
||||
SELECT %s
|
||||
FROM %s.%s o
|
||||
WHERE (SELECT cb_ords_handler_pkg.set_vpd_context_sql(:auth_header) FROM dual) = 1
|
||||
AND ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500)
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -192,28 +192,27 @@ public class OrdsProbeService {
|
||||
if (rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> sensitiveColumns = protectedObjectService.findColumns(objectId).stream()
|
||||
List<ProtectedColumn> sensitiveColumns = protectedObjectService.findColumns(objectId).stream()
|
||||
.filter(ProtectedColumn::sensitive)
|
||||
.map(column -> column.columnName().toLowerCase(Locale.ROOT))
|
||||
.toList();
|
||||
if (sensitiveColumns.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> masked = new ArrayList<>();
|
||||
for (String column : sensitiveColumns) {
|
||||
for (ProtectedColumn column : sensitiveColumns) {
|
||||
boolean present = false;
|
||||
boolean allNull = true;
|
||||
for (Map<String, Object> row : rows) {
|
||||
for (Map.Entry<String, Object> entry : row.entrySet()) {
|
||||
if (entry.getKey().equalsIgnoreCase(column)) {
|
||||
if (entry.getKey().equalsIgnoreCase(column.columnName())) {
|
||||
present = true;
|
||||
allNull = allNull && entry.getValue() == null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (present && allNull) {
|
||||
masked.add(column);
|
||||
masked.add(column.columnName().toLowerCase(Locale.ROOT) + " [" + column.policyLabel() + "]");
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
|
||||
@@ -19,7 +19,12 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Service
|
||||
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 ProtectedObjectService protectedObjectService;
|
||||
@@ -45,14 +50,30 @@ public class PermissionService {
|
||||
|
||||
@Transactional
|
||||
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()) {
|
||||
throw new AppException("역할명은 필수입니다.");
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
@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
|
||||
public void deleteRole(long roleId) {
|
||||
int deleted = permissionMapper.deleteRole(roleId);
|
||||
@@ -67,6 +88,7 @@ public class PermissionService {
|
||||
if (!"SELECT".equalsIgnoreCase(command.action())) {
|
||||
throw new AppException("초기 구현에서는 SELECT 권한만 저장할 수 있습니다.");
|
||||
}
|
||||
String permissionEffect = normalizePermissionEffect(command.permissionEffect());
|
||||
AppRole role = permissionMapper.findRole(command.roleId());
|
||||
if (role == null) {
|
||||
throw new AppException("역할을 찾을 수 없습니다.");
|
||||
@@ -78,9 +100,10 @@ public class PermissionService {
|
||||
Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId());
|
||||
long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId;
|
||||
if (existingId == null) {
|
||||
permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT");
|
||||
permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect);
|
||||
} else {
|
||||
permissionMapper.updatePermissionAction(permissionId, "SELECT");
|
||||
permissionMapper.updatePermissionEffect(permissionId, permissionEffect);
|
||||
}
|
||||
|
||||
permissionMapper.deleteRules(permissionId);
|
||||
@@ -105,7 +128,7 @@ public class PermissionService {
|
||||
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
|
||||
"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
|
||||
@@ -137,17 +160,17 @@ public class PermissionService {
|
||||
throw new AppException("허용되지 않은 행 규칙입니다: " + type);
|
||||
}
|
||||
String column = normalizeNullable(rule.ruleColumn());
|
||||
if (!"ALL".equals(type) && !allowedColumns.contains(column)) {
|
||||
if (column != null && !allowedColumns.contains(column)) {
|
||||
throw new AppException("행 규칙 컬럼은 보호 객체 컬럼이어야 합니다: " + column);
|
||||
}
|
||||
if (!seen.add(column + ":" + type + ":" + clean(rule.ruleValue()))) {
|
||||
throw new AppException("중복된 행 규칙이 있습니다.");
|
||||
}
|
||||
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 + " 규칙에는 컬럼이 필요합니다.");
|
||||
}
|
||||
if (("=".equals(type) || "!=".equals(type)) && clean(rule.ruleValue()).isBlank()) {
|
||||
if (VALUE_REQUIRED_RULE_TYPES.contains(type) && clean(rule.ruleValue()).isBlank()) {
|
||||
throw new AppException(type + " 규칙에는 값이 필요합니다.");
|
||||
}
|
||||
}
|
||||
@@ -183,6 +206,22 @@ public class PermissionService {
|
||||
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) {
|
||||
String cleaned = clean(value);
|
||||
return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT);
|
||||
|
||||
@@ -25,6 +25,9 @@ public class ProtectedObjectService {
|
||||
private final Map<String, CacheEntry<List<String>>> databaseColumnsCache = new ConcurrentHashMap<>();
|
||||
private final Map<Long, CacheEntry<List<ProtectedColumn>>> protectedColumnsCache = new ConcurrentHashMap<>();
|
||||
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) {
|
||||
this.mapper = mapper;
|
||||
@@ -90,7 +93,9 @@ public class ProtectedObjectService {
|
||||
mapper.insertObject(objectId, normalized);
|
||||
Set<String> sensitive = splitCsv(normalized.sensitiveColumns());
|
||||
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;
|
||||
protectedColumnsCache.remove(objectId);
|
||||
@@ -137,7 +142,7 @@ public class ProtectedObjectService {
|
||||
long objectId = mapper.nextObjectId();
|
||||
mapper.insertObject(objectId, command);
|
||||
for (String column : columns) {
|
||||
mapper.insertColumn(mapper.nextColumnId(), objectId, column, "N");
|
||||
mapper.insertColumn(mapper.nextColumnId(), objectId, column, "N", "PUBLIC", "NONE");
|
||||
}
|
||||
databaseObjectsCache = null;
|
||||
protectedColumnsCache.remove(objectId);
|
||||
@@ -187,6 +192,22 @@ public class ProtectedObjectService {
|
||||
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
|
||||
public void disableObject(long objectId) {
|
||||
int updated = mapper.disableObject(objectId);
|
||||
@@ -211,6 +232,24 @@ public class ProtectedObjectService {
|
||||
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) {
|
||||
|
||||
boolean expired() {
|
||||
|
||||
@@ -1,22 +1,47 @@
|
||||
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.VpdObjectFilterDetail;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyDetail;
|
||||
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.VpdSchemaObjectOption;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdTargetView;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.VpdPolicyMapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
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.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class VpdPolicyService {
|
||||
|
||||
private final VpdPolicyMapper mapper;
|
||||
private final OpenAiCompatibleClient aiClient;
|
||||
private static final Set<String> ALLOWED_STATEMENTS = Set.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX");
|
||||
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.jdbcTemplate = jdbcTemplate;
|
||||
this.aiClient = aiClient;
|
||||
}
|
||||
|
||||
@@ -24,6 +49,179 @@ public class VpdPolicyService {
|
||||
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) {
|
||||
String normalizedOwner = requiredIdentifier(owner, "Function owner");
|
||||
String normalizedFunction = requiredIdentifier(functionName, "Function name");
|
||||
@@ -45,6 +243,129 @@ public class VpdPolicyService {
|
||||
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) {
|
||||
VpdPolicyDetail detail = findPolicyDetail(objectOwner, objectName, policyName);
|
||||
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) {
|
||||
if (policy.packageName() == null || policy.packageName().isBlank()) {
|
||||
return policy.functionName();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
|
||||
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.ProtectedObjectService;
|
||||
import java.util.Arrays;
|
||||
@@ -74,6 +75,22 @@ public class PermissionController {
|
||||
.map(column -> column.columnName())
|
||||
.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();
|
||||
var dbObjects = protectedObjectService.findDatabaseObjects();
|
||||
long dbObjectsAt = System.nanoTime();
|
||||
@@ -89,6 +106,8 @@ public class PermissionController {
|
||||
model.addAttribute("roles", roles);
|
||||
model.addAttribute("objects", objects);
|
||||
model.addAttribute("columnsByObject", columnsByObject);
|
||||
model.addAttribute("maskableColumnsByObject", maskableColumnsByObject);
|
||||
model.addAttribute("maskableColumnLabelsByObject", maskableColumnLabelsByObject);
|
||||
model.addAttribute("dbObjects", dbObjects);
|
||||
model.addAttribute("permissions", permissions);
|
||||
return "permissions";
|
||||
@@ -102,6 +121,7 @@ public class PermissionController {
|
||||
public String save(
|
||||
@RequestParam long roleId,
|
||||
@RequestParam String objectRef,
|
||||
@RequestParam(defaultValue = "ALLOW") String permissionEffect,
|
||||
@RequestParam(required = false) List<String> ruleColumn,
|
||||
@RequestParam List<String> ruleType,
|
||||
@RequestParam(required = false) List<String> ruleValue,
|
||||
@@ -113,6 +133,7 @@ public class PermissionController {
|
||||
roleId,
|
||||
objectId,
|
||||
"SELECT",
|
||||
permissionEffect,
|
||||
buildRules(ruleColumn, ruleType, ruleValue),
|
||||
splitColumns(visibleColumns)
|
||||
));
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObjectCrea
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import com.cloudhandson.vpdbackoffice.service.OrdsMetadataService;
|
||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -28,8 +29,14 @@ public class ProtectedObjectController {
|
||||
|
||||
@GetMapping("/objects")
|
||||
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("columnsByObject", objects.stream()
|
||||
.collect(Collectors.toMap(
|
||||
object -> object.objectId(),
|
||||
object -> protectedObjectService.findColumns(object.objectId())
|
||||
)));
|
||||
return "objects";
|
||||
}
|
||||
|
||||
@@ -65,6 +72,22 @@ public class ProtectedObjectController {
|
||||
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")
|
||||
public String createOrdsHandler(@RequestParam long objectId, RedirectAttributes redirectAttributes) {
|
||||
try {
|
||||
|
||||
@@ -27,13 +27,25 @@ public class RoleController {
|
||||
public String create(
|
||||
@RequestParam String roleName,
|
||||
@RequestParam(required = false) String description,
|
||||
@RequestParam(defaultValue = "PUBLIC") String maxSensitivityLevel,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
permissionService.createRole(roleName, description);
|
||||
permissionService.createRole(roleName, description, maxSensitivityLevel);
|
||||
redirectAttributes.addFlashAttribute("message", "역할을 추가했습니다.");
|
||||
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")
|
||||
public String delete(@RequestParam long roleId, RedirectAttributes redirectAttributes) {
|
||||
permissionService.deleteRole(roleId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
|
||||
import java.util.List;
|
||||
@@ -7,7 +8,9 @@ import org.springframework.dao.DataAccessException;
|
||||
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 VpdPolicyController {
|
||||
@@ -19,15 +22,215 @@ public class VpdPolicyController {
|
||||
}
|
||||
|
||||
@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 {
|
||||
String selectedSchemaOwner = schemaOwner == null ? "" : schemaOwner.trim().toUpperCase();
|
||||
model.addAttribute("policies", vpdPolicyService.findPolicies());
|
||||
model.addAttribute("vpdTargets", vpdPolicyService.findVpdTargets(selectedSchemaOwner));
|
||||
model.addAttribute("selectedSchemaOwner", selectedSchemaOwner);
|
||||
model.addAttribute("formOptions", vpdPolicyService.formOptions());
|
||||
} catch (DataAccessException exception) {
|
||||
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
|
||||
model.addAttribute("runtimeError", message);
|
||||
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")
|
||||
@@ -66,6 +269,23 @@ public class VpdPolicyController {
|
||||
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")
|
||||
public String policyExplanation(
|
||||
@RequestParam String objectOwner,
|
||||
|
||||
82
src/main/resources/mapper/GroupMapper.xml
Normal file
82
src/main/resources/mapper/GroupMapper.xml
Normal 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>
|
||||
83
src/main/resources/mapper/OperationStatusMapper.xml
Normal file
83
src/main/resources/mapper/OperationStatusMapper.xml
Normal 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>
|
||||
@@ -3,13 +3,19 @@
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.PermissionMapper">
|
||||
<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
|
||||
ORDER BY role_name
|
||||
</select>
|
||||
|
||||
<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
|
||||
WHERE role_id = #{roleId}
|
||||
</select>
|
||||
@@ -19,10 +25,20 @@
|
||||
</select>
|
||||
|
||||
<insert id="insertRole">
|
||||
INSERT INTO cb_app_role (role_id, role_name)
|
||||
VALUES (#{roleId,jdbcType=NUMERIC}, UPPER(#{roleName,jdbcType=VARCHAR}))
|
||||
INSERT INTO cb_app_role (role_id, role_name, max_sensitivity_level)
|
||||
VALUES (
|
||||
#{roleId,jdbcType=NUMERIC},
|
||||
UPPER(#{roleName,jdbcType=VARCHAR}),
|
||||
#{maxSensitivityLevel,jdbcType=VARCHAR}
|
||||
)
|
||||
</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 FROM cb_app_role
|
||||
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
||||
@@ -35,6 +51,7 @@
|
||||
o.object_id,
|
||||
p.target_name AS object_name,
|
||||
p.action_name AS action,
|
||||
NVL(p.permission_effect, 'ALLOW') AS permission_effect,
|
||||
LISTAGG(
|
||||
NVL2(pr.rule_column, pr.rule_column || ' ', '') ||
|
||||
pr.rule_type ||
|
||||
@@ -51,8 +68,10 @@
|
||||
SELECT LISTAGG(
|
||||
CASE
|
||||
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 = 'SELF' THEN pr2.rule_column || ' = SYS_CONTEXT(CB_AGENT_CTX.EMP_NO)'
|
||||
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 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
|
||||
END,
|
||||
CHR(10) || 'AND '
|
||||
@@ -72,7 +91,7 @@
|
||||
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_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
|
||||
</select>
|
||||
|
||||
@@ -102,7 +121,8 @@
|
||||
SELECT p.perm_id AS permission_id,
|
||||
p.role_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
|
||||
JOIN cb_protected_object o ON o.object_name = p.target_name
|
||||
WHERE p.role_id = #{roleId}
|
||||
@@ -118,8 +138,8 @@
|
||||
</select>
|
||||
|
||||
<insert id="insertPermission">
|
||||
INSERT INTO cb_permission (perm_id, role_id, target_name, action_name)
|
||||
SELECT #{permissionId}, #{roleId}, object_name, #{action}
|
||||
INSERT INTO cb_permission (perm_id, role_id, target_name, action_name, permission_effect)
|
||||
SELECT #{permissionId}, #{roleId}, object_name, #{action}, #{permissionEffect}
|
||||
FROM cb_protected_object
|
||||
WHERE object_id = #{objectId}
|
||||
</insert>
|
||||
@@ -130,6 +150,12 @@
|
||||
WHERE perm_id = #{permissionId}
|
||||
</update>
|
||||
|
||||
<update id="updatePermissionEffect">
|
||||
UPDATE cb_permission
|
||||
SET permission_effect = #{permissionEffect}
|
||||
WHERE perm_id = #{permissionId}
|
||||
</update>
|
||||
|
||||
<delete id="deleteRules">
|
||||
DELETE FROM cb_permission_rule
|
||||
WHERE perm_id = #{permissionId}
|
||||
|
||||
@@ -57,7 +57,13 @@
|
||||
</select>
|
||||
|
||||
<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
|
||||
WHERE object_id = #{objectId}
|
||||
ORDER BY column_id
|
||||
@@ -83,10 +89,26 @@
|
||||
</insert>
|
||||
|
||||
<insert id="insertColumn">
|
||||
INSERT INTO cb_protected_column (column_id, object_id, column_name, sensitive_yn)
|
||||
VALUES (#{columnId}, #{objectId}, UPPER(#{columnName}), #{sensitiveYn})
|
||||
INSERT INTO cb_protected_column (
|
||||
column_id, object_id, column_name, sensitive_yn, sensitivity_level, redaction_method
|
||||
)
|
||||
VALUES (
|
||||
#{columnId}, #{objectId}, UPPER(#{columnName}), #{sensitiveYn}, #{sensitivityLevel}, #{redactionMethod}
|
||||
)
|
||||
</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 cb_protected_object
|
||||
SET ords_path = #{ordsPath,jdbcType=VARCHAR}
|
||||
|
||||
@@ -38,6 +38,208 @@
|
||||
ORDER BY p.object_owner, p.object_name, p.policy_name
|
||||
</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 <include refid="policyColumns"><property name="alias" value="p."/></include>
|
||||
FROM all_policies p
|
||||
@@ -53,6 +255,22 @@
|
||||
)
|
||||
</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 LISTAGG(text, '') WITHIN GROUP (ORDER BY line)
|
||||
FROM all_source
|
||||
|
||||
@@ -129,7 +129,8 @@ body {
|
||||
}
|
||||
|
||||
.rw-menu-trigger:hover,
|
||||
.rw-menu-trigger:focus {
|
||||
.rw-menu-trigger:focus,
|
||||
.rw-menu-group.open .rw-menu-trigger {
|
||||
background: var(--rw-surface-muted);
|
||||
border-color: var(--rw-border);
|
||||
color: var(--rw-text);
|
||||
@@ -149,8 +150,7 @@ body {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.rw-menu-group:hover .rw-menu-panel,
|
||||
.rw-menu-group:focus-within .rw-menu-panel {
|
||||
.rw-menu-group.open .rw-menu-panel {
|
||||
display: grid;
|
||||
gap: .15rem;
|
||||
}
|
||||
@@ -181,9 +181,17 @@ body {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.rw-menu-group:focus-within .rw-menu-panel {
|
||||
.rw-menu-group.open .rw-menu-panel {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.architecture-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.architecture-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@@ -201,6 +209,55 @@ body {
|
||||
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,
|
||||
.content-band,
|
||||
.summary-tile {
|
||||
@@ -244,6 +301,7 @@ body {
|
||||
|
||||
.content-band {
|
||||
margin-bottom: 1rem;
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -251,21 +309,30 @@ body {
|
||||
.section-heading h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
color: var(--rw-muted);
|
||||
font-size: .875rem;
|
||||
margin: .2rem 0 0;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
@@ -333,7 +400,154 @@ body {
|
||||
.rule-row {
|
||||
display: grid;
|
||||
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,
|
||||
@@ -650,11 +864,35 @@ body {
|
||||
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 {
|
||||
background: var(--rw-surface) !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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 34%) 1fr;
|
||||
@@ -814,18 +1052,119 @@ body {
|
||||
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) {
|
||||
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 {
|
||||
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 {
|
||||
align-items: flex-start;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
flex-wrap: wrap;
|
||||
gap: .25rem;
|
||||
.rw-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rw-menu-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rw-menu-trigger {
|
||||
padding-left: .65rem;
|
||||
padding-right: .65rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
@@ -147,10 +243,33 @@ function filterUserRoleDetail() {
|
||||
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) {
|
||||
document.querySelectorAll('.rule-column-select').forEach((select) => {
|
||||
const current = select.value;
|
||||
select.innerHTML = '<option value="">전체 행</option>' + columns
|
||||
select.innerHTML = '<option value="">기본 컬럼</option>' + columns
|
||||
.map((column) => `<option value="${column}">${column}</option>`)
|
||||
.join('');
|
||||
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() {
|
||||
const objectSelect = document.querySelector('select[name="objectRef"]');
|
||||
if (!objectSelect) {
|
||||
@@ -166,6 +349,7 @@ async function syncRuleColumnOptions() {
|
||||
}
|
||||
const selectedOption = objectSelect.options[objectSelect.selectedIndex];
|
||||
const fallbackColumns = (selectedOption?.dataset.columns || '').split(',').filter(Boolean);
|
||||
renderMaskableColumnOptions(selectedOption);
|
||||
renderRuleColumnOptions(fallbackColumns);
|
||||
try {
|
||||
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', () => {
|
||||
initPersistentMenus();
|
||||
renderMarkdownViews();
|
||||
const master = document.getElementById('userRoleMaster');
|
||||
if (master) {
|
||||
master.addEventListener('change', 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"]');
|
||||
if (objectSelect) {
|
||||
objectSelect.addEventListener('change', syncRuleColumnOptions);
|
||||
objectSelect.addEventListener('change', () => {
|
||||
syncRuleColumnOptions();
|
||||
updatePermissionWizardPreview(document);
|
||||
});
|
||||
syncRuleColumnOptions();
|
||||
}
|
||||
initPermissionWizard();
|
||||
document.querySelectorAll('.rule-type-select').forEach((select) => {
|
||||
select.addEventListener('change', () => {
|
||||
syncRuleTypeHints();
|
||||
updatePermissionWizardPreview(document);
|
||||
});
|
||||
});
|
||||
syncRuleTypeHints();
|
||||
const catalog = document.getElementById('objectCatalogSelect');
|
||||
if (catalog) {
|
||||
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) => {
|
||||
button.addEventListener('click', () => {
|
||||
const row = button.closest('.rule-row');
|
||||
@@ -228,13 +539,44 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
clone.querySelectorAll('input').forEach((input) => input.value = '');
|
||||
const cloneButton = clone.querySelector('[data-rule-add]');
|
||||
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);
|
||||
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) => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>VPD 권한 백오피스</h1>
|
||||
<p>보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다.</p>
|
||||
<p>Oracle Database의 VPD/ORDS 기능을 권한 테이블과 운영 UI로 제어하고 검증합니다.</p>
|
||||
</div>
|
||||
|
||||
<section th:replace="~{fragments/layout :: architectureStrip('')}"></section>
|
||||
|
||||
<div class="alert alert-warning" th:if="${runtimeErrorMessage}">
|
||||
<div class="fw-semibold" th:text="${runtimeErrorTitle}">데이터 처리 오류가 발생했습니다.</div>
|
||||
<div th:text="${runtimeErrorMessage}"></div>
|
||||
|
||||
@@ -16,16 +16,17 @@
|
||||
<a class="navbar-brand" href="/">VPD Backoffice</a>
|
||||
<div class="rw-menu">
|
||||
<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">
|
||||
<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="/permissions">권한</a>
|
||||
<a class="nav-link" href="/tokens">토큰</a>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<a class="nav-link" href="/objects">조회 Handler 생성</a>
|
||||
<a class="nav-link" href="/probe">ORDS 검증</a>
|
||||
@@ -33,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<a class="nav-link" href="/mcp-chatbot">Chatbot</a>
|
||||
<a class="nav-link" href="/mcp-reasoning">Reasoning</a>
|
||||
@@ -42,9 +43,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<a class="nav-link" href="/operation-status">운영 상태</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,5 +58,25 @@
|
||||
</form>
|
||||
</div>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
<span>Tool: <code th:text="${result.toolName()}">tool</code></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 th:if="${!#lists.isEmpty(result.probeResult().maskedColumns())}">
|
||||
Masked: <code th:text="${#strings.listJoin(result.probeResult().maskedColumns(), ', ')}"></code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="ai-answer">
|
||||
<h3>Answer</h3>
|
||||
<pre th:text="${result.answer()}">answer</pre>
|
||||
<h3>요약 및 판단</h3>
|
||||
<div class="markdown-view" data-markdown-view th:text="${result.answer()}">answer</div>
|
||||
</section>
|
||||
|
||||
<div class="probe-exchange-grid">
|
||||
|
||||
@@ -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>
|
||||
@@ -13,7 +13,7 @@
|
||||
<dd th:text="${detail.policy().policyGroup()}">SYS_DEFAULT</dd>
|
||||
<dt>Policy Name</dt>
|
||||
<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>
|
||||
<dt>Statement Types</dt>
|
||||
<dd th:text="${detail.policy().statementTypes()} ?: '-'">SELECT</dd>
|
||||
|
||||
173
src/main/resources/templates/groups.html
Normal file
173
src/main/resources/templates/groups.html
Normal 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>
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
<section class="content-band chat-shell">
|
||||
<div class="chat-message assistant-message">
|
||||
<strong>Router</strong>
|
||||
<p>보호 객체명이나 업무명을 질문에 넣으면 가장 가까운 MCP tool을 선택합니다. Bearer Token을 입력하면 실제 ORDS 호출까지 실행합니다.</p>
|
||||
<strong>질문 실행</strong>
|
||||
<p>아래 예시를 누르거나 직접 질문을 입력하세요. Bearer Token을 입력하면 실제 ORDS/VPD 결과까지 조회합니다.</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<input type="hidden" name="contextPath" value="vpd-live">
|
||||
<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>
|
||||
Limit
|
||||
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
|
||||
@@ -38,12 +35,39 @@
|
||||
</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>
|
||||
</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>
|
||||
</form>
|
||||
</section>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>MCP Reasoning</h1>
|
||||
<p>보호 객체 ORDS 호출을 내부 도구로 실행하고, 실행 증거를 OpenAI 호환 모델에 전달해 권한 결과를 해석합니다.</p>
|
||||
<p>Bearer Token으로 보호 객체를 조회한 뒤, 반환된 행과 NULL 처리 결과를 모델이 설명합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" th:if="${runtimeError}">
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<section class="content-band">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</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}"
|
||||
th:value="${tool.objectId()}"
|
||||
th:text="${tool.name() + ' / ' + tool.displayName()}"></option>
|
||||
th:text="${tool.displayName() + ' / ' + tool.ordsPath()}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -42,35 +42,61 @@
|
||||
</label>
|
||||
<label class="span-2">
|
||||
질문
|
||||
<textarea class="form-control" name="question" rows="3"
|
||||
placeholder="예: 이 토큰으로 어떤 행이 보이고 어떤 컬럼이 NULL 처리됐는지 요약해줘."></textarea>
|
||||
<textarea class="form-control" id="mcp-reasoning-question" name="question" rows="3"
|
||||
placeholder="비워도 조회 행, NULL 처리, 권한 범위, 다음 조치를 요약합니다."></textarea>
|
||||
</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>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="content-band">
|
||||
<div class="section-heading">
|
||||
<h2>등록된 도구</h2>
|
||||
<h2>조회 가능 대상</h2>
|
||||
<span class="badge text-bg-secondary" th:text="${#lists.size(tools)}">0</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>선택</th>
|
||||
<th>Object</th>
|
||||
<th>ORDS Path</th>
|
||||
<th>MCP Tool</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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><code th:text="${tool.ordsPath()}">path</code></td>
|
||||
<td><code th:text="${tool.name()}">tool</code></td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(tools)}">
|
||||
<td colspan="3" class="text-muted">등록된 보호 객체 도구가 없습니다.</td>
|
||||
<td colspan="4" class="text-muted">등록된 보호 객체 도구가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -78,8 +104,27 @@
|
||||
</section>
|
||||
|
||||
<section id="mcp-result" class="content-band">
|
||||
<div class="text-muted">Reasoning 결과가 여기에 표시됩니다.</div>
|
||||
<div class="text-muted">실행하면 ORDS 호출 결과, 모델 요약, request/response 증거가 한 번에 표시됩니다.</div>
|
||||
</section>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>ORDS 조회 Handler 생성</h1>
|
||||
<p>DB 테이블/뷰를 선택해 Bearer Token 기반 VPD 검증용 ORDS 조회 Handler 경로를 등록하고 생성합니다.</p>
|
||||
<p>VPD가 적용된 TABLE/VIEW를 ORDS HTTP 경로로 서빙하기 위한 조회 Handler를 등록합니다.</p>
|
||||
</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-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
||||
|
||||
@@ -86,6 +88,65 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="5" class="policy-source-cell">
|
||||
<details class="column-policy-panel">
|
||||
<summary>기본 컬럼 민감도/마스킹 정책</summary>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Column</th>
|
||||
<th>민감도</th>
|
||||
<th>마스킹</th>
|
||||
<th>상태</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="column : ${columnsByObject[object.objectId()]}">
|
||||
<td>
|
||||
<span th:text="${column.columnName()}">CONTENTS</span>
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"
|
||||
th:form="${'column-policy-form-' + column.columnId()}">
|
||||
<input type="hidden" name="columnId" th:value="${column.columnId()}"
|
||||
th:form="${'column-policy-form-' + column.columnId()}">
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" name="sensitivityLevel"
|
||||
th:form="${'column-policy-form-' + column.columnId()}">
|
||||
<option value="PUBLIC" th:selected="${column.sensitivityLevel() == 'PUBLIC'}">PUBLIC</option>
|
||||
<option value="INTERNAL" th:selected="${column.sensitivityLevel() == 'INTERNAL'}">INTERNAL</option>
|
||||
<option value="CONFIDENTIAL" th:selected="${column.sensitivityLevel() == 'CONFIDENTIAL'}">CONFIDENTIAL</option>
|
||||
<option value="RESTRICTED" th:selected="${column.sensitivityLevel() == 'RESTRICTED'}">RESTRICTED</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" name="redactionMethod"
|
||||
th:form="${'column-policy-form-' + column.columnId()}">
|
||||
<option value="NONE" th:selected="${column.redactionMethod() == 'NONE'}">NONE</option>
|
||||
<option value="NULLIFY" th:selected="${column.redactionMethod() == 'NULLIFY'}">NULLIFY</option>
|
||||
<option value="PARTIAL" th:selected="${column.redactionMethod() == 'PARTIAL'}">PARTIAL</option>
|
||||
<option value="FULL" th:selected="${column.redactionMethod() == 'FULL'}">FULL</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge"
|
||||
th:classappend="${column.sensitive()} ? ' text-bg-warning' : ' text-bg-secondary'"
|
||||
th:text="${column.policyLabel()}">PUBLIC/NONE</span>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/objects/column-policy"
|
||||
th:id="${'column-policy-form-' + column.columnId()}"></form>
|
||||
<button class="btn btn-sm btn-outline-primary" type="submit"
|
||||
th:form="${'column-policy-form-' + column.columnId()}">저장</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(columnsByObject[object.objectId()])}">
|
||||
<td colspan="5" class="text-muted">등록된 컬럼 정책이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
<div th:id="${'handler-source-' + object.objectId()}" class="text-muted small">
|
||||
소스 보기를 누르면 기본 Handler PL/SQL이 표시됩니다.
|
||||
</div>
|
||||
|
||||
77
src/main/resources/templates/operation-status.html
Normal file
77
src/main/resources/templates/operation-status.html
Normal 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>
|
||||
@@ -6,28 +6,82 @@
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>권한 관리</h1>
|
||||
<p>역할별 보호 객체 접근과 행 규칙을 저장합니다.</p>
|
||||
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다.</p>
|
||||
</div>
|
||||
|
||||
<section th:replace="~{fragments/layout :: architectureStrip('permission')}"></section>
|
||||
|
||||
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
||||
|
||||
<section class="content-band">
|
||||
<h2>권한 추가</h2>
|
||||
<form method="post" action="/permissions" class="form-grid">
|
||||
<div class="section-heading">
|
||||
<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}">
|
||||
<aside class="wizard-summary" aria-live="polite">
|
||||
<div>
|
||||
<span>역할</span>
|
||||
<strong data-wizard-summary="role">선택 전</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>객체</span>
|
||||
<strong data-wizard-summary="object">선택 전</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>효과</span>
|
||||
<strong data-wizard-summary="effect">ALLOW</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>행 규칙</span>
|
||||
<strong data-wizard-summary="rules">ALL</strong>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="wizard-panel active" data-wizard-step="1">
|
||||
<div class="wizard-panel-heading">
|
||||
<span class="wizard-step-number">1</span>
|
||||
<div>
|
||||
<h3>역할 선택</h3>
|
||||
<p>권한을 부여하거나 거부할 역할을 선택합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
역할
|
||||
<select class="form-select" name="roleId" required>
|
||||
<option th:each="role : ${roles}" th:value="${role.roleId()}" th:text="${role.roleName()}"></option>
|
||||
<option th:each="role : ${roles}"
|
||||
th:value="${role.roleId()}"
|
||||
th:text="${role.roleName()}"
|
||||
th:attr="data-max-sensitivity=${role.maxSensitivityLevel()}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="wizard-hint">선택한 역할의 민감도 허용 상한도 preview에 반영됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-panel" data-wizard-step="2">
|
||||
<div class="wizard-panel-heading">
|
||||
<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()], ',')}"
|
||||
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 스키마 객체">
|
||||
@@ -37,34 +91,96 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<div class="span-2">
|
||||
<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="">기본 컬럼</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" name="ruleType">
|
||||
<select class="form-select rule-type-select" name="ruleType">
|
||||
<option value="ALL">ALL</option>
|
||||
<option value="=">=</option>
|
||||
<option value="!=">!=</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 class="span-2">
|
||||
<div class="field-block-title">컬럼 NULL 처리 예외</div>
|
||||
<input class="form-control" name="visibleColumns" placeholder="CONTENTS">
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">저장</button>
|
||||
|
||||
<div class="wizard-panel" data-wizard-step="4">
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -78,9 +194,10 @@
|
||||
<th>역할</th>
|
||||
<th>테이블/뷰</th>
|
||||
<th>Action</th>
|
||||
<th>Effect</th>
|
||||
<th>행 규칙</th>
|
||||
<th>적용 필터</th>
|
||||
<th>NULL 제외 컬럼</th>
|
||||
<th>원문 표시 허용 컬럼</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -90,6 +207,11 @@
|
||||
<td th:text="${permission.roleName()}">HR_DEPT_ROLE</td>
|
||||
<td th:text="${permission.objectName()}">CB_V_SEARCH_DOCUMENTS</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><pre class="table-pre" th:text="${permission.filterPreview()} ?: '-'">ALL ROWS</pre></td>
|
||||
<td th:text="${permission.visibleColumns()} ?: '-'">CONTENTS</td>
|
||||
@@ -102,7 +224,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(permissions)}">
|
||||
<td colspan="8" class="text-muted">등록된 권한이 없습니다.</td>
|
||||
<td colspan="9" class="text-muted">등록된 권한이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -6,10 +6,16 @@
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>ORDS 검증</h1>
|
||||
<p>Bearer Token과 보호 객체를 선택해 VPD/Redaction 적용 결과를 확인합니다.</p>
|
||||
<p>ORDS가 서빙하는 TABLE/VIEW 호출 결과로 VPD/Redaction 적용 여부를 확인합니다.</p>
|
||||
</div>
|
||||
|
||||
<section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section>
|
||||
|
||||
<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">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
설명
|
||||
<input class="form-control" name="description" maxlength="200">
|
||||
</label>
|
||||
<label>
|
||||
민감도 허용 상한
|
||||
<select class="form-select" name="maxSensitivityLevel">
|
||||
<option value="PUBLIC">PUBLIC</option>
|
||||
<option value="INTERNAL">INTERNAL</option>
|
||||
<option value="CONFIDENTIAL">CONFIDENTIAL</option>
|
||||
<option value="RESTRICTED">RESTRICTED</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn rw-btn-primary" type="submit">추가</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -35,6 +44,7 @@
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>역할명</th>
|
||||
<th>민감도 허용 상한</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -42,6 +52,19 @@
|
||||
<tr th:each="role : ${roles}">
|
||||
<td th:text="${role.roleId()}">10</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>
|
||||
<form method="post" action="/roles/delete" class="inline-form">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
@@ -51,7 +74,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(roles)}">
|
||||
<td colspan="3" class="text-muted">등록된 역할이 없습니다.</td>
|
||||
<td colspan="4" class="text-muted">등록된 역할이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
188
src/main/resources/templates/vpd-filter-policies.html
Normal file
188
src/main/resources/templates/vpd-filter-policies.html
Normal 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>
|
||||
@@ -6,13 +6,239 @@
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>VPD 설정</h1>
|
||||
<p>ORDS 조회 Handler 대상에 등록된 보호 객체에 적용된 Oracle VPD policy 설정을 확인합니다.</p>
|
||||
<p>Oracle Database의 TABLE/VIEW에 VPD policy와 policy function/filter predicate를 적용합니다.</p>
|
||||
</div>
|
||||
|
||||
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
|
||||
|
||||
<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>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">
|
||||
<div class="section-heading">
|
||||
@@ -26,7 +252,7 @@
|
||||
<tr>
|
||||
<th>Object</th>
|
||||
<th>Policy</th>
|
||||
<th>Function</th>
|
||||
<th>Filter</th>
|
||||
<th>Statements</th>
|
||||
<th>Status</th>
|
||||
<th>Type</th>
|
||||
@@ -58,6 +284,7 @@
|
||||
hx-swap="innerHTML">
|
||||
<code th:text="${policy.functionDisplayName()}">OWNER.FUNC</code>
|
||||
</button>
|
||||
<div class="text-muted small">클릭하면 filter source를 봅니다.</div>
|
||||
</td>
|
||||
<td th:text="${policy.statementTypes()} ?: '-'">SELECT</td>
|
||||
<td>
|
||||
@@ -91,14 +318,14 @@
|
||||
<tr>
|
||||
<td colspan="8" class="policy-source-cell">
|
||||
<div th:id="${'policy-source-' + iter.index}" class="text-muted small">
|
||||
Policy, policy function, LLM 설명을 클릭하면 상세 내역이 표시됩니다.
|
||||
Policy, Filter, LLM 설명을 클릭하면 상세 내역이 표시됩니다.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</th:block>
|
||||
<tr th:if="${#lists.isEmpty(policies)}">
|
||||
<td colspan="8" class="text-muted">
|
||||
등록된 보호 객체에 적용된 VPD policy가 없습니다. ORDS 조회 Handler 대상 등록 상태와 DB policy 적용 상태를 확인하세요.
|
||||
등록된 TABLE/VIEW에 적용된 VPD policy가 없습니다. 보호 객체 등록 상태와 DB policy 적용 상태를 확인하세요.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -106,5 +333,16 @@
|
||||
</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>
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.AuditMapper;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.PermissionMapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -37,8 +38,8 @@ class PermissionServiceTest {
|
||||
@Override
|
||||
public List<ProtectedColumn> findColumns(long objectId) {
|
||||
return List.of(
|
||||
new ProtectedColumn(1L, 1L, "DEPT_CODE", "N", null),
|
||||
new ProtectedColumn(2L, 1L, "OWNER_EMP_NO", "N", null)
|
||||
new ProtectedColumn(1L, 1L, "DEPT_CODE", "N", null, "INTERNAL", "NONE"),
|
||||
new ProtectedColumn(2L, 1L, "OWNER_EMP_NO", "N", null, "INTERNAL", "NONE")
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -51,6 +52,7 @@ class PermissionServiceTest {
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "ALL", null), new RuleCommand("DEPT_CODE", "=", "APAC")),
|
||||
List.of()
|
||||
);
|
||||
@@ -66,6 +68,7 @@ class PermissionServiceTest {
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand("DEPT_CODE", "CUSTOM_PREDICATE", "1=1")),
|
||||
List.of()
|
||||
);
|
||||
@@ -75,6 +78,83 @@ class PermissionServiceTest {
|
||||
.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
|
||||
void disablesProtectedObjectWhenLastPermissionIsDeleted() {
|
||||
var mapper = new FakePermissionMapper();
|
||||
@@ -100,14 +180,16 @@ class PermissionServiceTest {
|
||||
}
|
||||
|
||||
private static class FakePermissionMapper implements PermissionMapper {
|
||||
private final List<PermissionRule> insertedRules = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
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
|
||||
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
|
||||
@@ -116,7 +198,12 @@ class PermissionServiceTest {
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -149,20 +236,29 @@ class PermissionServiceTest {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private String insertedEffect;
|
||||
|
||||
@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
|
||||
public void updatePermissionAction(long permissionId, String action) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updatePermissionEffect(long permissionId, String permissionEffect) {
|
||||
insertedEffect = permissionEffect;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteRules(long permissionId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void insertRule(PermissionRule rule) {
|
||||
insertedRules.add(rule);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
Reference in New Issue
Block a user