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 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 VALUE_REQUIRED_RULE_TYPES = Set.of( "=", "!=", "DEPT", "EMP_NO", "TAG", "STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL", "STATIC_SQL"); private static final Set DEFAULT_COLUMN_RULE_TYPES = Set.of( "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG", "STATIC_SQL"); private static final Set STATIC_SQL_KEYWORDS = Set.of( "AND", "OR", "NOT", "NULL", "IS", "IN", "LIKE", "BETWEEN", "ESCAPE", "TRUE", "FALSE", "UPPER", "LOWER", "TRIM", "NVL", "COALESCE", "TO_CHAR", "TO_DATE", "REGEXP_LIKE", "INSTR", "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 PERMISSION_EFFECTS = Set.of("ALLOW", "DENY"); private static final Set 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 findRoles() { return permissionMapper.findRoles(); } public List 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 rules) { if (rules == null || rules.isEmpty()) { throw new AppException("행 규칙은 하나 이상 필요합니다."); } Set seen = new HashSet<>(); boolean hasAll = false; Set 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 allowedColumns(long objectId) { Set allowed = new HashSet<>(); for (ProtectedColumn column : protectedObjectService.findColumns(objectId)) { allowed.add(column.columnName().toUpperCase(Locale.ROOT)); } return allowed; } private void validateStaticSqlPredicate(String predicate, Set 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(); } }