From da80041bcb6d2c64c9cf32b1407f0d5ffa1dda1b Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 23 Jul 2026 13:36:21 +0900 Subject: [PATCH] refs #707: enforce HMM leave team VPD --- docs/design/707-hmm-leave-team-vpd/README.md | 116 ++++ ...6-07-23-hmm-leave-team-vpd-verification.md | 61 ++ sql/adb/72_hmm_leave_team_vpd.sql | 597 ++++++++++++++++++ .../domain/permission/PermissionView.java | 9 +- .../domain/vpd/VpdFunctionOption.java | 3 +- .../domain/vpd/VpdPolicyFormOptions.java | 14 +- .../domain/vpd/VpdPolicyView.java | 3 +- .../service/BackofficeSchemaService.java | 61 +- .../service/PermissionService.java | 4 +- .../service/VpdPolicyService.java | 47 +- .../web/VpdPolicyController.java | 10 +- .../resources/mapper/PermissionMapper.xml | 73 ++- src/main/resources/static/js/app.js | 26 +- src/main/resources/templates/permissions.html | 51 +- .../templates/vpd-filter-runtime.html | 20 +- .../domain/permission/PermissionViewTest.java | 29 + .../domain/vpd/VpdPolicyFormOptionsTest.java | 31 + .../web/GuidedFlowTemplateTest.java | 4 +- 18 files changed, 1034 insertions(+), 125 deletions(-) create mode 100644 docs/design/707-hmm-leave-team-vpd/README.md create mode 100644 docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md create mode 100644 sql/adb/72_hmm_leave_team_vpd.sql create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionViewTest.java create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptionsTest.java diff --git a/docs/design/707-hmm-leave-team-vpd/README.md b/docs/design/707-hmm-leave-team-vpd/README.md new file mode 100644 index 0000000..330611c --- /dev/null +++ b/docs/design/707-hmm-leave-team-vpd/README.md @@ -0,0 +1,116 @@ +# HMM 휴가 원장 팀 계층 VPD 설계 (#707) + +> 상태: 구현·ADB 적용·배포·검증 완료 +> 대상: HMMAIPOC `ADMIN`, `hmm-backoffice.cloud-handson.com` +> Git 기준: `hmm-backoffice` + +## 목적 + +HMM 백오피스의 사용자·역할·보호 객체·행 접근 규칙을 실제 Oracle VPD와 연결한다. +팀장 E1001은 자신과 직접 보고 팀원의 휴가 잔여 및 휴가 신청을 조회하고, 일반 팀원은 자기 행만 +조회한다. 유효한 사용자 컨텍스트나 허용 규칙이 없으면 모든 행을 차단한다. + +## 조사 결과 + +- 직원 7명은 `HMM_HR_EMPLOYEES`에 있으며 E1001의 직접 보고 관계가 + `MANAGER_EMPLOYEE_ID = 1`로 저장되어 있다. +- `HMM_LEAVE_BALANCES`는 직원별 1행씩 총 7행이고, `HMM_LEAVE_REQUESTS`는 팀원 6명의 총 8행이다. +- HMM 역할 `HMM_HR_VIEWER`, `HMM_HR_MANAGER`, `HMM_HR_ADMIN`은 존재한다. +- 적용 전 사용자 역할, HMM 토큰, 보호 객체, 행 접근 규칙, VPD 정책은 0건이었다. +- `ADMIN`은 `EXEMPT ACCESS POLICY` 권한이 있어 정책 적용 여부를 검증할 때는 별도의 비면제 + 검증 사용자가 필요하다. + +## 접근 정책 + +| 사용자 유형 | 역할 | 저장 규칙 | 결과 | +|---|---|---|---| +| 일반 팀원 | `HMM_HR_VIEWER` | `SELF` | 현재 직원 ID와 같은 `EMPLOYEE_ID` 행 | +| 팀장 | `HMM_HR_MANAGER` | `MANAGED_TEAM` | 현재 직원 본인과 `MANAGER_EMPLOYEE_ID`가 현재 직원인 직접 보고자 행 | +| HR 관리자 | `HMM_HR_ADMIN` | `ALL` | 전체 행. 역할이 실제로 부여된 경우에만 적용 | +| 미인증/무권한 | 없음 | 없음 | `1 = 0` | + +E1001에는 `HMM_HR_MANAGER`, E1002~E1007에는 `HMM_HR_VIEWER`를 부여한다. HR 관리자 역할은 +미리 규칙만 준비하고 현재 데모 사용자에게 자동 부여하지 않는다. + +## DB 구성 + +### 컨텍스트 + +`HMM_ACCESS_CTX`는 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`만이 설정한다. +패키지는 전달된 토큰의 SHA-256 해시를 `HMM_ACCESS_BEARER_TOKENS`와 비교하고 다음 조건을 모두 +만족한 경우에만 직원 ID, 사번, 팀 ID를 세션 컨텍스트에 기록한다. + +- 토큰이 회수되지 않음 +- 만료 시각이 현재보다 이후 +- 직원이 `ACTIVE` + +오류 또는 누락 토큰은 컨텍스트를 먼저 지우고 실패한다. + +### 정책 함수 + +`HMM_LEAVE_VPD_FILTER`는 현재 컨텍스트의 직원에게 직접 또는 접근 그룹으로 부여된 활성 역할을 +구한다. 역할과 `CB_PERMISSION`, `CB_PERMISSION_RULE`에 저장된 대상별 규칙을 결합해 +`HMM_LEAVE_BALANCES`와 `HMM_LEAVE_REQUESTS`의 `EMPLOYEE_ID` 조건을 반환한다. + +- 한 권한 안의 규칙은 `AND` +- 서로 다른 허용 권한은 `OR` +- 거부 규칙은 허용 결과에서 제외 +- 알 수 없는 규칙, 존재하지 않는 컬럼, 빈 컨텍스트는 fail-closed + +### 정책 연결 + +두 휴가 테이블에 `HMM_LEAVE_SCOPE_POLICY`를 `SELECT`, `CONTEXT_SENSITIVE`로 연결한다. +정책 메타데이터와 설명은 백오피스의 보호 객체 및 VPD 화면에서 조회한다. + +## 백오피스 변경 + +- `/permissions`가 HMM 역할과 `CB_PERMISSION*` 호환 저장소의 실제 규칙을 표시한다. +- HMM 행 규칙 선택지는 `ALL`, `MANAGED_TEAM`, `SELF`, `STATIC_SQL`을 우선 제공한다. +- 미리보기는 `HMM_ACCESS_CTX.EMPLOYEE_ID`와 직접 보고 관계를 보여준다. +- 휴가 테이블의 기본 정책 연결은 `HMM_LEAVE_VPD_FILTER`를 선택한다. +- 보호 객체·역할·권한 수정은 기존 감사 및 외부 권한 변경 알림 흐름을 유지한다. + +## 배포 순서 + +1. 애플리케이션 테스트와 SQL 구문 검사를 수행한다. +2. 멱등 SQL `sql/adb/72_hmm_leave_team_vpd.sql`을 `ADMIN`으로 실행한다. +3. VPD 정책·컴파일 상태·백오피스 화면을 확인한다. +4. 백오피스와 같은 SHA-256 저장 규칙으로 E1001과 E1002 임시 검증 토큰을 만든다. +5. 비면제 검증 사용자에서 토큰별 조회 결과를 확인하고 검증 토큰과 사용자를 제거한다. +6. 애플리케이션 JAR를 배포한 뒤 전 메뉴 회귀검사를 수행한다. + +## 완료 기준 + +- E1001: 휴가 잔여 7행, 휴가 신청 8행 +- E1002: 휴가 잔여 1행, 휴가 신청 1행 +- E1002가 E1003의 행을 조건에 추가 조회해도 0행 +- 무컨텍스트: 두 테이블 모두 0행 +- 두 정책과 컨텍스트/패키지/함수가 `VALID` 및 `ENABLE=YES` +- `/users`, `/roles`, `/objects`, `/permissions`, `/vpd-policies`, `/vpd-filter-runtime`, + `/tokens`, `/probe`를 포함한 백오피스 메뉴에 데이터 처리 오류가 없음 + +## 검증 결과 + +2026-07-23 배포에서 모든 완료 기준을 충족했다. + +- `HMM_ACCESS_CTX_PKG`와 `HMM_LEAVE_VPD_FILTER`는 `VALID` +- 두 `HMM_LEAVE_SCOPE_POLICY`는 `CONTEXT_SENSITIVE`, `ENABLE=YES` +- 비면제 임시 DB 사용자와 원문을 저장하지 않는 임시 토큰으로 확인: + - 무컨텍스트: 잔여 0행, 신청 0행 + - E1001 팀장: 잔여 7행, 신청 8행 + - E1002 팀원: 잔여 1행, 신청 1행 + - E1002가 E1003 신청을 조건 조회: 0행 + - 무효 토큰: 0행 +- 검증용 DB 사용자와 임시 토큰은 즉시 제거했다. +- 자동 테스트 105건이 통과했다. +- 실제 브라우저로 관리 화면 26개와 HMM 정형 원장 6개를 전수 확인했으며 HTTP/화면/브라우저 + 오류는 0건이었다. + +상세 증거는 `docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md`에 기록한다. + +## 보안 및 롤백 + +- 토큰 원문과 DB 비밀번호를 SQL, Git, 로그에 기록하지 않는다. +- 검증용 DB 사용자와 검증 토큰은 테스트가 끝나면 제거 또는 회수한다. +- 롤백은 두 `HMM_LEAVE_SCOPE_POLICY`를 비활성화하거나 제거한 뒤 사용자 역할·권한 메타데이터를 + 유지하는 방식으로 수행한다. 휴가 원장 데이터는 수정하거나 삭제하지 않는다. diff --git a/docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md b/docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md new file mode 100644 index 0000000..bba825a --- /dev/null +++ b/docs/reports/2026-07-23-hmm-leave-team-vpd-verification.md @@ -0,0 +1,61 @@ +# HMM 휴가 팀 계층 VPD 적용·검증 보고서 + +- 일자: 2026-07-23 +- Redmine: #707 +- 브랜치: `hmm-backoffice` +- 대상 DB: HMMAIPOC `ADMIN` +- 대상 서비스: `https://hmm-backoffice.cloud-handson.com` + +## 적용 결과 + +`sql/adb/72_hmm_leave_team_vpd.sql`을 적용해 다음 구성을 활성화했다. + +| 구성 | 결과 | +|---|---| +| 신뢰 컨텍스트 | `HMM_ACCESS_CTX` | +| 토큰 검증 패키지 | `HMM_ACCESS_CTX_PKG` / `VALID` | +| 정책 함수 | `HMM_LEAVE_VPD_FILTER` / `VALID` | +| 휴가 잔여 정책 | `HMM_LEAVE_BALANCES.HMM_LEAVE_SCOPE_POLICY` / `ENABLE=YES` | +| 휴가 신청 정책 | `HMM_LEAVE_REQUESTS.HMM_LEAVE_SCOPE_POLICY` / `ENABLE=YES` | +| 팀장 역할 | E1001 → `HMM_HR_MANAGER` | +| 팀원 역할 | E1002~E1007 → `HMM_HR_VIEWER` | +| 저장 권한 | 두 객체 × `ALL`, `MANAGED_TEAM`, `SELF` = 6건 | + +## 실제 행 접근 검증 + +`ADMIN`에는 `EXEMPT ACCESS POLICY`가 있으므로 이를 결과 검증에 사용하지 않았다. 별도의 비면제 +임시 DB 사용자를 만들고, SHA-256 해시만 DB에 저장한 임시 E1001/E1002 토큰으로 조회한 뒤 +사용자와 토큰을 즉시 제거했다. + +| 검증 세션 | 휴가 잔여 | 휴가 신청 | 교차 사용자 조건 조회 | +|---|---:|---:|---:| +| 컨텍스트 없음 | 0 | 0 | - | +| E1001 팀장 | 7 | 8 | 직접 보고 팀원 포함 | +| E1002 팀원 | 1 | 1 | E1003 신청 0 | +| 무효 토큰 | 0 | - | 컨텍스트 초기화·차단 | + +결과: `VPD_VERIFY=PASS` + +## 애플리케이션 검증 + +- Maven: 105 tests, 0 failures, 0 errors +- 배포 JAR SHA-256: + `1b10582e72e4639902ea8f65156af1067941af3ef50cde1d9f767c88f048fb85` +- systemd: `vpd-backoffice.service` active +- 공개 로그인: HTTP 200 +- 실제 브라우저 전수 검사: + - 관리 화면 26개: 실패 0 + - HMM 정형 원장 6개: 실패 0 + - `ORA-*`, 데이터 처리 오류, KB/POC 잔존 문구: 0 + - page error: 0 + - console error: 0 + +특히 `/roles`, `/permissions`, `/vpd-policies`, `/vpd-filter-runtime`에서 HMM 역할 3개, +휴가 원장 권한 6개, 정책 2개와 `HMM_LEAVE_VPD_FILTER`가 표시되는 것을 화면으로 확인했다. + +## 보안 정리 + +- DB 비밀번호, 토큰 원문, 해시는 Git·Redmine·보고서에 기록하지 않았다. +- 임시 비면제 DB 사용자는 검증 종료 시 `DROP USER ... CASCADE`로 제거했다. +- 임시 토큰 2건은 검증 종료 시 삭제했다. +- HR 관리자 역할은 전체 접근 규칙만 준비하고 데모 사용자에게 자동 부여하지 않았다. diff --git a/sql/adb/72_hmm_leave_team_vpd.sql b/sql/adb/72_hmm_leave_team_vpd.sql new file mode 100644 index 0000000..0b082df --- /dev/null +++ b/sql/adb/72_hmm_leave_team_vpd.sql @@ -0,0 +1,597 @@ +-- ============================================================ +-- 72_hmm_leave_team_vpd.sql +-- +-- HMM HR leave row security: +-- * HMM_HR_MANAGER -> self and directly managed employees +-- * HMM_HR_VIEWER -> self only +-- * HMM_HR_ADMIN -> all rows when the role is explicitly assigned +-- +-- Run as ADMIN after the HMM access-management schema is installed. +-- This script is idempotent and never stores a plain bearer token. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO OFF +SET FEEDBACK ON +SET DEFINE OFF + +ALTER SESSION DISABLE PARALLEL DML; + +PROMPT === 1. Ensuring HMM roles and demo role assignments === +MERGE INTO hmm_access_roles dst +USING ( + SELECT 1 role_id, 'HMM_HR_VIEWER' role_name, + 'HMM employee self-service leave access' description, + 'PUBLIC' max_sensitivity_level FROM dual + UNION ALL + SELECT 2, 'HMM_HR_MANAGER', + 'HMM manager access to self and direct reports', + 'INTERNAL' FROM dual + UNION ALL + SELECT 3, 'HMM_HR_ADMIN', + 'HMM HR administrator full leave access', + 'CONFIDENTIAL' FROM dual +) src +ON (dst.role_name = src.role_name) +WHEN MATCHED THEN UPDATE SET + dst.description = src.description, + dst.max_sensitivity_level = src.max_sensitivity_level, + dst.active_yn = 'Y', + dst.updated_at = SYSTIMESTAMP +WHEN NOT MATCHED THEN INSERT ( + role_id, role_name, description, max_sensitivity_level, + active_yn, created_at, updated_at +) VALUES ( + src.role_id, src.role_name, src.description, src.max_sensitivity_level, + 'Y', SYSTIMESTAMP, SYSTIMESTAMP +); + +MERGE INTO hmm_employee_access_roles dst +USING ( + SELECT e.employee_id, r.role_id + FROM hmm_hr_employees e + JOIN hmm_access_roles r ON r.role_name = 'HMM_HR_MANAGER' + WHERE e.employee_code = 'E1001' + AND e.employment_status = 'ACTIVE' +) src +ON (dst.employee_id = src.employee_id AND dst.role_id = src.role_id) +WHEN NOT MATCHED THEN INSERT (employee_id, role_id, created_at) +VALUES (src.employee_id, src.role_id, SYSTIMESTAMP); + +MERGE INTO hmm_employee_access_roles dst +USING ( + SELECT e.employee_id, r.role_id + FROM hmm_hr_employees e + JOIN hmm_access_roles r ON r.role_name = 'HMM_HR_VIEWER' + WHERE e.manager_employee_id = ( + SELECT manager.employee_id + FROM hmm_hr_employees manager + WHERE manager.employee_code = 'E1001' + ) + AND e.employment_status = 'ACTIVE' +) src +ON (dst.employee_id = src.employee_id AND dst.role_id = src.role_id) +WHEN NOT MATCHED THEN INSERT (employee_id, role_id, created_at) +VALUES (src.employee_id, src.role_id, SYSTIMESTAMP); + +COMMIT; + +PROMPT === 2. Ensuring protected leave objects and columns === +DECLARE + PROCEDURE ensure_object( + p_object_name IN VARCHAR2, + p_description IN VARCHAR2 + ) AS + v_object_id NUMBER; + BEGIN + BEGIN + SELECT object_id + INTO v_object_id + FROM hmm_access_objects + WHERE owner = 'ADMIN' + AND object_name = p_object_name; + + UPDATE hmm_access_objects + SET enabled_yn = 'Y', + description = p_description, + ords_path = 'cb-ords/cb-object-query/admin/' || LOWER(p_object_name) + WHERE object_id = v_object_id; + EXCEPTION + WHEN NO_DATA_FOUND THEN + SELECT NVL(MAX(object_id), 0) + 1 + INTO v_object_id + FROM hmm_access_objects; + + INSERT INTO hmm_access_objects ( + object_id, owner, object_name, ords_path, enabled_yn, description + ) VALUES ( + v_object_id, + 'ADMIN', + p_object_name, + 'cb-ords/cb-object-query/admin/' || LOWER(p_object_name), + 'Y', + p_description + ); + END; + + FOR c IN ( + SELECT column_name + FROM user_tab_columns + WHERE table_name = p_object_name + ORDER BY column_id + ) LOOP + MERGE INTO hmm_access_object_columns dst + USING ( + SELECT v_object_id object_id, c.column_name column_name + FROM dual + ) src + ON ( + dst.object_id = src.object_id + AND dst.column_name = src.column_name + ) + WHEN NOT MATCHED THEN INSERT ( + column_id, object_id, column_name, sensitive_yn, + sensitivity_level, redaction_method + ) VALUES ( + (SELECT NVL(MAX(column_id), 0) + 1 FROM hmm_access_object_columns), + src.object_id, + src.column_name, + 'N', + 'PUBLIC', + 'NONE' + ); + END LOOP; + END; +BEGIN + ensure_object( + 'HMM_LEAVE_BALANCES', + 'HMM employee leave balance protected by employee hierarchy' + ); + ensure_object( + 'HMM_LEAVE_REQUESTS', + 'HMM employee leave requests protected by employee hierarchy' + ); +END; +/ + +COMMIT; + +PROMPT === 3. Ensuring role permission rules === +DECLARE + PROCEDURE ensure_permission( + p_role_name IN VARCHAR2, + p_target_name IN VARCHAR2, + p_rule_type IN VARCHAR2 + ) AS + v_role_id NUMBER; + v_perm_id NUMBER; + BEGIN + SELECT role_id + INTO v_role_id + FROM hmm_access_roles + WHERE role_name = p_role_name + AND active_yn = 'Y'; + + BEGIN + SELECT perm_id + INTO v_perm_id + FROM hmm_access_permissions + WHERE role_id = v_role_id + AND target_name = p_target_name + AND action_name = 'SELECT'; + + UPDATE hmm_access_permissions + SET permission_effect = 'ALLOW' + WHERE perm_id = v_perm_id; + EXCEPTION + WHEN NO_DATA_FOUND THEN + SELECT cb_permission_seq.NEXTVAL + INTO v_perm_id + FROM dual; + + INSERT INTO hmm_access_permissions ( + perm_id, role_id, target_name, action_name, permission_effect + ) VALUES ( + v_perm_id, v_role_id, p_target_name, 'SELECT', 'ALLOW' + ); + END; + + DELETE FROM hmm_access_permission_rules + WHERE perm_id = v_perm_id; + + INSERT INTO hmm_access_permission_rules ( + rule_id, perm_id, rule_column, rule_type, rule_value + ) VALUES ( + cb_permission_rule_seq.NEXTVAL, + v_perm_id, + 'EMPLOYEE_ID', + p_rule_type, + NULL + ); + END; +BEGIN + ensure_permission('HMM_HR_VIEWER', 'HMM_LEAVE_BALANCES', 'SELF'); + ensure_permission('HMM_HR_VIEWER', 'HMM_LEAVE_REQUESTS', 'SELF'); + ensure_permission('HMM_HR_MANAGER', 'HMM_LEAVE_BALANCES', 'MANAGED_TEAM'); + ensure_permission('HMM_HR_MANAGER', 'HMM_LEAVE_REQUESTS', 'MANAGED_TEAM'); + ensure_permission('HMM_HR_ADMIN', 'HMM_LEAVE_BALANCES', 'ALL'); + ensure_permission('HMM_HR_ADMIN', 'HMM_LEAVE_REQUESTS', 'ALL'); +END; +/ + +COMMIT; + +PROMPT === 4. Creating secure HMM access context === +CREATE OR REPLACE PACKAGE hmm_access_ctx_pkg AUTHID DEFINER AS + PROCEDURE clear_user; + PROCEDURE set_user_by_bearer(p_bearer_token IN VARCHAR2); +END; +/ + +CREATE OR REPLACE PACKAGE BODY hmm_access_ctx_pkg AS + PROCEDURE clear_user AS + BEGIN + DBMS_SESSION.SET_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID', NULL); + DBMS_SESSION.SET_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_CODE', NULL); + DBMS_SESSION.SET_CONTEXT('HMM_ACCESS_CTX', 'TEAM_ID', NULL); + END; + + PROCEDURE set_user_by_bearer(p_bearer_token IN VARCHAR2) AS + v_employee_id hmm_hr_employees.employee_id%TYPE; + v_employee_code hmm_hr_employees.employee_code%TYPE; + v_team_id hmm_hr_employees.team_id%TYPE; + BEGIN + IF p_bearer_token IS NULL OR TRIM(p_bearer_token) IS NULL THEN + clear_user; + RAISE_APPLICATION_ERROR(-20101, 'Bearer token is required'); + END IF; + + SELECT e.employee_id, + e.employee_code, + e.team_id + INTO v_employee_id, + v_employee_code, + v_team_id + FROM hmm_access_bearer_tokens t + JOIN hmm_hr_employees e + ON e.employee_id = t.employee_id + WHERE t.key_hash = STANDARD_HASH(p_bearer_token, 'SHA256') + AND t.revoked_at IS NULL + AND t.expires_at > CAST(SYSTIMESTAMP AS TIMESTAMP) + AND e.employment_status = 'ACTIVE'; + + DBMS_SESSION.SET_CONTEXT( + 'HMM_ACCESS_CTX', 'EMPLOYEE_ID', TO_CHAR(v_employee_id) + ); + DBMS_SESSION.SET_CONTEXT( + 'HMM_ACCESS_CTX', 'EMPLOYEE_CODE', v_employee_code + ); + DBMS_SESSION.SET_CONTEXT( + 'HMM_ACCESS_CTX', 'TEAM_ID', TO_CHAR(v_team_id) + ); + EXCEPTION + WHEN NO_DATA_FOUND THEN + clear_user; + RAISE_APPLICATION_ERROR(-20102, 'Invalid or expired bearer token'); + WHEN TOO_MANY_ROWS THEN + clear_user; + RAISE_APPLICATION_ERROR(-20103, 'Bearer token mapping is not unique'); + END; +END; +/ + +CREATE OR REPLACE CONTEXT hmm_access_ctx USING hmm_access_ctx_pkg; + +PROMPT === 5. Creating the HMM leave VPD filter === +CREATE OR REPLACE FUNCTION hmm_leave_vpd_filter( + p_schema IN VARCHAR2, + p_object IN VARCHAR2 +) RETURN VARCHAR2 +AUTHID DEFINER +AS + v_employee_id NUMBER; + v_target VARCHAR2(128); + v_allow_predicate VARCHAR2(32767); + v_deny_predicate VARCHAR2(32767); + v_permission_clause VARCHAR2(32767); + + FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS + BEGIN + RETURN '''' || REPLACE(NVL(p_value, ''), '''', '''''') || ''''; + END; + + FUNCTION safe_column(p_column_name IN VARCHAR2) RETURN VARCHAR2 IS + v_column VARCHAR2(128); + v_count NUMBER; + BEGIN + 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_schema) + AND table_name = UPPER(p_object) + AND column_name = v_column; + + RETURN CASE WHEN v_count = 1 THEN v_column ELSE NULL END; + EXCEPTION + WHEN OTHERS THEN + RETURN NULL; + END; + + PROCEDURE append_and(p_clause IN VARCHAR2) IS + BEGIN + IF p_clause IS NULL THEN + v_permission_clause := NULL; + ELSIF v_permission_clause IS NULL THEN + v_permission_clause := '(' || p_clause || ')'; + ELSE + v_permission_clause := v_permission_clause || ' AND (' || p_clause || ')'; + END IF; + END; + + PROCEDURE append_permission( + 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_employee_id := TO_NUMBER( + SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID') + ); + EXCEPTION + WHEN OTHERS THEN + RETURN '1 = 0'; + END; + + IF v_employee_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)); + IF v_target NOT IN ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS') THEN + RETURN '1 = 0'; + END IF; + + FOR permission_row IN ( + SELECT DISTINCT + p.perm_id, + NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') permission_effect + FROM ( + SELECT er.role_id + FROM hmm_employee_access_roles er + WHERE er.employee_id = v_employee_id + UNION + SELECT gr.role_id + FROM hmm_access_group_members gm + JOIN hmm_access_groups g + ON g.group_id = gm.group_id + AND g.active_yn = 'Y' + JOIN hmm_group_access_roles gr + ON gr.group_id = gm.group_id + WHERE gm.employee_id = v_employee_id + ) effective_role + JOIN hmm_access_roles role + ON role.role_id = effective_role.role_id + AND role.active_yn = 'Y' + JOIN hmm_access_permissions p + ON p.role_id = role.role_id + AND p.target_name = v_target + AND p.action_name = 'SELECT' + ORDER BY p.perm_id + ) LOOP + v_permission_clause := NULL; + + FOR rule_row IN ( + SELECT UPPER(TRIM(rule_type)) rule_type, + UPPER(TRIM(rule_column)) rule_column, + rule_value + FROM hmm_access_permission_rules + WHERE perm_id = permission_row.perm_id + ORDER BY rule_id + ) LOOP + DECLARE + v_column VARCHAR2(128); + v_clause VARCHAR2(32767); + BEGIN + v_column := safe_column(NVL(rule_row.rule_column, 'EMPLOYEE_ID')); + v_clause := NULL; + + IF rule_row.rule_type = 'ALL' THEN + v_clause := '1 = 1'; + ELSIF rule_row.rule_type = 'SELF' AND v_column IS NOT NULL THEN + v_clause := v_column + || ' = TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID''))'; + ELSIF rule_row.rule_type = 'MANAGED_TEAM' AND v_column IS NOT NULL THEN + v_clause := v_column || ' IN (' + || 'SELECT employee.employee_id ' + || 'FROM ADMIN.HMM_HR_EMPLOYEES employee ' + || 'WHERE employee.employee_id = ' + || 'TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID'')) ' + || 'OR employee.manager_employee_id = ' + || 'TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID''))' + || ')'; + ELSIF rule_row.rule_type = '=' + AND v_column IS NOT NULL + AND rule_row.rule_value IS NOT NULL THEN + v_clause := 'TO_CHAR(' || v_column || ') = ' + || quote_literal(rule_row.rule_value); + ELSIF rule_row.rule_type IN ('!=', '<>') + AND v_column IS NOT NULL + AND rule_row.rule_value IS NOT NULL THEN + v_clause := 'TO_CHAR(' || v_column || ') <> ' + || quote_literal(rule_row.rule_value); + END IF; + + IF v_clause IS NULL THEN + v_permission_clause := NULL; + EXIT; + END IF; + append_and(v_clause); + END; + END LOOP; + + append_permission( + permission_row.permission_effect, + v_permission_clause + ); + END LOOP; + + IF v_allow_predicate IS NULL THEN + RETURN '1 = 0'; + END IF; + + IF v_deny_predicate IS NULL THEN + RETURN v_allow_predicate; + END IF; + + RETURN '((' || v_allow_predicate || ') AND NOT (' + || v_deny_predicate || '))'; +EXCEPTION + WHEN OTHERS THEN + RETURN '1 = 0'; +END; +/ + +SHOW ERRORS PACKAGE hmm_access_ctx_pkg +SHOW ERRORS PACKAGE BODY hmm_access_ctx_pkg +SHOW ERRORS FUNCTION hmm_leave_vpd_filter + +PROMPT === 6. Attaching context-sensitive VPD policies === +DECLARE + PROCEDURE replace_policy(p_object_name IN VARCHAR2) AS + BEGIN + BEGIN + DBMS_RLS.DROP_POLICY( + object_schema => 'ADMIN', + object_name => p_object_name, + policy_name => 'HMM_LEAVE_SCOPE_POLICY' + ); + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -28102 THEN + RAISE; + END IF; + END; + + DBMS_RLS.ADD_POLICY( + object_schema => 'ADMIN', + object_name => p_object_name, + policy_name => 'HMM_LEAVE_SCOPE_POLICY', + function_schema => 'ADMIN', + policy_function => 'HMM_LEAVE_VPD_FILTER', + statement_types => 'SELECT', + update_check => FALSE, + enable => TRUE, + policy_type => DBMS_RLS.CONTEXT_SENSITIVE + ); + END; +BEGIN + replace_policy('HMM_LEAVE_BALANCES'); + replace_policy('HMM_LEAVE_REQUESTS'); +END; +/ + +PROMPT === 7. Recording backoffice descriptions === +MERGE INTO hmm_access_vpd_filter_notes dst +USING ( + SELECT 'ADMIN' function_owner, + 'HMM_LEAVE_VPD_FILTER' function_name, + 'Builds self, managed-team, and administrator leave predicates from HMM roles and access rules.' + description + FROM dual +) src +ON ( + dst.function_owner = src.function_owner + AND dst.function_name = src.function_name +) +WHEN MATCHED THEN UPDATE SET + dst.description = src.description, + dst.updated_at = SYSTIMESTAMP +WHEN NOT MATCHED THEN INSERT ( + function_owner, function_name, description, updated_at +) VALUES ( + src.function_owner, src.function_name, src.description, SYSTIMESTAMP +); + +MERGE INTO hmm_access_vpd_policy_notes dst +USING ( + SELECT 'ADMIN' object_owner, + object_name, + 'HMM_LEAVE_SCOPE_POLICY' policy_name, + 'Restricts leave rows to self, direct reports, or explicit HR administrator access.' + description + FROM ( + SELECT 'HMM_LEAVE_BALANCES' object_name FROM dual + UNION ALL + SELECT 'HMM_LEAVE_REQUESTS' FROM dual + ) +) src +ON ( + dst.object_owner = src.object_owner + AND dst.object_name = src.object_name + AND dst.policy_name = src.policy_name +) +WHEN MATCHED THEN UPDATE SET + dst.description = src.description, + dst.updated_at = SYSTIMESTAMP +WHEN NOT MATCHED THEN INSERT ( + object_owner, object_name, policy_name, description, updated_at +) VALUES ( + src.object_owner, src.object_name, src.policy_name, + src.description, SYSTIMESTAMP +); + +COMMIT; + +PROMPT === 8. Deployment inventory === +SELECT object_name, object_type, status +FROM user_objects +WHERE object_name IN ( + 'HMM_ACCESS_CTX', + 'HMM_ACCESS_CTX_PKG', + 'HMM_LEAVE_VPD_FILTER' +) +ORDER BY object_name, object_type; + +SELECT object_name, policy_name, function, sel, enable, policy_type +FROM all_policies +WHERE object_owner = 'ADMIN' + AND object_name IN ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS') + AND policy_name = 'HMM_LEAVE_SCOPE_POLICY' +ORDER BY object_name; + +SELECT e.employee_code, r.role_name +FROM hmm_employee_access_roles er +JOIN hmm_hr_employees e ON e.employee_id = er.employee_id +JOIN hmm_access_roles r ON r.role_id = er.role_id +WHERE r.role_name IN ('HMM_HR_VIEWER', 'HMM_HR_MANAGER') +ORDER BY e.employee_code, r.role_name; + +SELECT r.role_name, + p.target_name, + pr.rule_column, + pr.rule_type +FROM hmm_access_permissions p +JOIN hmm_access_roles r ON r.role_id = p.role_id +JOIN hmm_access_permission_rules pr ON pr.perm_id = p.perm_id +WHERE p.target_name IN ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS') +ORDER BY r.role_name, p.target_name; + +PROMPT === HMM leave team VPD ready === diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java index 7c9532b..2409b08 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java @@ -67,11 +67,14 @@ public record PermissionView( if (upper.startsWith("STATIC_SQL ")) { return "정적 SQL 조건: " + rawRule.substring("STATIC_SQL ".length()); } - if (upper.contains(" MY_DEPT")) { + if ("MY_DEPT".equals(upper) || upper.contains(" MY_DEPT")) { return "내 부서 행"; } - if (upper.contains(" SELF")) { - return "내 사번/소유자 행"; + if ("MANAGED_TEAM".equals(upper) || upper.contains(" MANAGED_TEAM")) { + return "본인 및 직접 보고 팀원 행"; + } + if ("SELF".equals(upper) || upper.contains(" SELF")) { + return "본인 직원 행"; } if (upper.contains(" TAG ")) { return "태그 조건: " + rawRule; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java index 50b844f..4406ca6 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java @@ -17,6 +17,7 @@ public record VpdFunctionOption( } public boolean permissionSystemDefault() { - return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName); + return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName) + || "HMM_LEAVE_VPD_FILTER".equalsIgnoreCase(functionName); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java index a8b45c2..61407c0 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptions.java @@ -13,9 +13,21 @@ public record VpdPolicyFormOptions( public String defaultPermissionFunctionKey() { return functions.stream() - .filter(function -> "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(function.functionName())) + .filter(VpdFunctionOption::permissionSystemDefault) .findFirst() .map(VpdFunctionOption::value) .orElse(""); } + + public String defaultPermissionFunctionKey(String objectName) { + String preferredFunction = objectName != null + && objectName.toUpperCase(java.util.Locale.ROOT).startsWith("HMM_LEAVE_") + ? "HMM_LEAVE_VPD_FILTER" + : "CB_AGENT_DOC_VPD_FILTER"; + return functions.stream() + .filter(function -> preferredFunction.equalsIgnoreCase(function.functionName())) + .findFirst() + .map(VpdFunctionOption::value) + .orElseGet(this::defaultPermissionFunctionKey); + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java index e872eb7..a4eaba5 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java @@ -26,6 +26,7 @@ public record VpdPolicyView( } public boolean permissionSystemDefault() { - return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName); + return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName) + || "HMM_LEAVE_VPD_FILTER".equalsIgnoreCase(functionName); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index 9f5d324..a3b6603 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -333,18 +333,16 @@ public class BackofficeSchemaService { ); } - addCountCheck(checks, "VPD Runtime", "CB_AGENT_CTX", "CONTEXT", - "SELECT COUNT(*) FROM all_context WHERE namespace = 'CB_AGENT_CTX'", - "sqlcl에서 17_agent_ords_security_local_vpd_setup.sql 또는 CREATE CONTEXT를 실행하세요.", + addCountCheck(checks, "VPD Runtime", "HMM_ACCESS_CTX", "CONTEXT", + "SELECT COUNT(*) FROM all_context WHERE namespace = 'HMM_ACCESS_CTX'", + "sqlcl에서 72_hmm_leave_team_vpd.sql을 실행하세요.", true); - addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_CTX_PKG", "PACKAGE", - "sqlcl에서 cb_agent_ctx_pkg package를 생성/컴파일하세요."); - addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_CTX_PKG BODY", "PACKAGE BODY", - "sqlcl에서 cb_agent_ctx_pkg package body를 생성/컴파일하세요.", "CB_AGENT_CTX_PKG"); - addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_DOC_VPD_FILTER", "FUNCTION", - "sqlcl에서 26_agent_ords_security_dynamic_vpd_filter.sql을 실행하세요."); - addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_CAN_READ_COLUMN", "FUNCTION", - "sqlcl에서 backoffice support redaction function을 생성/컴파일하세요."); + addUserObjectCheck(checks, "VPD Runtime", "HMM_ACCESS_CTX_PKG", "PACKAGE", + "sqlcl에서 72_hmm_leave_team_vpd.sql을 실행하세요."); + addUserObjectCheck(checks, "VPD Runtime", "HMM_ACCESS_CTX_PKG BODY", "PACKAGE BODY", + "sqlcl에서 72_hmm_leave_team_vpd.sql을 실행하세요.", "HMM_ACCESS_CTX_PKG"); + addUserObjectCheck(checks, "VPD Runtime", "HMM_LEAVE_VPD_FILTER", "FUNCTION", + "sqlcl에서 72_hmm_leave_team_vpd.sql을 실행하세요."); addPolicyCheck(checks); addCountCheck(checks, "ORDS Runtime", ORDS_SCHEMA, "USER", @@ -360,8 +358,6 @@ public class BackofficeSchemaService { addAllObjectCheck(checks, "ORDS Runtime", ORDS_SCHEMA, "CB_ORDS_HANDLER_PKG", "PACKAGE", "CB_ORDS로 접속해 22_agent_ords_security_ords_handler_setup.sql을 실행하세요."); - addGrantCheck(checks, currentUser, "CB_AGENT_CTX_PKG", "EXECUTE"); - addGrantCheck(checks, currentUser, "CB_AGENT_CAN_READ_COLUMN", "EXECUTE"); addProtectedObjectGrantCheck(checks); return new SchemaPreflightView(currentUser, ORDS_SCHEMA, checks, appDdlPreview(), sqlclScript(currentUser)); @@ -597,14 +593,14 @@ public class BackofficeSchemaService { SELECT COUNT(*) FROM all_policies WHERE pf_owner = USER - AND function = 'CB_AGENT_DOC_VPD_FILTER' + AND function = 'HMM_LEAVE_VPD_FILTER' """); checks.add(new SchemaPreflightCheck( "VPD Runtime", - "CB_AGENT_DOC_VPD_FILTER 적용 policy", + "HMM_LEAVE_VPD_FILTER 적용 policy", "DBMS_RLS POLICY", count > 0 ? "OK" : "WARN", - count > 0 ? count + "개 policy가 기본 filter를 사용합니다." : "기본 filter가 적용된 TABLE/VIEW가 없습니다.", + count > 0 ? count + "개 HMM 휴가 policy가 기본 filter를 사용합니다." : "HMM 휴가 filter가 적용된 TABLE/VIEW가 없습니다.", count > 0 ? "조치 없음" : "운영 > VPD 설정에서 개별 또는 벌크 적용을 실행하세요.", false )); @@ -640,9 +636,14 @@ public class BackofficeSchemaService { } private void addProtectedObjectGrantCheck(List checks) { - if (!exists("SELECT COUNT(*) FROM user_tables WHERE table_name = 'CB_PROTECTED_OBJECT'")) { + if (!exists(""" + SELECT COUNT(*) + FROM user_objects + WHERE object_name = 'CB_PROTECTED_OBJECT' + AND object_type IN ('TABLE', 'VIEW') + """)) { checks.add(new SchemaPreflightCheck("Grants", "보호 객체 SELECT grants", "GRANT", "WARN", - "CB_PROTECTED_OBJECT 테이블이 없어 등록 보호 객체 grant를 계산할 수 없습니다.", + "CB_PROTECTED_OBJECT 객체가 없어 등록 보호 객체 grant를 계산할 수 없습니다.", "지원 테이블 생성/확인 후 다시 점검하세요.", false)); return; } @@ -706,26 +707,16 @@ public class BackofficeSchemaService { private String sqlclScript(String currentUser) { String owner = currentUser == null || currentUser.isBlank() ? "ADMIN" : currentUser; return """ - -- sqlcl에서 실행할 VPD/ORDS runtime 준비 순서 - -- 1. ADMIN 또는 보호 객체 owner로 접속 - @sql/adb/17_agent_ords_security_local_vpd_setup.sql + -- sqlcl에서 실행할 HMM VPD runtime 준비 순서 + -- 1. ADMIN으로 접속 @sql/adb/25_agent_ords_security_backoffice_support.sql - @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql - @sql/adb/62_kb_aso_masking_backoffice_metadata.sql - @sql/adb/21_agent_ords_security_ords_enable_schema.sql + @sql/adb/72_hmm_leave_team_vpd.sql - -- 2. ORDS parsing schema로 접속 - CONNECT cb_ords/@ - @sql/adb/22_agent_ords_security_ords_handler_setup.sql - - -- 3. 대표 권한 부여 SQL + -- 2. 비면제 업무 runtime 사용자에 필요한 최소 권한 CONNECT %s/@ - GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords; - GRANT SELECT ON . TO cb_ords; - - -- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행 - @sql/adb/64_kb_aso_masking_default_column_rules.sql - @sql/adb/63_kb_aso_masking_rule_runtime.sql + GRANT EXECUTE ON hmm_access_ctx_pkg TO ; + GRANT SELECT ON hmm_leave_balances TO ; + GRANT SELECT ON hmm_leave_requests TO ; """.formatted(owner.toLowerCase()); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java index 3117b3d..4672421 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -23,14 +23,14 @@ import org.springframework.transaction.annotation.Transactional; public class PermissionService { private static final Set RULE_TYPES = Set.of( - "ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", + "ALL", "=", "!=", "MANAGED_TEAM", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL", "TOKEN_SUBJECT", "OWN_CONTRACT", "CHANNEL_CONTRACT", "OWN_CUSTOMER", "CHANNEL_CUSTOMER", "STATIC_SQL"); private static final Set VALUE_REQUIRED_RULE_TYPES = Set.of( "=", "!=", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL", "STATIC_SQL"); private static final Set DEFAULT_COLUMN_RULE_TYPES = Set.of( - "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", "STATIC_SQL"); + "MANAGED_TEAM", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", "STATIC_SQL"); private static final Set STATIC_SQL_KEYWORDS = Set.of( "AND", "OR", "NOT", "NULL", "IS", "IN", "LIKE", "BETWEEN", "ESCAPE", "TRUE", "FALSE", "UPPER", "LOWER", "TRIM", "NVL", "COALESCE", "TO_CHAR", "TO_DATE", "REGEXP_LIKE", "INSTR", diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java index 55b26a4..47aabd5 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java @@ -39,6 +39,8 @@ public class VpdPolicyService { private static final long CATALOG_CACHE_MILLIS = 15 * 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"; + private static final String HMM_LEAVE_POLICY_NAME = "HMM_LEAVE_SCOPE_POLICY"; + private static final String HMM_LEAVE_FILTER_FUNCTION = "HMM_LEAVE_VPD_FILTER"; /* * DBMS_RLS.ADD_POLICY accepts PL/SQL BOOLEAN arguments. Keep all four * permitted flag combinations as fixed statements: database object names @@ -154,8 +156,8 @@ public class VpdPolicyService { @Transactional public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) { String functionName = requiredIdentifier(functionNameValue, "Function name"); - if (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) { - throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION + if (isSystemManagedPermissionFilter(functionName)) { + throw new AppException("기본 동적 권한 필터 " + functionName + "는 이 화면에서 수정할 수 없습니다. 권한체계는 사용자·그룹·역할·행 접근 규칙 화면에서 변경하세요."); } String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class); @@ -180,15 +182,18 @@ public class VpdPolicyService { if (objectParts.length != 2) { throw new AppException("보호할 객체 형식이 올바르지 않습니다: " + objectKey); } - String functionKey = formOptions().defaultPermissionFunctionKey(); + String functionKey = formOptions().defaultPermissionFunctionKey(objectParts[1]); if (functionKey.isBlank()) { - throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION + throw new AppException("대상 객체에 사용할 기본 동적 권한 필터" + "가 설치되어 있지 않습니다. 운영 상태에서 동적 권한 필터 설치 여부를 확인한 뒤 다시 적용하세요."); } + String policyName = isHmmLeaveObject(objectParts[1]) + ? HMM_LEAVE_POLICY_NAME + : COMMON_POLICY_NAME; createPolicy(new VpdPolicyCreateCommand( objectParts[0], objectParts[1], - COMMON_POLICY_NAME, + policyName, functionKey, null, null, @@ -352,8 +357,8 @@ public class VpdPolicyService { description = null; } return description == null || description.isBlank() - ? (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName) - ? "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다." + ? (isSystemManagedPermissionFilter(functionName) + ? defaultPermissionFilterDescription(functionName) : "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다.") : description; } @@ -377,7 +382,7 @@ public class VpdPolicyService { */ public String findFilterPredicate(String owner, String packageName, String functionName) { if (packageName != null && !packageName.isBlank() - || DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) { + || isSystemManagedPermissionFilter(functionName)) { return ""; } try { @@ -493,10 +498,11 @@ public class VpdPolicyService { ) { List templates = new ArrayList<>(); functions.stream() - .filter(function -> DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(function.functionName())) - .findFirst() - .ifPresent(function -> templates.add(new VpdPolicyTemplateOption( - COMMON_POLICY_NAME, + .filter(VpdFunctionOption::permissionSystemDefault) + .forEach(function -> templates.add(new VpdPolicyTemplateOption( + HMM_LEAVE_FILTER_FUNCTION.equalsIgnoreCase(function.functionName()) + ? HMM_LEAVE_POLICY_NAME + : COMMON_POLICY_NAME, function.owner(), function.packageName(), function.functionName(), @@ -746,6 +752,23 @@ public class VpdPolicyService { return formOptions().defaultPermissionFunctionKey(); } + private boolean isSystemManagedPermissionFilter(String functionName) { + return DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName) + || HMM_LEAVE_FILTER_FUNCTION.equalsIgnoreCase(functionName); + } + + private boolean isHmmLeaveObject(String objectName) { + return objectName != null + && objectName.toUpperCase(Locale.ROOT).startsWith("HMM_LEAVE_"); + } + + private String defaultPermissionFilterDescription(String functionName) { + if (HMM_LEAVE_FILTER_FUNCTION.equalsIgnoreCase(functionName)) { + return "HMM 직원 본인·직접 보고 팀원·HR 관리자 역할을 행 조건으로 변환합니다."; + } + return "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다."; + } + private String normalizeStatementTypes(String value) { String raw = value == null || value.isBlank() ? "SELECT" : value; List statements = List.of(raw.split(",")).stream() diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java index 78bb8b9..aa47a45 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java @@ -153,9 +153,13 @@ public class VpdPolicyController { } private static String defaultFilterDescription(String functionName) { - return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName) - ? "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다." - : "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다."; + if ("HMM_LEAVE_VPD_FILTER".equalsIgnoreCase(functionName)) { + return "HMM 직원 본인·직접 보고 팀원·HR 관리자 역할을 행 조건으로 변환합니다."; + } + if ("CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName)) { + return "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다."; + } + return "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다."; } private static long elapsedMillis(long started, long finished) { diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml index 03c65e3..00d4e8f 100644 --- a/src/main/resources/mapper/PermissionMapper.xml +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -61,24 +61,67 @@ @@ -179,12 +174,12 @@
행 규칙의 두 가지 적용 방식 보기 -

