feat #467: separate column sensitivity policies
This commit is contained in:
@@ -3,6 +3,7 @@ package com.cloudhandson.vpdbackoffice.domain.permission;
|
||||
public record AppRole(
|
||||
long roleId,
|
||||
String roleName,
|
||||
String description
|
||||
String description,
|
||||
String maxSensitivityLevel
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -5,10 +5,19 @@ public record ProtectedColumn(
|
||||
long objectId,
|
||||
String columnName,
|
||||
String sensitiveYn,
|
||||
Long visibleRoleId
|
||||
Long visibleRoleId,
|
||||
String sensitivityLevel,
|
||||
String redactionMethod
|
||||
) {
|
||||
|
||||
public boolean sensitive() {
|
||||
return "Y".equalsIgnoreCase(sensitiveYn);
|
||||
return "Y".equalsIgnoreCase(sensitiveYn)
|
||||
|| !"PUBLIC".equalsIgnoreCase(sensitivityLevel);
|
||||
}
|
||||
|
||||
public String policyLabel() {
|
||||
String level = sensitivityLevel == null || sensitivityLevel.isBlank() ? "PUBLIC" : sensitivityLevel;
|
||||
String method = redactionMethod == null || redactionMethod.isBlank() ? "NONE" : redactionMethod;
|
||||
return level + "/" + method;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ public interface PermissionMapper {
|
||||
|
||||
void insertRole(@Param("roleId") long roleId,
|
||||
@Param("roleName") String roleName,
|
||||
@Param("description") String description);
|
||||
@Param("description") String description,
|
||||
@Param("maxSensitivityLevel") String maxSensitivityLevel);
|
||||
|
||||
int updateRoleMaxSensitivity(@Param("roleId") long roleId,
|
||||
@Param("maxSensitivityLevel") String maxSensitivityLevel);
|
||||
|
||||
int deleteRole(@Param("roleId") long roleId);
|
||||
|
||||
|
||||
@@ -34,7 +34,13 @@ public interface ProtectedObjectMapper {
|
||||
void insertColumn(@Param("columnId") long columnId,
|
||||
@Param("objectId") long objectId,
|
||||
@Param("columnName") String columnName,
|
||||
@Param("sensitiveYn") String sensitiveYn);
|
||||
@Param("sensitiveYn") String sensitiveYn,
|
||||
@Param("sensitivityLevel") String sensitivityLevel,
|
||||
@Param("redactionMethod") String redactionMethod);
|
||||
|
||||
int updateColumnPolicy(@Param("columnId") long columnId,
|
||||
@Param("sensitivityLevel") String sensitivityLevel,
|
||||
@Param("redactionMethod") String redactionMethod);
|
||||
|
||||
int updateOrdsPath(@Param("objectId") long objectId, @Param("ordsPath") String ordsPath);
|
||||
|
||||
|
||||
@@ -40,10 +40,13 @@ public class BackofficeSchemaService {
|
||||
""");
|
||||
createTable(results, "cb_app_role", """
|
||||
CREATE TABLE cb_app_role (
|
||||
role_id NUMBER PRIMARY KEY,
|
||||
role_name VARCHAR2(100) NOT NULL UNIQUE
|
||||
role_id NUMBER PRIMARY KEY,
|
||||
role_name VARCHAR2(100) NOT NULL UNIQUE,
|
||||
max_sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL
|
||||
)
|
||||
""");
|
||||
addColumn(results, "cb_app_role", "max_sensitivity_level",
|
||||
"ALTER TABLE cb_app_role ADD (max_sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)");
|
||||
createTable(results, "cb_user_role", """
|
||||
CREATE TABLE cb_user_role (
|
||||
user_id NUMBER NOT NULL,
|
||||
@@ -108,9 +111,23 @@ public class BackofficeSchemaService {
|
||||
column_name VARCHAR2(128) NOT NULL,
|
||||
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL,
|
||||
visible_role_id NUMBER,
|
||||
sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL,
|
||||
redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL,
|
||||
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name)
|
||||
)
|
||||
""");
|
||||
addColumn(results, "cb_protected_column", "sensitivity_level",
|
||||
"ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)");
|
||||
addColumn(results, "cb_protected_column", "redaction_method",
|
||||
"ALTER TABLE cb_protected_column ADD (redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL)");
|
||||
jdbcTemplate.update("""
|
||||
UPDATE cb_protected_column
|
||||
SET sensitivity_level = CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END,
|
||||
redaction_method = CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END
|
||||
WHERE sensitivity_level = 'PUBLIC'
|
||||
AND redaction_method = 'NONE'
|
||||
AND sensitive_yn = 'Y'
|
||||
""");
|
||||
createTable(results, "cb_ords_probe_audit", """
|
||||
CREATE TABLE cb_ords_probe_audit (
|
||||
audit_id NUMBER PRIMARY KEY,
|
||||
|
||||
@@ -192,28 +192,27 @@ public class OrdsProbeService {
|
||||
if (rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> sensitiveColumns = protectedObjectService.findColumns(objectId).stream()
|
||||
List<ProtectedColumn> sensitiveColumns = protectedObjectService.findColumns(objectId).stream()
|
||||
.filter(ProtectedColumn::sensitive)
|
||||
.map(column -> column.columnName().toLowerCase(Locale.ROOT))
|
||||
.toList();
|
||||
if (sensitiveColumns.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> masked = new ArrayList<>();
|
||||
for (String column : sensitiveColumns) {
|
||||
for (ProtectedColumn column : sensitiveColumns) {
|
||||
boolean present = false;
|
||||
boolean allNull = true;
|
||||
for (Map<String, Object> row : rows) {
|
||||
for (Map.Entry<String, Object> entry : row.entrySet()) {
|
||||
if (entry.getKey().equalsIgnoreCase(column)) {
|
||||
if (entry.getKey().equalsIgnoreCase(column.columnName())) {
|
||||
present = true;
|
||||
allNull = allNull && entry.getValue() == null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (present && allNull) {
|
||||
masked.add(column);
|
||||
masked.add(column.columnName().toLowerCase(Locale.ROOT) + " [" + column.policyLabel() + "]");
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
|
||||
@@ -23,6 +23,8 @@ public class PermissionService {
|
||||
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of("=", "!=", "DEPT", "EMP_NO");
|
||||
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of("MY_DEPT", "SELF", "DEPT", "EMP_NO");
|
||||
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;
|
||||
@@ -48,14 +50,30 @@ public class PermissionService {
|
||||
|
||||
@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);
|
||||
permissionMapper.insertRole(roleId, roleName.trim(), description, normalizeSensitivityLevel(maxSensitivityLevel));
|
||||
auditService.record(new AuditEvent("ROLE_CREATED", null, null, "SUCCESS", null, null, roleName));
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteRole(long roleId) {
|
||||
int deleted = permissionMapper.deleteRole(roleId);
|
||||
@@ -196,6 +214,14 @@ public class PermissionService {
|
||||
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);
|
||||
|
||||
@@ -25,6 +25,9 @@ public class ProtectedObjectService {
|
||||
private final Map<String, CacheEntry<List<String>>> databaseColumnsCache = new ConcurrentHashMap<>();
|
||||
private final Map<Long, CacheEntry<List<ProtectedColumn>>> protectedColumnsCache = new ConcurrentHashMap<>();
|
||||
private static final long CATALOG_CACHE_MILLIS = 60_000L;
|
||||
private static final Set<String> SENSITIVITY_LEVELS = Set.of(
|
||||
"PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED");
|
||||
private static final Set<String> REDACTION_METHODS = Set.of("NONE", "NULLIFY", "PARTIAL", "FULL");
|
||||
|
||||
public ProtectedObjectService(ProtectedObjectMapper mapper, AuditService auditService) {
|
||||
this.mapper = mapper;
|
||||
@@ -90,7 +93,9 @@ public class ProtectedObjectService {
|
||||
mapper.insertObject(objectId, normalized);
|
||||
Set<String> sensitive = splitCsv(normalized.sensitiveColumns());
|
||||
for (String column : splitCsv(normalized.columns())) {
|
||||
mapper.insertColumn(mapper.nextColumnId(), objectId, column, sensitive.contains(column) ? "Y" : "N");
|
||||
String sensitiveYn = sensitive.contains(column) ? "Y" : "N";
|
||||
mapper.insertColumn(mapper.nextColumnId(), objectId, column, sensitiveYn,
|
||||
defaultSensitivityLevel(sensitiveYn), defaultRedactionMethod(sensitiveYn));
|
||||
}
|
||||
databaseObjectsCache = null;
|
||||
protectedColumnsCache.remove(objectId);
|
||||
@@ -137,7 +142,7 @@ public class ProtectedObjectService {
|
||||
long objectId = mapper.nextObjectId();
|
||||
mapper.insertObject(objectId, command);
|
||||
for (String column : columns) {
|
||||
mapper.insertColumn(mapper.nextColumnId(), objectId, column, "N");
|
||||
mapper.insertColumn(mapper.nextColumnId(), objectId, column, "N", "PUBLIC", "NONE");
|
||||
}
|
||||
databaseObjectsCache = null;
|
||||
protectedColumnsCache.remove(objectId);
|
||||
@@ -187,6 +192,22 @@ public class ProtectedObjectService {
|
||||
ordsPath.trim()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateColumnPolicy(long columnId, String sensitivityLevel, String redactionMethod) {
|
||||
String normalizedLevel = normalizeOption(sensitivityLevel, "PUBLIC", SENSITIVITY_LEVELS, "민감도 등급");
|
||||
String normalizedMethod = normalizeOption(redactionMethod, "NONE", REDACTION_METHODS, "마스킹 방식");
|
||||
if ("PUBLIC".equals(normalizedLevel) && !"NONE".equals(normalizedMethod)) {
|
||||
throw new AppException("PUBLIC 컬럼의 마스킹 방식은 NONE이어야 합니다.");
|
||||
}
|
||||
int updated = mapper.updateColumnPolicy(columnId, normalizedLevel, normalizedMethod);
|
||||
if (updated == 0) {
|
||||
throw new AppException("수정할 컬럼 정책을 찾을 수 없습니다.");
|
||||
}
|
||||
protectedColumnsCache.clear();
|
||||
auditService.record(new AuditEvent("PROTECTED_COLUMN_POLICY_UPDATED", null, null, "SUCCESS", null, null,
|
||||
"columnId=" + columnId + ", " + normalizedLevel + "/" + normalizedMethod));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void disableObject(long objectId) {
|
||||
int updated = mapper.disableObject(objectId);
|
||||
@@ -211,6 +232,24 @@ public class ProtectedObjectService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private String defaultSensitivityLevel(String sensitiveYn) {
|
||||
return "Y".equalsIgnoreCase(sensitiveYn) ? "CONFIDENTIAL" : "PUBLIC";
|
||||
}
|
||||
|
||||
private String defaultRedactionMethod(String sensitiveYn) {
|
||||
return "Y".equalsIgnoreCase(sensitiveYn) ? "NULLIFY" : "NONE";
|
||||
}
|
||||
|
||||
private String normalizeOption(String value, String defaultValue, Set<String> allowed, String label) {
|
||||
String normalized = value == null || value.isBlank()
|
||||
? defaultValue
|
||||
: value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!allowed.contains(normalized)) {
|
||||
throw new AppException("허용되지 않은 " + label + "입니다: " + normalized);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private record CacheEntry<T>(T value, long expiresAt) {
|
||||
|
||||
boolean expired() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObjectCrea
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import com.cloudhandson.vpdbackoffice.service.OrdsMetadataService;
|
||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -28,8 +29,14 @@ public class ProtectedObjectController {
|
||||
|
||||
@GetMapping("/objects")
|
||||
public String objects(Model model) {
|
||||
model.addAttribute("objects", protectedObjectService.findEnabled());
|
||||
var objects = protectedObjectService.findEnabled();
|
||||
model.addAttribute("objects", objects);
|
||||
model.addAttribute("dbObjects", protectedObjectService.findDatabaseObjects());
|
||||
model.addAttribute("columnsByObject", objects.stream()
|
||||
.collect(Collectors.toMap(
|
||||
object -> object.objectId(),
|
||||
object -> protectedObjectService.findColumns(object.objectId())
|
||||
)));
|
||||
return "objects";
|
||||
}
|
||||
|
||||
@@ -65,6 +72,22 @@ public class ProtectedObjectController {
|
||||
return "redirect:/objects";
|
||||
}
|
||||
|
||||
@PostMapping("/objects/column-policy")
|
||||
public String updateColumnPolicy(
|
||||
@RequestParam long columnId,
|
||||
@RequestParam String sensitivityLevel,
|
||||
@RequestParam String redactionMethod,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
protectedObjectService.updateColumnPolicy(columnId, sensitivityLevel, redactionMethod);
|
||||
redirectAttributes.addFlashAttribute("message", "컬럼 민감도/마스킹 정책을 수정했습니다.");
|
||||
} catch (AppException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||
}
|
||||
return "redirect:/objects";
|
||||
}
|
||||
|
||||
@PostMapping("/objects/ords-handler")
|
||||
public String createOrdsHandler(@RequestParam long objectId, RedirectAttributes redirectAttributes) {
|
||||
try {
|
||||
|
||||
@@ -27,13 +27,25 @@ public class RoleController {
|
||||
public String create(
|
||||
@RequestParam String roleName,
|
||||
@RequestParam(required = false) String description,
|
||||
@RequestParam(defaultValue = "PUBLIC") String maxSensitivityLevel,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
permissionService.createRole(roleName, description);
|
||||
permissionService.createRole(roleName, description, maxSensitivityLevel);
|
||||
redirectAttributes.addFlashAttribute("message", "역할을 추가했습니다.");
|
||||
return "redirect:/roles";
|
||||
}
|
||||
|
||||
@PostMapping("/roles/max-sensitivity")
|
||||
public String updateMaxSensitivity(
|
||||
@RequestParam long roleId,
|
||||
@RequestParam String maxSensitivityLevel,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
permissionService.updateRoleMaxSensitivity(roleId, maxSensitivityLevel);
|
||||
redirectAttributes.addFlashAttribute("message", "역할 민감도 허용 상한을 수정했습니다.");
|
||||
return "redirect:/roles";
|
||||
}
|
||||
|
||||
@PostMapping("/roles/delete")
|
||||
public String delete(@RequestParam long roleId, RedirectAttributes redirectAttributes) {
|
||||
permissionService.deleteRole(roleId);
|
||||
|
||||
Reference in New Issue
Block a user