refs #707: enforce HMM leave team VPD

This commit is contained in:
devmrko
2026-07-23 13:36:21 +09:00
parent a495e7a962
commit da80041bcb
18 changed files with 1034 additions and 125 deletions

View File

@@ -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`를 비활성화하거나 제거한 뒤 사용자 역할·권한 메타데이터를
유지하는 방식으로 수행한다. 휴가 원장 데이터는 수정하거나 삭제하지 않는다.

View File

@@ -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 관리자 역할은 전체 접근 규칙만 준비하고 데모 사용자에게 자동 부여하지 않았다.

View File

@@ -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 ===

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<SchemaPreflightCheck> 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/<password>@<tns_alias>
@sql/adb/22_agent_ords_security_ords_handler_setup.sql
-- 3. 대표 권한 부여 SQL
-- 2. 비면제 업무 runtime 사용자에 필요한 최소 권한
CONNECT %s/<password>@<tns_alias>
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
GRANT SELECT ON <owner>.<table_or_view> 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 <hmm_runtime_user>;
GRANT SELECT ON hmm_leave_balances TO <hmm_runtime_user>;
GRANT SELECT ON hmm_leave_requests TO <hmm_runtime_user>;
""".formatted(owner.toLowerCase());
}

View File

@@ -23,14 +23,14 @@ import org.springframework.transaction.annotation.Transactional;
public class PermissionService {
private static final Set<String> 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<String> VALUE_REQUIRED_RULE_TYPES = Set.of(
"=", "!=", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL", "STATIC_SQL");
private static final Set<String> 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<String> 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",

View File

@@ -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<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,
.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<String> statements = List.of(raw.split(",")).stream()

View File

@@ -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) {

View File

@@ -61,24 +61,67 @@
<select id="countPermissionsByRoleId" resultType="int">
SELECT COUNT(*)
FROM dual
WHERE 1 = 0
FROM cb_permission
WHERE role_id = #{roleId,jdbcType=NUMERIC}
</select>
<select id="findPermissionViews" resultType="com.cloudhandson.vpdbackoffice.domain.permission.PermissionView">
SELECT CAST(NULL AS NUMBER) AS permission_id,
CAST(NULL AS NUMBER) AS role_id,
CAST(NULL AS VARCHAR2(80)) AS role_name,
CAST(NULL AS NUMBER) AS object_id,
CAST(NULL AS VARCHAR2(200)) AS object_name,
CAST(NULL AS VARCHAR2(30)) AS action,
CAST(NULL AS VARCHAR2(20)) AS permission_effect,
CAST(NULL AS VARCHAR2(4000)) AS rules,
CAST(NULL AS VARCHAR2(4000)) AS visible_columns,
CAST(NULL AS VARCHAR2(4000)) AS filter_preview,
CAST(NULL AS VARCHAR2(4000)) AS null_policy_preview
FROM dual
WHERE 1 = 0
SELECT p.perm_id AS permission_id,
r.role_id,
r.role_name,
NVL(o.object_id, -1) AS 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 ||
NVL2(pr.rule_value, ' ' || pr.rule_value, ''),
', '
) WITHIN GROUP (ORDER BY pr.rule_id) AS rules,
(
SELECT LISTAGG(pc.column_name, ', ')
WITHIN GROUP (ORDER BY pc.column_name)
FROM cb_permission_column pc
WHERE pc.permission_id = p.perm_id
) AS visible_columns,
(
SELECT LISTAGG(
CASE
WHEN pr2.rule_type = 'ALL' THEN '1 = 1'
WHEN pr2.rule_type = 'MANAGED_TEAM' THEN
NVL(pr2.rule_column, 'EMPLOYEE_ID')
|| ' IN (current employee and direct reports)'
WHEN pr2.rule_type = 'SELF' THEN
NVL(pr2.rule_column, 'EMPLOYEE_ID')
|| ' = SYS_CONTEXT(HMM_ACCESS_CTX.EMPLOYEE_ID)'
WHEN pr2.rule_type = 'STATIC_SQL' THEN pr2.rule_value
ELSE NVL2(pr2.rule_column, pr2.rule_column || ' ', '')
|| pr2.rule_type
|| NVL2(pr2.rule_value, ' ' || pr2.rule_value, '')
END,
CHR(10) || 'AND '
) WITHIN GROUP (ORDER BY pr2.rule_id)
FROM cb_permission_rule pr2
WHERE pr2.perm_id = p.perm_id
) AS filter_preview,
'컬럼 원문/마스킹은 컬럼 마스킹에서 관리' AS null_policy_preview
FROM cb_permission p
JOIN hmm_access_roles 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,
p.permission_effect
ORDER BY r.role_name, p.target_name
</select>
<select id="findPermissionId" resultType="long">

View File

@@ -396,9 +396,10 @@ function syncRuleTypeHints(root = document) {
const type = typeSelect.value;
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL', 'STATIC_SQL'].includes(type);
const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type);
const columnOptional = ['ALL', 'MANAGED_TEAM', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type);
const placeholderByType = {
ALL: '값 불필요',
MANAGED_TEAM: '값 불필요',
MY_DEPT: '값 불필요',
SELF: '값 불필요',
DEPT: '예: HR',
@@ -417,10 +418,10 @@ function syncRuleTypeHints(root = document) {
};
columnSelect.required = !columnOptional;
columnSelect.disabled = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type);
columnSelect.disabled = ['ALL', 'MANAGED_TEAM', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type);
valueInput.required = valueRequired;
valueInput.disabled = [
'ALL', 'MY_DEPT', 'SELF', 'TOKEN_SUBJECT', 'OWN_CONTRACT',
'ALL', 'MANAGED_TEAM', 'MY_DEPT', 'SELF', 'TOKEN_SUBJECT', 'OWN_CONTRACT',
'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'
].includes(type);
valueInput.placeholder = placeholderByType[type] || '비교 값';
@@ -708,8 +709,9 @@ function sqlLiteral(value) {
function permissionRuleBusinessLabel(type, column, value) {
const displayColumn = column || {
MANAGED_TEAM: 'EMPLOYEE_ID',
MY_DEPT: 'DEPT_CODE',
SELF: 'OWNER_EMP_NO',
SELF: 'EMPLOYEE_ID',
DEPT: 'DEPT_CODE',
EMP_NO: 'OWNER_EMP_NO',
TAG: 'TECH_TAG',
@@ -724,6 +726,9 @@ function permissionRuleBusinessLabel(type, column, value) {
if (type === 'ALL') {
return '전체 행';
}
if (type === 'MANAGED_TEAM') {
return `본인 및 직접 보고 팀원 행 (${displayColumn})`;
}
if (type === 'TOKEN_SUBJECT') {
return `토큰 이해관계자 본인 행 (${displayColumn})`;
}
@@ -746,7 +751,7 @@ function permissionRuleBusinessLabel(type, column, value) {
return `내 부서 행 (${displayColumn})`;
}
if (type === 'SELF') {
return `내 사번/소유자 행 (${displayColumn})`;
return `본인 직원 행 (${displayColumn})`;
}
if (type === 'TAG') {
return `태그 조건 ${displayColumn} contains ${value || '-'}`;
@@ -760,8 +765,9 @@ function collectWizardRules(root) {
const type = row.querySelector('[name="ruleType"]')?.value || '';
const value = row.querySelector('[name="ruleValue"]')?.value || '';
const displayColumn = column || {
MANAGED_TEAM: 'EMPLOYEE_ID',
MY_DEPT: 'DEPT_CODE',
SELF: 'OWNER_EMP_NO',
SELF: 'EMPLOYEE_ID',
DEPT: 'DEPT_CODE',
EMP_NO: 'OWNER_EMP_NO',
TAG: 'TECH_TAG',
@@ -785,8 +791,9 @@ function collectWizardPredicates(root) {
const type = row.querySelector('[name="ruleType"]')?.value || '';
const value = row.querySelector('[name="ruleValue"]')?.value || '';
const displayColumn = column || {
MANAGED_TEAM: 'EMPLOYEE_ID',
MY_DEPT: 'DEPT_CODE',
SELF: 'OWNER_EMP_NO',
SELF: 'EMPLOYEE_ID',
DEPT: 'DEPT_CODE',
EMP_NO: 'OWNER_EMP_NO',
TAG: 'TECH_TAG',
@@ -803,6 +810,9 @@ function collectWizardPredicates(root) {
if (type === 'ALL') {
return '1 = 1';
}
if (type === 'MANAGED_TEAM') {
return `${displayColumn} IN (현재 직원 및 직접 보고 팀원)`;
}
if (type === 'STATIC_SQL') {
return value;
}
@@ -810,7 +820,7 @@ function collectWizardPredicates(root) {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')`;
}
if (type === 'SELF') {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`;
return `${displayColumn} = SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID')`;
}
if (type === 'STAKEHOLDER_SELF') {
return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_ID = TOKEN_STAKEHOLDER_ID)`;

View File

@@ -8,8 +8,8 @@
<h1>행 접근 규칙</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. 저장값은 최종 SQL이 아니라 <code>CB_AGENT_DOC_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.</p>
<p>컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 주민번호, 보험료, 청구금액 같은 컬럼 표시는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 관리합니다.</p>
<p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. HMM 휴가 원장의 저장값은 <code>HMM_LEAVE_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바니다.</p>
<p>팀장은 본인과 직속 팀원, 팀원은 본인 행만 조회하도록 설정합니다. 컬럼 원문/마스킹은 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 별도로 관리합니다.</p>
</details>
</div>
@@ -157,19 +157,14 @@
<option value="">객체 컬럼 선택</option>
</select>
<select class="form-select rule-type-select" name="ruleType">
<option value="ALL">ALL</option>
<optgroup label="KB · 본인 범위">
<option value="OWN_CONTRACT">본인 담당 계약</option>
<option value="OWN_CUSTOMER">본인 담당 고객</option>
<optgroup label="HMM HR · 표준 범위">
<option value="SELF">본인 직원 행</option>
<option value="MANAGED_TEAM">본인 및 직접 보고 팀원</option>
<option value="ALL">전체 행</option>
</optgroup>
<optgroup label="KB · 채널 범위">
<option value="CHANNEL_CONTRACT">토큰 채널 계약</option>
<option value="CHANNEL_CUSTOMER">토큰 채널 고객</option>
</optgroup>
<optgroup label="KB · 토큰 식별">
<option value="TOKEN_SUBJECT">토큰 사용자 본인</option>
</optgroup>
<optgroup label="SQL · 정적 조건">
<optgroup label="고급 조건">
<option value="=">선택 컬럼 값 일치</option>
<option value="!=">선택 컬럼 값 불일치</option>
<option value="STATIC_SQL">정적 SQL 조건식</option>
</optgroup>
</select>
@@ -179,12 +174,12 @@
</div>
<details class="explanation-details">
<summary>행 규칙의 두 가지 적용 방식 보기</summary>
<p class="wizard-hint"><strong>조건 코드</strong>토큰 context로 치환됩니다. 예를 들어 <code>본인 담당 고객 / CUST_ID</code>는 계약원장에서 토큰 사용자 ID의 담당 고객을 찾아 현재 객체의 <code>CUST_ID</code>에 적용합니다. <strong>정적 SQL 조건식</strong> <code>CONTRACT_STATUS = '정상'</code>처럼 현재 객체 컬럼을 사용한 WHERE 절을 그대로 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다. 역할명은 최종 WHERE 절에 직접 들어가지 않습니다.</p>
<p class="wizard-hint"><strong>표준 범위</strong>유효한 HMM 토큰에서 만든 직원 context로 치환됩니다. <code>SELF</code>는 현재 직원의 행만, <code>MANAGED_TEAM</code> 현재 직원과 <code>MANAGER_EMPLOYEE_ID</code>로 연결된 직접 보고자의 행만 남깁니다. <strong>정적 SQL 조건식</strong>은 현재 객체 컬럼을 사용한 고정 WHERE 조건을 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다.</p>
<p class="wizard-hint"><strong>컬럼 원문/마스킹은 제외했습니다.</strong> 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.</p>
</details>
<details class="explanation-details">
<summary>조건 코드가 실제 VPD WHERE 조각으로 바뀌는 방식</summary>
<p class="wizard-hint">이 표는 저장 규칙이 <code>CB_AGENT_DOC_VPD_FILTER</code>에서 어떤 predicate로 변환되는지 설명하기 위한 예시입니다. 실제 컬럼 존재 여부와 대상 객체는 DB에서 다시 검증됩니다.</p>
<p class="wizard-hint">이 표는 저장 규칙이 <code>HMM_LEAVE_VPD_FILTER</code>에서 어떤 predicate로 변환되는지 설명하기 위한 예시입니다. 실제 컬럼 존재 여부와 대상 객체는 DB에서 다시 검증됩니다.</p>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>저장 조건 코드</th><th>업무 의미</th><th>VPD filter가 만드는 조건</th></tr></thead>
@@ -195,29 +190,19 @@
<td><code>1 = 1</code></td>
</tr>
<tr>
<td><code>TOKEN_SUBJECT</code></td>
<td>토큰으로 식별된 이해관계자 본인 행</td>
<td><code>USER_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td>
<td><code>SELF</code></td>
<td>토큰으로 식별된 HMM 직원 본인 행</td>
<td><code>EMPLOYEE_ID = SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID')</code></td>
</tr>
<tr>
<td><code>OWN_CONTRACT</code></td>
<td>토큰 사용자가 담당 설계사인 계약</td>
<td><code>FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td>
</tr>
<tr>
<td><code>CHANNEL_CONTRACT</code></td>
<td>토큰 사용자의 채널에 속한 계약</td>
<td><code>FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')</code></td>
</tr>
<tr>
<td><code>OWN_CUSTOMER</code> / <code>CHANNEL_CUSTOMER</code></td>
<td>계약원장에서 담당자 또는 채널 기준으로 연결되는 고객·청구·담보·외부보유 행</td>
<td><code>EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS ...)</code></td>
<td><code>MANAGED_TEAM</code></td>
<td>팀장 본인과 직접 보고 팀원의 행</td>
<td><code>EMPLOYEE_ID IN (현재 직원 및 MANAGER_EMPLOYEE_ID가 현재 직원인 직원)</code></td>
</tr>
<tr>
<td><code>STATIC_SQL</code></td>
<td>현재 객체 컬럼으로 표현한 고정 조건</td>
<td><code>CONTRACT_STATUS = '정상'</code>처럼 검증된 현재 객체 컬럼 조건</td>
<td><code>REQUEST_STATUS = 'PENDING'</code>처럼 검증된 현재 객체 컬럼 조건</td>
</tr>
</tbody>
</table>

View File

@@ -9,10 +9,10 @@
<h1 class="mt-2">행 접근 필터 구조</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>기본 동적 필터 <code>CB_AGENT_DOC_VPD_FILTER</code>의 연결 상태와 현재 DB에 배포된 함수를 읽기 전용으로 보여줍니다. 행 접근 변경은 <a href="/permissions">행 접근 규칙</a>에서 수행합니다.</p>
<p>HMM 기본 동적 필터 <code>HMM_LEAVE_VPD_FILTER</code>의 연결 상태와 현재 DB에 배포된 함수를 읽기 전용으로 보여줍니다. 행 접근 변경은 <a href="/permissions">행 접근 규칙</a>에서 수행합니다.</p>
<section class="content-band mt-3 mb-0">
<h2>행 필터(VPD) 적용 흐름</h2>
<p><strong>Oracle VPD 정책 함수는 Bearer Token을 직접 해석하지 않습니다.</strong> 앞 단계의 <code>cb_agent_ctx_pkg.set_user_by_bearer</code>가 만든 <code>CB_AGENT_CTX</code>를 읽어, 현재 SELECT에 붙일 WHERE predicate를 생성합니다.</p>
<p><strong>Oracle VPD 정책 함수는 Bearer Token을 직접 해석하지 않습니다.</strong> 앞 단계의 <code>hmm_access_ctx_pkg.set_user_by_bearer</code>가 만든 <code>HMM_ACCESS_CTX</code>를 읽어, 현재 SELECT에 붙일 WHERE predicate를 생성합니다.</p>
<div class="diagram-canvas">
<svg class="product-flow-diagram" viewBox="0 0 1120 410" role="img" aria-labelledby="vpd-help-flow-title vpd-help-flow-desc">
<title id="vpd-help-flow-title">Bearer 토큰 기반 행 필터 적용 흐름</title>
@@ -29,15 +29,15 @@
<path class="diagram-link" marker-end="url(#flow-arrow)" d="M900 136 V280 H755 V315"/>
<g class="diagram-node"><rect x="25" y="48" width="160" height="88" rx="12"/><text x="105" y="79" class="diagram-node-title">Bearer Token</text><text x="105" y="106" class="diagram-node-detail">ORDS 요청</text></g>
<g class="diagram-node"><rect x="225" y="48" width="180" height="88" rx="12"/><text x="315" y="76" class="diagram-node-title">토큰 검증·해석</text><text x="315" y="103" class="diagram-node-detail">set_user_by_bearer</text><text x="315" y="120" class="diagram-node-note">실패 시 context 정리</text></g>
<g class="diagram-node"><rect x="445" y="48" width="180" height="88" rx="12"/><text x="535" y="76" class="diagram-node-title">CB_AGENT_CTX</text><text x="535" y="103" class="diagram-node-detail">USER_ID · DEPT_CODE · EMP_NO</text><text x="535" y="120" class="diagram-node-note">STAKEHOLDER_USER_ID · CHANNEL</text></g>
<g class="diagram-node diagram-node-accent"><rect x="665" y="48" width="180" height="88" rx="12"/><text x="755" y="76" class="diagram-node-title">행 접근 함수 호출</text><text x="755" y="103" class="diagram-node-detail">CB_AGENT_DOC_VPD_FILTER</text><text x="755" y="120" class="diagram-node-note">p_object = 조회 대상</text></g>
<g class="diagram-node"><rect x="445" y="48" width="180" height="88" rx="12"/><text x="535" y="76" class="diagram-node-title">HMM_ACCESS_CTX</text><text x="535" y="103" class="diagram-node-detail">EMPLOYEE_ID · CODE</text><text x="535" y="120" class="diagram-node-note">TEAM_ID</text></g>
<g class="diagram-node diagram-node-accent"><rect x="665" y="48" width="180" height="88" rx="12"/><text x="755" y="76" class="diagram-node-title">행 접근 함수 호출</text><text x="755" y="103" class="diagram-node-detail">HMM_LEAVE_VPD_FILTER</text><text x="755" y="120" class="diagram-node-note">p_object = 휴가 원장</text></g>
<g class="diagram-node diagram-node-accent"><rect x="885" y="48" width="185" height="88" rx="12"/><text x="977" y="76" class="diagram-node-title">WHERE predicate</text><text x="977" y="103" class="diagram-node-detail">ALLOW / DENY 결합</text><text x="977" y="120" class="diagram-node-note">원래 SELECT에 자동 추가</text></g>
<g class="diagram-node diagram-node-data"><rect x="590" y="212" width="310" height="72" rx="12"/><text x="745" y="239" class="diagram-node-title">역할·권한·규칙 메타데이터</text><text x="745" y="263" class="diagram-node-detail">cb_user_role · cb_group_role · cb_permission_rule</text></g>
<g class="diagram-node"><rect x="665" y="315" width="180" height="64" rx="12"/><text x="755" y="341" class="diagram-node-title">ALLOW 없음 / USER_ID 없음</text><text x="755" y="363" class="diagram-node-detail">RETURN 1 = 0 · 행 없음</text></g>
<g class="diagram-node diagram-node-data"><rect x="590" y="212" width="310" height="72" rx="12"/><text x="745" y="239" class="diagram-node-title">HMM 역할·권한·규칙</text><text x="745" y="263" class="diagram-node-detail">employee/group roles · permission rules</text></g>
<g class="diagram-node"><rect x="665" y="315" width="180" height="64" rx="12"/><text x="755" y="341" class="diagram-node-title">ALLOW/직원 context 없음</text><text x="755" y="363" class="diagram-node-detail">RETURN 1 = 0 · 행 없음</text></g>
<g class="diagram-node diagram-node-data"><rect x="885" y="315" width="185" height="64" rx="12"/><text x="977" y="341" class="diagram-node-title">ALLOW 조건 충족</text><text x="977" y="363" class="diagram-node-detail">조건에 맞는 행만 반환</text></g>
</svg>
</div>
<p class="form-hint mb-0"><code>rule_type</code>별 WHERE 조각은 권한 내부에서 AND, ALLOW 권한끼리는 OR, DENY 권한은 최종적으로 <code>AND NOT (...)</code>으로 결합됩니다. ASO는 같은 context의 <code>MR_&lt;column_id&gt;</code><em>컬럼 표시</em>를 정하고, 행 접근 필터는 <code>USER_ID</code>·<code>DEPT_CODE</code>·<code>EMP_NO</code>·<code>STAKEHOLDER_*</code><em>보이는 행</em>을 정합니다.</p>
<p class="form-hint mb-0"><code>rule_type</code>별 WHERE 조각은 권한 내부에서 AND, ALLOW 권한끼리는 OR, DENY 권한은 최종적으로 <code>AND NOT (...)</code>으로 결합됩니다. 행 접근 필터는 <code>EMPLOYEE_ID</code>와 직원의 직접 보고 관계<em>보이는 행</em>을 정합니다.</p>
</section>
</details>
</section>
@@ -53,7 +53,7 @@
<div class="macro-micro-grid">
<div>
<h3>1. 신뢰 컨텍스트 설정</h3>
<p>ORDS가 Bearer Token을 검증하고 <code>CB_AGENT_CTX</code>사용자·이해관계자·채널 정보를 설정합니다.</p>
<p>애플리케이션이 Bearer Token을 검증하고 <code>HMM_ACCESS_CTX</code>직원·팀 정보를 설정합니다.</p>
</div>
<div>
<h3>2. 권한과 규칙 계산</h3>
@@ -102,10 +102,10 @@
<table class="table table-sm align-middle">
<thead><tr><th>점검 항목</th><th>확인 기준</th><th>조치 위치</th></tr></thead>
<tbody>
<tr><td>토큰 신뢰 경계</td><td>Bearer Token은 ORDS에서 검증하고 요청 종료 시 컨텍스트를 정리합니다.</td><td>ORDS Handler / <code>CB_AGENT_CTX_PKG</code></td></tr>
<tr><td>토큰 신뢰 경계</td><td>Bearer Token은 DB 패키지에서 해시·만료·회수·재직 상태를 검증합니다.</td><td><code>HMM_ACCESS_CTX_PKG</code></td></tr>
<tr><td>권한 결합</td><td>권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.</td><td><a href="/permissions">행 접근 규칙</a></td></tr>
<tr><td>오류·미권한</td><td>유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.</td><td><a href="/probe">접근 검증</a></td></tr>
<tr><td>컬럼 보호</td><td>행 필터(VPD)와 보험료·주민번호 등의 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr>
<tr><td>컬럼 보호</td><td>행 필터(VPD)와 개인정보 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr>
</tbody>
</table>
</div>

View File

@@ -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("본인 직원 행");
}
}

View File

@@ -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");
}
}

View File

@@ -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")