조건 코드는 토큰 context로 치환됩니다. 예를 들어 본인 담당 고객 / CUST_ID는 계약원장에서 토큰 사용자 ID의 담당 고객을 찾아 현재 객체의 CUST_ID에 적용합니다. 정적 SQL 조건식CONTRACT_STATUS = '정상'처럼 현재 객체 컬럼을 사용한 WHERE 절을 그대로 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다. 역할명은 최종 WHERE 절에 직접 들어가지 않습니다.

+

표준 범위는 유효한 HMM 토큰에서 만든 직원 context로 치환됩니다. SELF는 현재 직원의 행만, MANAGED_TEAM은 현재 직원과 MANAGER_EMPLOYEE_ID로 연결된 직접 보고자의 행만 남깁니다. 정적 SQL 조건식은 현재 객체 컬럼을 사용한 고정 WHERE 조건을 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다.

컬럼 원문/마스킹은 제외했습니다. 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.

조건 코드가 실제 VPD WHERE 조각으로 바뀌는 방식 -

이 표는 저장 규칙이 CB_AGENT_DOC_VPD_FILTER에서 어떤 predicate로 변환되는지 설명하기 위한 예시입니다. 실제 컬럼 존재 여부와 대상 객체는 DB에서 다시 검증됩니다.

+

