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

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