[Developer] #619 focus POC on KB VPD permissions
This commit is contained in:
@@ -13,6 +13,8 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -22,11 +24,21 @@ public class PermissionService {
|
||||
|
||||
private static final Set<String> RULE_TYPES = Set.of(
|
||||
"ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG",
|
||||
"STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL");
|
||||
"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");
|
||||
"=", "!=", "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");
|
||||
"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",
|
||||
"LENGTH", "SUBSTR", "REPLACE", "CAST", "AS", "DATE", "TIMESTAMP", "NUMBER", "VARCHAR2", "CHAR");
|
||||
private static final Pattern SQL_STRING_LITERAL = Pattern.compile("'(?:''|[^'])*'");
|
||||
private static final Pattern SQL_IDENTIFIER = Pattern.compile("[A-Z_$#][A-Z0-9_$#]*");
|
||||
private static final Pattern SQL_QUALIFIED_IDENTIFIER =
|
||||
Pattern.compile("[A-Z_$#][A-Z0-9_$#]*\\s*\\.", Pattern.CASE_INSENSITIVE);
|
||||
private static final Set<String> PERMISSION_EFFECTS = Set.of("ALLOW", "DENY");
|
||||
private static final Set<String> SENSITIVITY_LEVELS = Set.of(
|
||||
"PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED");
|
||||
@@ -225,12 +237,18 @@ public class PermissionService {
|
||||
if ("TAG".equals(type) && column == null && !allowedColumns.contains("TECH_TAG")) {
|
||||
throw new AppException("TAG 규칙의 기본 컬럼 TECH_TAG가 보호 객체에 없습니다. 컬럼을 지정하세요.");
|
||||
}
|
||||
if ("STATIC_SQL".equals(type) && column != null) {
|
||||
throw new AppException("정적 SQL 조건은 컬럼 선택 없이 조건식 전체를 입력하세요.");
|
||||
}
|
||||
if (VALUE_REQUIRED_RULE_TYPES.contains(type) && ruleValue.isBlank()) {
|
||||
throw new AppException(type + " 규칙에는 값이 필요합니다.");
|
||||
}
|
||||
if ("TAG".equals(type) && !ruleValue.matches("[A-Z0-9_-]+")) {
|
||||
throw new AppException("TAG 값은 영문 대문자, 숫자, '_' 또는 '-'만 사용할 수 있습니다.");
|
||||
}
|
||||
if ("STATIC_SQL".equals(type)) {
|
||||
validateStaticSqlPredicate(ruleValue, allowedColumns);
|
||||
}
|
||||
}
|
||||
if (hasAll && rules.size() > 1) {
|
||||
throw new AppException("ALL 규칙은 다른 규칙과 함께 저장할 수 없습니다.");
|
||||
@@ -245,6 +263,41 @@ public class PermissionService {
|
||||
return allowed;
|
||||
}
|
||||
|
||||
private void validateStaticSqlPredicate(String predicate, Set<String> allowedColumns) {
|
||||
if (predicate.length() > 1000) {
|
||||
throw new AppException("정적 SQL 조건은 1,000자 이하여야 합니다.");
|
||||
}
|
||||
if (predicate.chars().filter(value -> value == '\'').count() % 2 != 0) {
|
||||
throw new AppException("정적 SQL 조건의 문자열 따옴표가 닫히지 않았습니다.");
|
||||
}
|
||||
if (predicate.indexOf(';') >= 0 || predicate.contains("--") || predicate.contains("/*")
|
||||
|| predicate.contains("*/") || predicate.indexOf(':') >= 0 || predicate.indexOf('@') >= 0
|
||||
|| predicate.contains("||") || predicate.chars().anyMatch(Character::isISOControl)) {
|
||||
throw new AppException("정적 SQL 조건에는 단일 WHERE 조건식만 입력할 수 있습니다.");
|
||||
}
|
||||
|
||||
String lexical = SQL_STRING_LITERAL.matcher(predicate).replaceAll(" ");
|
||||
if (SQL_QUALIFIED_IDENTIFIER.matcher(lexical).find()) {
|
||||
throw new AppException("정적 SQL 조건에는 다른 테이블·스키마를 참조할 수 없습니다.");
|
||||
}
|
||||
|
||||
boolean hasObjectColumn = false;
|
||||
Matcher matcher = SQL_IDENTIFIER.matcher(lexical.toUpperCase(Locale.ROOT));
|
||||
while (matcher.find()) {
|
||||
String token = matcher.group();
|
||||
if (STATIC_SQL_KEYWORDS.contains(token)) {
|
||||
continue;
|
||||
}
|
||||
if (!allowedColumns.contains(token)) {
|
||||
throw new AppException("정적 SQL 조건에는 보호 객체 컬럼과 허용된 SQL 연산자만 사용할 수 있습니다: " + token);
|
||||
}
|
||||
hasObjectColumn = true;
|
||||
}
|
||||
if (!hasObjectColumn) {
|
||||
throw new AppException("정적 SQL 조건에는 보호 객체 컬럼이 하나 이상 필요합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateVisibleColumns(long objectId, List<String> visibleColumns) {
|
||||
if (visibleColumns == null || visibleColumns.isEmpty()) {
|
||||
return;
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
SELECT p.perm_id AS permission_id,
|
||||
r.role_id,
|
||||
r.role_name,
|
||||
o.object_id,
|
||||
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,
|
||||
@@ -93,27 +93,24 @@
|
||||
WHEN pr2.rule_type = 'TAG' THEN 'REGEXP_LIKE(UPPER('
|
||||
|| NVL(pr2.rule_column, 'TECH_TAG')
|
||||
|| '), ''(^|,)' || REPLACE(UPPER(pr2.rule_value), '''', '''''') || '(,|$)'')'
|
||||
WHEN pr2.rule_type = 'STAKEHOLDER_SELF' AND pr2.rule_column = 'FC_ID' THEN
|
||||
'(SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_ROLE'') = ''' || NVL(pr2.rule_value, '[역할값 필요]') || ''' '
|
||||
|| 'AND FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID''))'
|
||||
WHEN pr2.rule_type = 'STAKEHOLDER_SELF' AND pr2.rule_column = 'USER_ID' THEN
|
||||
'(USER_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID''))'
|
||||
WHEN pr2.rule_type = 'STAKEHOLDER_CHANNEL' AND pr2.rule_column = 'FC_CHANNEL' THEN
|
||||
'(SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_ROLE'') = ''' || NVL(pr2.rule_value, '[역할값 필요]') || ''' '
|
||||
|| 'AND FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL''))'
|
||||
WHEN pr2.rule_type = 'STAKEHOLDER_SELF' THEN
|
||||
WHEN pr2.rule_type = 'TOKEN_SUBJECT' THEN
|
||||
pr2.rule_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
|
||||
WHEN pr2.rule_type = 'OWN_CONTRACT' THEN
|
||||
pr2.rule_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
|
||||
WHEN pr2.rule_type = 'CHANNEL_CONTRACT' THEN
|
||||
pr2.rule_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')'
|
||||
WHEN pr2.rule_type = 'OWN_CUSTOMER' THEN
|
||||
'EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS c '
|
||||
|| 'WHERE c.' || pr2.rule_column || ' = [CURRENT_ROW].' || pr2.rule_column || ' '
|
||||
|| 'AND SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_ROLE'') = ''' || NVL(pr2.rule_value, '[역할값 필요]') || ''' '
|
||||
|| 'AND c.FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID''))'
|
||||
WHEN pr2.rule_type = 'STAKEHOLDER_CHANNEL' THEN
|
||||
WHEN pr2.rule_type = 'CHANNEL_CUSTOMER' THEN
|
||||
'EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS c '
|
||||
|| 'WHERE c.' || pr2.rule_column || ' = [CURRENT_ROW].' || pr2.rule_column || ' '
|
||||
|| 'AND SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_ROLE'') = ''' || NVL(pr2.rule_value, '[역할값 필요]') || ''' '
|
||||
|| 'AND c.FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL''))'
|
||||
WHEN pr2.rule_type = 'STATIC_SQL' THEN pr2.rule_value
|
||||
ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value
|
||||
END,
|
||||
CHR(10) || 'OR '
|
||||
CHR(10) || 'AND '
|
||||
) WITHIN GROUP (ORDER BY pr2.rule_id)
|
||||
FROM cb_permission_rule pr2
|
||||
WHERE pr2.perm_id = p.perm_id
|
||||
|
||||
@@ -388,8 +388,8 @@ function syncRuleTypeHints(root = document) {
|
||||
}
|
||||
|
||||
const type = typeSelect.value;
|
||||
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type);
|
||||
const columnOptional = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG'].includes(type);
|
||||
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 placeholderByType = {
|
||||
ALL: '값 불필요',
|
||||
MY_DEPT: '값 불필요',
|
||||
@@ -399,17 +399,30 @@ function syncRuleTypeHints(root = document) {
|
||||
TAG: '예: SPRING_BOOT',
|
||||
STAKEHOLDER_SELF: '예: 설계사',
|
||||
STAKEHOLDER_CHANNEL: '예: 지점장',
|
||||
TOKEN_SUBJECT: '값 불필요',
|
||||
OWN_CONTRACT: '값 불필요',
|
||||
CHANNEL_CONTRACT: '값 불필요',
|
||||
OWN_CUSTOMER: '값 불필요',
|
||||
CHANNEL_CUSTOMER: '값 불필요',
|
||||
STATIC_SQL: "예: CONTRACT_STATUS = '정상'",
|
||||
'=': '비교 값',
|
||||
'!=': '비교 값'
|
||||
};
|
||||
|
||||
columnSelect.required = !columnOptional;
|
||||
columnSelect.disabled = ['ALL', 'MY_DEPT', 'SELF', 'DEPT', 'EMP_NO', 'TAG', 'STATIC_SQL'].includes(type);
|
||||
valueInput.required = valueRequired;
|
||||
valueInput.disabled = ['ALL', 'MY_DEPT', 'SELF'].includes(type);
|
||||
valueInput.disabled = [
|
||||
'ALL', 'MY_DEPT', 'SELF', 'TOKEN_SUBJECT', 'OWN_CONTRACT',
|
||||
'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'
|
||||
].includes(type);
|
||||
valueInput.placeholder = placeholderByType[type] || '비교 값';
|
||||
if (valueInput.disabled) {
|
||||
valueInput.value = '';
|
||||
}
|
||||
if (columnSelect.disabled) {
|
||||
columnSelect.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -700,17 +713,29 @@ function collectWizardRules(root) {
|
||||
TAG: 'TECH_TAG',
|
||||
STAKEHOLDER_SELF: '선택 컬럼',
|
||||
STAKEHOLDER_CHANNEL: '선택 컬럼',
|
||||
TOKEN_SUBJECT: '선택 컬럼',
|
||||
OWN_CONTRACT: '선택 컬럼',
|
||||
CHANNEL_CONTRACT: '선택 컬럼',
|
||||
OWN_CUSTOMER: '선택 컬럼',
|
||||
CHANNEL_CUSTOMER: '선택 컬럼',
|
||||
STATIC_SQL: '',
|
||||
ALL: ''
|
||||
}[type] || '';
|
||||
if (type === 'ALL') {
|
||||
return 'ALL';
|
||||
}
|
||||
if (type === 'STATIC_SQL') {
|
||||
return `정적 SQL: ${value}`;
|
||||
}
|
||||
if (['MY_DEPT', 'SELF'].includes(type)) {
|
||||
return `${displayColumn} ${type}`;
|
||||
}
|
||||
if (['STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type)) {
|
||||
return `${displayColumn} ${type} 역할=${value}`;
|
||||
}
|
||||
if (['TOKEN_SUBJECT', 'OWN_CONTRACT', 'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'].includes(type)) {
|
||||
return `${displayColumn} ${type}`;
|
||||
}
|
||||
if (type === 'TAG') {
|
||||
return `${displayColumn} TAG ${value.toUpperCase()}`;
|
||||
}
|
||||
@@ -731,11 +756,20 @@ function collectWizardPredicates(root) {
|
||||
TAG: 'TECH_TAG',
|
||||
STAKEHOLDER_SELF: '선택 컬럼',
|
||||
STAKEHOLDER_CHANNEL: '선택 컬럼',
|
||||
TOKEN_SUBJECT: '선택 컬럼',
|
||||
OWN_CONTRACT: '선택 컬럼',
|
||||
CHANNEL_CONTRACT: '선택 컬럼',
|
||||
OWN_CUSTOMER: '선택 컬럼',
|
||||
CHANNEL_CUSTOMER: '선택 컬럼',
|
||||
STATIC_SQL: '',
|
||||
ALL: ''
|
||||
}[type] || '';
|
||||
if (type === 'ALL') {
|
||||
return '1 = 1';
|
||||
}
|
||||
if (type === 'STATIC_SQL') {
|
||||
return value;
|
||||
}
|
||||
if (type === 'MY_DEPT') {
|
||||
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')`;
|
||||
}
|
||||
@@ -748,6 +782,21 @@ function collectWizardPredicates(root) {
|
||||
if (type === 'STAKEHOLDER_CHANNEL') {
|
||||
return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_CHANNEL = TOKEN_CHANNEL)`;
|
||||
}
|
||||
if (type === 'TOKEN_SUBJECT') {
|
||||
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`;
|
||||
}
|
||||
if (type === 'OWN_CONTRACT') {
|
||||
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`;
|
||||
}
|
||||
if (type === 'CHANNEL_CONTRACT') {
|
||||
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')`;
|
||||
}
|
||||
if (type === 'OWN_CUSTOMER') {
|
||||
return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'))`;
|
||||
}
|
||||
if (type === 'CHANNEL_CUSTOMER') {
|
||||
return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL'))`;
|
||||
}
|
||||
if (type === 'DEPT' || type === 'EMP_NO') {
|
||||
return `${displayColumn} = ${sqlLiteral(value)}`;
|
||||
}
|
||||
@@ -794,8 +843,8 @@ function updatePermissionWizardPreview(root = document) {
|
||||
? `거부 규칙: ${ruleText}`
|
||||
: `허용 규칙: ${ruleText}`;
|
||||
const predicateText = effect === 'DENY'
|
||||
? (rules.includes('ALL') ? 'DENY ALL: 1 = 0' : `DENY 후보: NOT (${predicates.join(' OR ') || '조건 없음'})`)
|
||||
: `ALLOW 후보: ${predicates.join(' OR ') || '조건 없음'}`;
|
||||
? (rules.includes('ALL') ? 'DENY ALL: 1 = 0' : `DENY 후보: NOT (${predicates.join(' AND ') || '조건 없음'})`)
|
||||
: `ALLOW 후보: ${predicates.join(' AND ') || '조건 없음'}`;
|
||||
const columnPolicy = visibleColumns
|
||||
? `이 권한에서 원문 표시 허용: ${visibleColumns}`
|
||||
: '마스킹 대상 컬럼은 기본 정책대로 NULL/마스킹 처리';
|
||||
@@ -872,19 +921,25 @@ function permissionWizardRuleValidationMessage(wizard) {
|
||||
if (values.some((rule) => rule.type === 'ALL') && values.length > 1) {
|
||||
return 'ALL 규칙은 다른 조건 규칙과 함께 저장할 수 없습니다. ALL만 남기거나 ALL을 삭제하세요.';
|
||||
}
|
||||
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'];
|
||||
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL', 'STATIC_SQL'];
|
||||
const seen = new Set();
|
||||
for (const [index, rule] of values.entries()) {
|
||||
const position = index + 1;
|
||||
if (!rule.type) {
|
||||
return `${position}번째 행 규칙 유형을 선택하세요.`;
|
||||
}
|
||||
if (['=', '!=', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(rule.type) && !rule.column) {
|
||||
if ([
|
||||
'=', '!=', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL', 'TOKEN_SUBJECT',
|
||||
'OWN_CONTRACT', 'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'
|
||||
].includes(rule.type) && !rule.column) {
|
||||
return `${position}번째 ${rule.type} 규칙에는 비교할 컬럼이 필요합니다.`;
|
||||
}
|
||||
if (valueRequired.includes(rule.type) && !rule.value) {
|
||||
return `${position}번째 ${rule.type} 규칙의 값을 입력하세요.`;
|
||||
}
|
||||
if (rule.type === 'STATIC_SQL' && rule.column) {
|
||||
return `${position}번째 정적 SQL 조건은 컬럼을 따로 선택하지 마세요.`;
|
||||
}
|
||||
if (rule.type === 'TAG' && rule.value && !/^[A-Za-z0-9_-]+$/.test(rule.value)) {
|
||||
return `${position}번째 TAG 값은 영문·숫자, '_' 또는 '-'만 사용할 수 있습니다.`;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h1>접근 규칙</h1>
|
||||
<details class="explanation-details">
|
||||
<summary>도움말</summary>
|
||||
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 원장 예제는 토큰 주체에서 한 번 해석한 이해당사자 ID·역할·채널을 secure context로 사용하고, 대상 고객은 계약원장으로만 연결합니다. <code>STAKEHOLDER_SELF</code>와 <code>STAKEHOLDER_CHANNEL</code>은 각각의 역할 분기로 만들고 허용 분기끼리는 OR로 계산합니다.</p>
|
||||
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 조건 코드는 토큰 context 값으로 치환되고, 정적 SQL 조건은 대상 객체의 WHERE 절에 그대로 추가됩니다. 한 권한의 규칙은 AND, 서로 다른 ALLOW 권한은 OR로 합치며 DENY는 허용 결과에서 제외합니다.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -154,31 +154,32 @@
|
||||
<div id="rowRuleList" class="rule-list">
|
||||
<div class="rule-row">
|
||||
<select class="form-select rule-column-select" name="ruleColumn">
|
||||
<option value="">기본 컬럼</option>
|
||||
<option value="DEPT_CODE">DEPT_CODE</option>
|
||||
<option value="OWNER_EMP_NO">OWNER_EMP_NO</option>
|
||||
<option value="DOC_ID">DOC_ID</option>
|
||||
<option value="TITLE">TITLE</option>
|
||||
<option value="">객체 컬럼 선택</option>
|
||||
</select>
|
||||
<select class="form-select rule-type-select" name="ruleType">
|
||||
<option value="ALL">ALL</option>
|
||||
<option value="MY_DEPT">현재 사용자 부서</option>
|
||||
<option value="SELF">현재 사용자 사번</option>
|
||||
<option value="DEPT">지정 부서</option>
|
||||
<option value="EMP_NO">지정 사번</option>
|
||||
<option value="TAG">특정 기술 태그</option>
|
||||
<option value="STAKEHOLDER_SELF">이해당사자 본인 담당</option>
|
||||
<option value="STAKEHOLDER_CHANNEL">이해당사자 채널 범위</option>
|
||||
<option value="=">=</option>
|
||||
<option value="!=">!=</option>
|
||||
<optgroup label="KB · 본인 범위">
|
||||
<option value="OWN_CONTRACT">본인 담당 계약</option>
|
||||
<option value="OWN_CUSTOMER">본인 담당 고객</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 · 정적 조건">
|
||||
<option value="STATIC_SQL">정적 SQL 조건식</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<input class="form-control" name="ruleValue" placeholder="비교 값">
|
||||
<input class="form-control" name="ruleValue" maxlength="1000" placeholder="비교 값">
|
||||
<button class="btn btn-outline-secondary" type="button" data-rule-add>추가</button>
|
||||
</div>
|
||||
</div>
|
||||
<details class="explanation-details">
|
||||
<summary>행 규칙과 기술 태그 OR 조건 보기</summary>
|
||||
<p class="wizard-hint">특정 기술 태그는 기본적으로 <code>TECH_TAG</code> 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회됩니다. 이해당사자 규칙은 <strong>비교 컬럼</strong>과 <strong>업무 역할값</strong>(예: 설계사, 지점장)을 모두 입력하세요. 토큰을 해석한 뒤 <code>STAKEHOLDER_USER_ID</code>·<code>STAKEHOLDER_ROLE</code>·<code>STAKEHOLDER_CHANNEL</code> context를 사용합니다. <code>STAKEHOLDER_SELF</code>은 <code>c.FC_ID = context 사용자</code>, <code>STAKEHOLDER_CHANNEL</code>은 <code>c.FC_CHANNEL = context 채널</code>이며 서로 다른 허용 규칙은 OR로 결합됩니다.</p>
|
||||
<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로 합쳐집니다. 역할명은 VPD WHERE 절에 직접 들어가지 않습니다.</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,7 +242,7 @@
|
||||
|
||||
<section class="content-band">
|
||||
<h2>접근 규칙 목록</h2>
|
||||
<p class="section-subtitle">같은 보호 대상에 적용되는 ALLOW 역할 분기와 한 권한 안의 여러 행 규칙은 OR로 합칩니다. 예를 들어 설계사 본인계약 분기 또는 지점장 채널 분기 중 하나라도 성립하면 행이 허용됩니다. DENY 분기는 허용 결과에서 별도로 제외됩니다.</p>
|
||||
<p class="section-subtitle">한 접근 규칙 안에서는 조건 코드와 정적 SQL 조건을 AND로 합쳐 범위를 좁힙니다. 같은 보호 대상의 서로 다른 ALLOW 권한은 OR, DENY 권한은 허용 결과에서 제외됩니다.</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle permission-list-table">
|
||||
<thead>
|
||||
|
||||
@@ -221,6 +221,83 @@ class PermissionServiceTest {
|
||||
.hasMessageContaining("값이 필요");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsKbFilterConditionCodesWithoutAStaticRuleValue() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(
|
||||
new RuleCommand("DEPT_CODE", "OWN_CONTRACT", null),
|
||||
new RuleCommand("OWNER_EMP_NO", "CHANNEL_CUSTOMER", null)
|
||||
),
|
||||
List.of()
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleType)
|
||||
.containsExactly("OWN_CONTRACT", "CHANNEL_CUSTOMER");
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleValue)
|
||||
.containsExactly("", "");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsStaticSqlPredicateAlongsideAContextCondition() {
|
||||
var command = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(
|
||||
new RuleCommand("DEPT_CODE", "OWN_CONTRACT", null),
|
||||
new RuleCommand(null, "STATIC_SQL", "DEPT_CODE IN ('HR', 'FIN')")
|
||||
),
|
||||
List.of()
|
||||
);
|
||||
|
||||
permissionService.savePermissionSet(command);
|
||||
|
||||
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleType)
|
||||
.containsExactly("OWN_CONTRACT", "STATIC_SQL");
|
||||
assertThat(mapper.insertedRules)
|
||||
.extracting(PermissionRule::ruleValue)
|
||||
.containsExactly("", "DEPT_CODE IN ('HR', 'FIN')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStaticSqlStatementOrUnknownColumn() {
|
||||
var statementCommand = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "STATIC_SQL", "DEPT_CODE = 'HR'; DELETE FROM CB_APP_USER")),
|
||||
List.of()
|
||||
);
|
||||
var unknownColumnCommand = new PermissionSetCommand(
|
||||
10L,
|
||||
1L,
|
||||
"SELECT",
|
||||
"ALLOW",
|
||||
List.of(new RuleCommand(null, "STATIC_SQL", "UNSUPPORTED_COLUMN = 'HR'")),
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(statementCommand))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("단일 WHERE");
|
||||
assertThatThrownBy(() -> permissionService.savePermissionSet(unknownColumnCommand))
|
||||
.isInstanceOf(AppException.class)
|
||||
.hasMessageContaining("보호 객체 컬럼");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsMultipleVisibleColumns() {
|
||||
var command = new PermissionSetCommand(
|
||||
|
||||
@@ -157,14 +157,16 @@ class GuidedFlowTemplateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionWizardExplainsTagRulesAndOrSemantics() throws IOException {
|
||||
void permissionWizardExplainsConditionCodeAndStaticSqlSemantics() throws IOException {
|
||||
String html = template("permissions.html");
|
||||
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
|
||||
|
||||
assertThat(html)
|
||||
.contains("value=\"TAG\">특정 기술 태그")
|
||||
.contains("TECH_TAG")
|
||||
.contains("태그를 여러 개 추가하면")
|
||||
.contains("value=\"OWN_CUSTOMER\">본인 담당 고객")
|
||||
.contains("value=\"STATIC_SQL\">정적 SQL 조건식")
|
||||
.contains("행 규칙의 두 가지 적용 방식 보기")
|
||||
.contains("한 권한 안의 규칙은 모두 AND")
|
||||
.contains("서로 다른 역할의 ALLOW 권한은 OR")
|
||||
.contains("data-wizard-validation")
|
||||
.contains("다른 조건 규칙은 한 권한에 함께 저장할 수 없습니다")
|
||||
.contains("저장 영향")
|
||||
|
||||
Reference in New Issue
Block a user