이 표는 저장 규칙이 HMM_LEAVE_VPD_FILTER에서 어떤 predicate로 변환되는지 설명하기 위한 예시입니다. 실제 컬럼 존재 여부와 대상 객체는 DB에서 다시 검증됩니다.

@@ -195,29 +190,19 @@ - - - + + + - - - - - - - - - - - - - + + + - +
저장 조건 코드업무 의미VPD filter가 만드는 조건
1 = 1
TOKEN_SUBJECT토큰으로 식별된 이해관계자 본인 행USER_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')SELF토큰으로 식별된 HMM 직원 본인 행EMPLOYEE_ID = SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID')
OWN_CONTRACT토큰 사용자가 담당 설계사인 계약FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')
CHANNEL_CONTRACT토큰 사용자의 채널에 속한 계약FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')
OWN_CUSTOMER / CHANNEL_CUSTOMER계약원장에서 담당자 또는 채널 기준으로 연결되는 고객·청구·담보·외부보유 행EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS ...)MANAGED_TEAM팀장 본인과 직접 보고 팀원의 행EMPLOYEE_ID IN (현재 직원 및 MANAGER_EMPLOYEE_ID가 현재 직원인 직원)
STATIC_SQL 현재 객체 컬럼으로 표현한 고정 조건CONTRACT_STATUS = '정상'처럼 검증된 현재 객체 컬럼 조건REQUEST_STATUS = 'PENDING'처럼 검증된 현재 객체 컬럼 조건
diff --git a/src/main/resources/templates/vpd-filter-runtime.html b/src/main/resources/templates/vpd-filter-runtime.html index d170855..805c99f 100644 --- a/src/main/resources/templates/vpd-filter-runtime.html +++ b/src/main/resources/templates/vpd-filter-runtime.html @@ -9,10 +9,10 @@

