336 lines
15 KiB
Java
336 lines
15 KiB
Java
package com.cloudhandson.vpdbackoffice.service;
|
|
|
|
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
|
|
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
|
|
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionRule;
|
|
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSet;
|
|
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
|
|
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionView;
|
|
import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand;
|
|
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
|
import com.cloudhandson.vpdbackoffice.mapper.PermissionMapper;
|
|
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;
|
|
|
|
@Service
|
|
public class PermissionService {
|
|
|
|
private static final Set<String> RULE_TYPES = Set.of(
|
|
"ALL", "=", "!=", "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");
|
|
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");
|
|
|
|
private final PermissionMapper permissionMapper;
|
|
private final ProtectedObjectService protectedObjectService;
|
|
private final AuditService auditService;
|
|
private final ExternalAuthorizationChangeNotifier authorizationChangeNotifier;
|
|
|
|
@Autowired
|
|
public PermissionService(
|
|
PermissionMapper permissionMapper,
|
|
ProtectedObjectService protectedObjectService,
|
|
AuditService auditService,
|
|
ExternalAuthorizationChangeNotifier authorizationChangeNotifier
|
|
) {
|
|
this.permissionMapper = permissionMapper;
|
|
this.protectedObjectService = protectedObjectService;
|
|
this.auditService = auditService;
|
|
this.authorizationChangeNotifier = authorizationChangeNotifier;
|
|
}
|
|
|
|
public PermissionService(
|
|
PermissionMapper permissionMapper,
|
|
ProtectedObjectService protectedObjectService,
|
|
AuditService auditService
|
|
) {
|
|
this(permissionMapper, protectedObjectService, auditService, ExternalAuthorizationChangeNotifier.noop());
|
|
}
|
|
|
|
public List<AppRole> findRoles() {
|
|
return permissionMapper.findRoles();
|
|
}
|
|
|
|
public List<PermissionView> findPermissionViews() {
|
|
return permissionMapper.findPermissionViews();
|
|
}
|
|
|
|
@Transactional
|
|
public void createRole(String roleName, String description) {
|
|
createRole(roleName, description, "PUBLIC");
|
|
}
|
|
|
|
@Transactional
|
|
public void createRole(String roleName, String description, String maxSensitivityLevel) {
|
|
if (roleName == null || roleName.isBlank()) {
|
|
throw new AppException("역할명은 필수입니다.");
|
|
}
|
|
long roleId = permissionMapper.nextRoleId();
|
|
permissionMapper.insertRole(roleId, roleName.trim(), description, normalizeSensitivityLevel(maxSensitivityLevel));
|
|
auditService.record(new AuditEvent("ROLE_CREATED", null, null, "SUCCESS", null, null, roleName));
|
|
authorizationChangeNotifier.changed("ROLE_CREATED");
|
|
}
|
|
|
|
@Transactional
|
|
public void updateRoleMaxSensitivity(long roleId, String maxSensitivityLevel) {
|
|
String normalized = normalizeSensitivityLevel(maxSensitivityLevel);
|
|
int updated = permissionMapper.updateRoleMaxSensitivity(roleId, normalized);
|
|
if (updated == 0) {
|
|
throw new AppException("수정할 역할을 찾을 수 없습니다.");
|
|
}
|
|
auditService.record(new AuditEvent("ROLE_MAX_SENSITIVITY_UPDATED", null, null, "SUCCESS", null, null,
|
|
"roleId=" + roleId + ", max=" + normalized));
|
|
authorizationChangeNotifier.changed("ROLE_MAX_SENSITIVITY_UPDATED");
|
|
}
|
|
|
|
@Transactional
|
|
public void deleteRole(long roleId) {
|
|
deleteRole(roleId, true);
|
|
}
|
|
|
|
@Transactional
|
|
public void deleteRole(long roleId, boolean confirmImpact) {
|
|
if (!confirmImpact) {
|
|
throw new AppException("역할 삭제 전 영향 확인이 필요합니다.");
|
|
}
|
|
int userRoleCount = permissionMapper.countUserRolesByRoleId(roleId);
|
|
int groupRoleCount = permissionMapper.countGroupRolesByRoleId(roleId);
|
|
int permissionCount = permissionMapper.countPermissionsByRoleId(roleId);
|
|
if (userRoleCount + groupRoleCount + permissionCount > 0) {
|
|
throw new AppException("연결된 사용자/그룹/권한이 있는 역할은 삭제할 수 없습니다. 사용자 역할 "
|
|
+ userRoleCount + "건, 그룹 역할 " + groupRoleCount + "건, 권한 " + permissionCount
|
|
+ "건을 먼저 해제하세요.");
|
|
}
|
|
int deleted = permissionMapper.deleteRole(roleId);
|
|
if (deleted == 0) {
|
|
throw new AppException("삭제할 역할을 찾을 수 없습니다.");
|
|
}
|
|
auditService.record(new AuditEvent("ROLE_DELETED", null, null, "SUCCESS", null, null, "roleId=" + roleId));
|
|
authorizationChangeNotifier.changed("ROLE_DELETED");
|
|
}
|
|
|
|
@Transactional
|
|
public PermissionSet savePermissionSet(PermissionSetCommand command) {
|
|
if (!"SELECT".equalsIgnoreCase(command.action())) {
|
|
throw new AppException("초기 구현에서는 SELECT 권한만 저장할 수 있습니다.");
|
|
}
|
|
String permissionEffect = normalizePermissionEffect(command.permissionEffect());
|
|
AppRole role = permissionMapper.findRole(command.roleId());
|
|
if (role == null) {
|
|
throw new AppException("역할을 찾을 수 없습니다.");
|
|
}
|
|
protectedObjectService.assertEnabled(command.objectId());
|
|
validateRules(command.objectId(), command.rules());
|
|
|
|
Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId());
|
|
long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId;
|
|
if (existingId == null) {
|
|
permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect);
|
|
} else {
|
|
permissionMapper.updatePermissionAction(permissionId, "SELECT");
|
|
permissionMapper.updatePermissionEffect(permissionId, permissionEffect);
|
|
}
|
|
|
|
permissionMapper.deleteRules(permissionId);
|
|
for (RuleCommand rule : command.rules()) {
|
|
String type = normalize(rule.ruleType());
|
|
permissionMapper.insertRule(new PermissionRule(
|
|
permissionMapper.nextRuleId(),
|
|
permissionId,
|
|
normalizeNullable(rule.ruleColumn()),
|
|
type,
|
|
normalizeRuleValue(type, rule.ruleValue())
|
|
));
|
|
}
|
|
|
|
permissionMapper.deleteVisibleColumns(permissionId);
|
|
|
|
auditService.record(new AuditEvent(
|
|
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
|
|
"roleId=" + command.roleId()
|
|
));
|
|
authorizationChangeNotifier.changed("PERMISSION_SAVED");
|
|
return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect, List.of(), List.of());
|
|
}
|
|
|
|
@Transactional
|
|
public void deletePermission(long permissionId) {
|
|
deletePermission(permissionId, true);
|
|
}
|
|
|
|
@Transactional
|
|
public void deletePermission(long permissionId, boolean confirmImpact) {
|
|
if (!confirmImpact) {
|
|
throw new AppException("권한 삭제 전 영향 확인이 필요합니다.");
|
|
}
|
|
Long objectId = permissionMapper.findObjectIdByPermissionId(permissionId);
|
|
permissionMapper.deleteRules(permissionId);
|
|
permissionMapper.deleteVisibleColumns(permissionId);
|
|
int deleted = permissionMapper.deletePermission(permissionId);
|
|
if (deleted == 0) {
|
|
throw new AppException("삭제할 권한을 찾을 수 없습니다.");
|
|
}
|
|
if (objectId != null && permissionMapper.countPermissionsByObjectId(objectId) == 0) {
|
|
protectedObjectService.disableObject(objectId);
|
|
}
|
|
auditService.record(new AuditEvent("PERMISSION_DELETED", null, null, "SUCCESS", null, null,
|
|
"permissionId=" + permissionId));
|
|
authorizationChangeNotifier.changed("PERMISSION_DELETED");
|
|
}
|
|
|
|
public int countPermissionsByObjectId(long objectId) {
|
|
return permissionMapper.countPermissionsByObjectId(objectId);
|
|
}
|
|
|
|
private void validateRules(long objectId, List<RuleCommand> rules) {
|
|
if (rules == null || rules.isEmpty()) {
|
|
throw new AppException("행 규칙은 하나 이상 필요합니다.");
|
|
}
|
|
Set<String> seen = new HashSet<>();
|
|
boolean hasAll = false;
|
|
Set<String> allowedColumns = allowedColumns(objectId);
|
|
for (RuleCommand rule : rules) {
|
|
String type = normalize(rule.ruleType());
|
|
if (!RULE_TYPES.contains(type)) {
|
|
throw new AppException("허용되지 않은 행 규칙입니다: " + type);
|
|
}
|
|
String column = normalizeNullable(rule.ruleColumn());
|
|
if (column != null && !allowedColumns.contains(column)) {
|
|
throw new AppException("행 규칙 컬럼은 보호 객체 컬럼이어야 합니다: " + column);
|
|
}
|
|
String ruleValue = normalizeRuleValue(type, rule.ruleValue());
|
|
if (!seen.add(column + ":" + type + ":" + ruleValue)) {
|
|
throw new AppException("중복된 행 규칙이 있습니다.");
|
|
}
|
|
hasAll = hasAll || "ALL".equals(type);
|
|
if (!"ALL".equals(type) && column == null && !DEFAULT_COLUMN_RULE_TYPES.contains(type)) {
|
|
throw new AppException(type + " 규칙에는 컬럼이 필요합니다.");
|
|
}
|
|
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 규칙은 다른 규칙과 함께 저장할 수 없습니다.");
|
|
}
|
|
}
|
|
|
|
private Set<String> allowedColumns(long objectId) {
|
|
Set<String> allowed = new HashSet<>();
|
|
for (ProtectedColumn column : protectedObjectService.findColumns(objectId)) {
|
|
allowed.add(column.columnName().toUpperCase(Locale.ROOT));
|
|
}
|
|
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 조건에는 다른 테이블·스키마를 참조할 수 없습니다.");
|
|
}
|
|
|
|
// The VPD function makes the same narrowly-scoped exception. It lets an
|
|
// administrator express the auditable physical condition "1 = 1" for a
|
|
// full-access permission instead of relying on an implicit ALL marker.
|
|
if (predicate.matches("(?i)^1\\s*=\\s*1$")) {
|
|
return;
|
|
}
|
|
|
|
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 String normalize(String value) {
|
|
return clean(value).toUpperCase(Locale.ROOT);
|
|
}
|
|
|
|
private String normalizePermissionEffect(String value) {
|
|
String effect = clean(value).isBlank() ? "ALLOW" : normalize(value);
|
|
if (!PERMISSION_EFFECTS.contains(effect)) {
|
|
throw new AppException("허용되지 않은 권한 효과입니다: " + effect);
|
|
}
|
|
return effect;
|
|
}
|
|
|
|
private String normalizeSensitivityLevel(String value) {
|
|
String level = clean(value).isBlank() ? "PUBLIC" : normalize(value);
|
|
if (!SENSITIVITY_LEVELS.contains(level)) {
|
|
throw new AppException("허용되지 않은 민감도 등급입니다: " + level);
|
|
}
|
|
return level;
|
|
}
|
|
|
|
private String normalizeNullable(String value) {
|
|
String cleaned = clean(value);
|
|
return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT);
|
|
}
|
|
|
|
private String normalizeRuleValue(String type, String value) {
|
|
String cleaned = clean(value);
|
|
return "TAG".equals(type) ? cleaned.toUpperCase(Locale.ROOT) : cleaned;
|
|
}
|
|
|
|
private String clean(String value) {
|
|
return value == null ? "" : value.trim();
|
|
}
|
|
}
|