행 접근 필터 구조

도움말 -

기본 동적 필터 CB_AGENT_DOC_VPD_FILTER의 연결 상태와 현재 DB에 배포된 함수를 읽기 전용으로 보여줍니다. 행 접근 변경은 행 접근 규칙에서 수행합니다.

+

HMM 기본 동적 필터 HMM_LEAVE_VPD_FILTER의 연결 상태와 현재 DB에 배포된 함수를 읽기 전용으로 보여줍니다. 행 접근 변경은 행 접근 규칙에서 수행합니다.

행 필터(VPD) 적용 흐름

-

Oracle VPD 정책 함수는 Bearer Token을 직접 해석하지 않습니다. 앞 단계의 cb_agent_ctx_pkg.set_user_by_bearer가 만든 CB_AGENT_CTX를 읽어, 현재 SELECT에 붙일 WHERE predicate를 생성합니다.

+

Oracle VPD 정책 함수는 Bearer Token을 직접 해석하지 않습니다. 앞 단계의 hmm_access_ctx_pkg.set_user_by_bearer가 만든 HMM_ACCESS_CTX를 읽어, 현재 SELECT에 붙일 WHERE predicate를 생성합니다.

Bearer 토큰 기반 행 필터 적용 흐름 @@ -29,15 +29,15 @@ Bearer TokenORDS 요청 토큰 검증·해석set_user_by_bearer실패 시 context 정리 - CB_AGENT_CTXUSER_ID · DEPT_CODE · EMP_NOSTAKEHOLDER_USER_ID · CHANNEL - 행 접근 함수 호출CB_AGENT_DOC_VPD_FILTERp_object = 조회 대상 + HMM_ACCESS_CTXEMPLOYEE_ID · CODETEAM_ID + 행 접근 함수 호출HMM_LEAVE_VPD_FILTERp_object = 휴가 원장 WHERE predicateALLOW / DENY 결합원래 SELECT에 자동 추가 - 역할·권한·규칙 메타데이터cb_user_role · cb_group_role · cb_permission_rule - ALLOW 없음 / USER_ID 없음RETURN 1 = 0 · 행 없음 + HMM 역할·권한·규칙employee/group roles · permission rules + ALLOW/직원 context 없음RETURN 1 = 0 · 행 없음 ALLOW 조건 충족조건에 맞는 행만 반환
-

rule_type별 WHERE 조각은 권한 내부에서 AND, ALLOW 권한끼리는 OR, DENY 권한은 최종적으로 AND NOT (...)으로 결합됩니다. ASO는 같은 context의 MR_<column_id>컬럼 표시를 정하고, 행 접근 필터는 USER_ID·DEPT_CODE·EMP_NO·STAKEHOLDER_*보이는 행을 정합니다.

+

rule_type별 WHERE 조각은 권한 내부에서 AND, ALLOW 권한끼리는 OR, DENY 권한은 최종적으로 AND NOT (...)으로 결합됩니다. 행 접근 필터는 EMPLOYEE_ID와 직원의 직접 보고 관계로 보이는 행을 정합니다.

@@ -53,7 +53,7 @@

1. 신뢰 컨텍스트 설정

-

ORDS가 Bearer Token을 검증하고 CB_AGENT_CTX에 사용자·이해관계자·채널 정보를 설정합니다.

+

애플리케이션이 Bearer Token을 검증하고 HMM_ACCESS_CTX에 직원·팀 정보를 설정합니다.

2. 권한과 규칙 계산

@@ -102,10 +102,10 @@ - + - +
점검 항목확인 기준조치 위치
토큰 신뢰 경계Bearer Token은 ORDS에서 검증하고 요청 종료 시 컨텍스트를 정리합니다.ORDS Handler / CB_AGENT_CTX_PKG
토큰 신뢰 경계Bearer Token은 DB 패키지에서 해시·만료·회수·재직 상태를 검증합니다.HMM_ACCESS_CTX_PKG
권한 결합권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.행 접근 규칙
오류·미권한유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.접근 검증
컬럼 보호행 필터(VPD)와 보험료·주민번호 등의 ASO/Data Redaction 마스킹을 분리해 확인합니다.컬럼 마스킹
컬럼 보호행 필터(VPD)와 개인정보 ASO/Data Redaction 마스킹을 분리해 확인합니다.컬럼 마스킹
diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionViewTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionViewTest.java new file mode 100644 index 0000000..511b337 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionViewTest.java @@ -0,0 +1,29 @@ +package com.cloudhandson.vpdbackoffice.domain.permission; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class PermissionViewTest { + + @Test + void translatesHmmLeaveScopeRulesForBackofficeUsers() { + PermissionView view = new PermissionView( + 1L, + 2L, + "HMM_HR_MANAGER", + 10L, + "HMM_LEAVE_REQUESTS", + "SELECT", + "ALLOW", + "EMPLOYEE_ID MANAGED_TEAM,EMPLOYEE_ID SELF", + null, + null, + null + ); + + assertThat(view.businessRuleSummary()) + .contains("본인 및 직접 보고 팀원 행") + .contains("본인 직원 행"); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptionsTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptionsTest.java new file mode 100644 index 0000000..91c31cb --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyFormOptionsTest.java @@ -0,0 +1,31 @@ +package com.cloudhandson.vpdbackoffice.domain.vpd; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class VpdPolicyFormOptionsTest { + + @Test + void choosesHmmFilterForHmmLeaveObjects() { + VpdPolicyFormOptions options = new VpdPolicyFormOptions( + List.of(), + List.of(), + List.of(), + List.of( + new VpdFunctionOption("ADMIN", null, "CB_AGENT_DOC_VPD_FILTER", "FUNCTION"), + new VpdFunctionOption("ADMIN", null, "HMM_LEAVE_VPD_FILTER", "FUNCTION") + ), + List.of(), + List.of("SELECT") + ); + + assertThat(options.defaultPermissionFunctionKey("HMM_LEAVE_BALANCES")) + .isEqualTo("ADMIN.HMM_LEAVE_VPD_FILTER"); + assertThat(options.defaultPermissionFunctionKey("HMM_LEAVE_REQUESTS")) + .isEqualTo("ADMIN.HMM_LEAVE_VPD_FILTER"); + assertThat(options.defaultPermissionFunctionKey("CB_AGENT_DOCUMENTS")) + .isEqualTo("ADMIN.CB_AGENT_DOC_VPD_FILTER"); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index 43b9913..074d021 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -184,8 +184,10 @@ class GuidedFlowTemplateTest { String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js")); assertThat(html) - .contains("value=\"OWN_CUSTOMER\">본인 담당 고객") + .contains("value=\"MANAGED_TEAM\">본인 및 직접 보고 팀원") + .contains("value=\"SELF\">본인 직원 행") .contains("value=\"STATIC_SQL\">정적 SQL 조건식") + .contains("HMM_LEAVE_VPD_FILTER") .contains("행 규칙의 두 가지 적용 방식 보기") .contains("한 권한 안의 규칙은 모두 AND") .contains("서로 다른 역할의 ALLOW 권한은 OR")