Consolidate data access control backoffice updates
This commit is contained in:
@@ -1,13 +1,23 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@Controller
|
||||
public class LoginController {
|
||||
|
||||
private final BackofficeProperties properties;
|
||||
|
||||
public LoginController(BackofficeProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@GetMapping("/login")
|
||||
public String login() {
|
||||
public String login(Model model) {
|
||||
model.addAttribute("rememberMeAvailable", properties.security().rememberMeConfigured());
|
||||
model.addAttribute("rememberMeDays", properties.security().rememberMeDays());
|
||||
return "login";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import com.cloudhandson.vpdbackoffice.service.MaskingRuleService;
|
||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
public class MaskingRuleController {
|
||||
|
||||
private final MaskingRuleService maskingRuleService;
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
|
||||
public MaskingRuleController(
|
||||
MaskingRuleService maskingRuleService,
|
||||
ProtectedObjectService protectedObjectService
|
||||
) {
|
||||
this.maskingRuleService = maskingRuleService;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
}
|
||||
|
||||
@GetMapping("/masking-rules")
|
||||
public String maskingRules(Model model) {
|
||||
var objects = protectedObjectService.findEnabled();
|
||||
var managedObjectNames = maskingRuleService.managedObjectNames();
|
||||
var policyStatuses = maskingRuleService.findPolicyStatuses();
|
||||
var columnsByObject = protectedObjectService.findColumnsByObjectIds(
|
||||
objects.stream().map(object -> object.objectId()).toList()
|
||||
);
|
||||
var maskingTargetObjects = objects.stream()
|
||||
.filter(object -> managedObjectNames.contains(object.objectName()))
|
||||
.toList();
|
||||
model.addAttribute("rules", maskingRuleService.findAllRules());
|
||||
model.addAttribute("templates", Arrays.asList(MaskingTemplate.values()));
|
||||
model.addAttribute("columnRules", maskingRuleService.findColumnRules());
|
||||
model.addAttribute("policyStatuses", policyStatuses);
|
||||
model.addAttribute("policyStatusByObjectName", policyStatuses.stream().collect(Collectors.toMap(
|
||||
status -> status.objectName(),
|
||||
status -> status,
|
||||
(left, right) -> left
|
||||
)));
|
||||
model.addAttribute("objects", objects);
|
||||
model.addAttribute("maskingTargetObjects", maskingTargetObjects);
|
||||
model.addAttribute("sensitiveColumnsByObject", objects.stream().collect(Collectors.toMap(
|
||||
object -> object.objectId(),
|
||||
object -> columnsByObject.getOrDefault(object.objectId(), List.of()).stream()
|
||||
.filter(column -> column.sensitive())
|
||||
.toList()
|
||||
)));
|
||||
model.addAttribute("availableMaskingColumnsByObject", maskingTargetObjects.stream().collect(Collectors.toMap(
|
||||
object -> object.objectId(),
|
||||
object -> availableMaskingColumns(object, columnsByObject.getOrDefault(object.objectId(), List.of()))
|
||||
)));
|
||||
return "masking-rules";
|
||||
}
|
||||
|
||||
@PostMapping("/masking-rules")
|
||||
public String createRule(
|
||||
@RequestParam String ruleCode,
|
||||
@RequestParam String ruleName,
|
||||
@RequestParam String templateCode,
|
||||
@RequestParam(required = false) String description,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
maskingRuleService.createRule(new MaskingRuleCreateCommand(ruleCode, ruleName, templateCode, description));
|
||||
redirectAttributes.addFlashAttribute("message", "컬럼 마스킹 규칙을 등록했습니다.");
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
|
||||
}
|
||||
return "redirect:/masking-rules";
|
||||
}
|
||||
|
||||
@PostMapping("/masking-rules/active")
|
||||
public String setRuleActive(
|
||||
@RequestParam long ruleId,
|
||||
@RequestParam boolean active,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
var result = maskingRuleService.setRuleActive(ruleId, active);
|
||||
redirectAttributes.addFlashAttribute("message", (active ? "컬럼 마스킹 규칙을 활성화했습니다. " : "컬럼 마스킹 규칙을 비활성화했습니다. ")
|
||||
+ result.summary());
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||
}
|
||||
return "redirect:/masking-rules";
|
||||
}
|
||||
|
||||
@PostMapping("/masking-rules/columns")
|
||||
public String assignColumnRule(
|
||||
@RequestParam long columnId,
|
||||
@RequestParam long ruleId,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
var result = maskingRuleService.assignRuleToColumn(columnId, ruleId);
|
||||
redirectAttributes.addFlashAttribute("message", "컬럼에 컬럼 마스킹 규칙을 연결했습니다. " + result.summary());
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||
}
|
||||
return "redirect:/masking-rules";
|
||||
}
|
||||
|
||||
@PostMapping("/masking-rules/target-columns")
|
||||
public String addTargetColumn(
|
||||
@RequestParam String target,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
String[] parts = target == null ? new String[0] : target.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
throw new AppException("추가할 보호 객체와 컬럼을 선택하세요.");
|
||||
}
|
||||
var result = maskingRuleService.addTargetColumn(Long.parseLong(parts[0]), parts[1]);
|
||||
redirectAttributes.addFlashAttribute("message",
|
||||
"마스킹 대상 컬럼으로 등록했습니다. 실제 적용하려면 아래에서 기본 규칙을 연결하세요. " + result.summary());
|
||||
} catch (NumberFormatException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "추가할 보호 객체와 컬럼을 선택하세요.");
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
|
||||
}
|
||||
return "redirect:/masking-rules";
|
||||
}
|
||||
|
||||
@PostMapping("/masking-rules/columns/delete")
|
||||
public String removeColumnRule(@RequestParam long columnId, RedirectAttributes redirectAttributes) {
|
||||
try {
|
||||
var result = maskingRuleService.removeRuleFromColumn(columnId);
|
||||
redirectAttributes.addFlashAttribute("message", "컬럼 마스킹 규칙과 관련 사용자 예외를 해제했습니다. " + result.summary());
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||
}
|
||||
return "redirect:/masking-rules";
|
||||
}
|
||||
|
||||
@PostMapping("/masking-rules/synchronize")
|
||||
public String synchronizeDatabasePolicies(RedirectAttributes redirectAttributes) {
|
||||
try {
|
||||
redirectAttributes.addFlashAttribute("message", maskingRuleService.synchronizeDatabasePolicies().summary());
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
|
||||
}
|
||||
return "redirect:/masking-rules";
|
||||
}
|
||||
|
||||
private String safeMessage(Exception exception) {
|
||||
return exception instanceof AppException ? exception.getMessage() : "컬럼 마스킹 규칙을 저장하지 못했습니다. 입력값과 DB 상태를 확인하세요.";
|
||||
}
|
||||
|
||||
private List<String> availableMaskingColumns(
|
||||
ProtectedObject object,
|
||||
List<ProtectedColumn> protectedColumns
|
||||
) {
|
||||
var registeredSensitiveColumns = new HashSet<String>();
|
||||
protectedColumns.stream()
|
||||
.filter(ProtectedColumn::sensitive)
|
||||
.map(ProtectedColumn::columnName)
|
||||
.forEach(registeredSensitiveColumns::add);
|
||||
return protectedObjectService.findDatabaseColumns(object.owner(), object.objectName()).stream()
|
||||
.filter(columnName -> !registeredSensitiveColumns.contains(columnName))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,9 @@ import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||
import com.cloudhandson.vpdbackoffice.service.McpReasoningService;
|
||||
import com.cloudhandson.vpdbackoffice.service.McpSseService;
|
||||
import com.cloudhandson.vpdbackoffice.service.McpToolRegistry;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -20,17 +19,20 @@ import org.springframework.web.bind.annotation.ResponseBody;
|
||||
public class McpReasoningController {
|
||||
|
||||
private final McpToolRegistry toolRegistry;
|
||||
private final McpSseService mcpSseService;
|
||||
private final McpReasoningService reasoningService;
|
||||
private final BearerTokenService tokenService;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
public McpReasoningController(
|
||||
McpToolRegistry toolRegistry,
|
||||
McpSseService mcpSseService,
|
||||
McpReasoningService reasoningService,
|
||||
BearerTokenService tokenService,
|
||||
UserMapper userMapper
|
||||
) {
|
||||
this.toolRegistry = toolRegistry;
|
||||
this.mcpSseService = mcpSseService;
|
||||
this.reasoningService = reasoningService;
|
||||
this.tokenService = tokenService;
|
||||
this.userMapper = userMapper;
|
||||
@@ -53,28 +55,12 @@ public class McpReasoningController {
|
||||
@GetMapping("/mcp/tools")
|
||||
@ResponseBody
|
||||
public Object tools() {
|
||||
try {
|
||||
return toolRegistry.listTools();
|
||||
} catch (DataAccessException e) {
|
||||
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("status", "DB_NOT_AVAILABLE");
|
||||
response.put("title", message.title());
|
||||
response.put("message", message.message());
|
||||
response.put("tools", List.of());
|
||||
return response;
|
||||
}
|
||||
return mcpSseService.registeredTools();
|
||||
}
|
||||
|
||||
@GetMapping("/mcp-sse")
|
||||
public String ssePage(Model model) {
|
||||
try {
|
||||
model.addAttribute("tools", toolRegistry.listTools());
|
||||
} catch (DataAccessException e) {
|
||||
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
|
||||
model.addAttribute("tools", List.of());
|
||||
model.addAttribute("runtimeError", message);
|
||||
}
|
||||
model.addAttribute("tools", mcpSseService.registeredTools());
|
||||
return "mcp-sse";
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
@@ -34,13 +36,16 @@ public class McpSseController {
|
||||
* The legacy SSE endpoints remain available for existing integrations.
|
||||
*/
|
||||
@PostMapping(path = "/mcp", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<ObjectNode> streamableMessage(@RequestBody JsonNode request) {
|
||||
public ResponseEntity<ObjectNode> streamableMessage(
|
||||
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
||||
@RequestBody JsonNode request
|
||||
) {
|
||||
// JSON-RPC notifications never receive a response body. Current MCP
|
||||
// clients send notifications/initialized immediately after initialize.
|
||||
if (request != null && !request.has("id")) {
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
return ResponseEntity.ok(mcpSseService.handle("default", request));
|
||||
return ResponseEntity.ok(mcpSseService.handle("default", request, bearerToken(authorization)));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
@@ -56,18 +61,20 @@ public class McpSseController {
|
||||
@PostMapping(path = "/mcp/messages", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<?> defaultMessage(
|
||||
@RequestParam(required = false) String sessionId,
|
||||
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
||||
@RequestBody JsonNode request
|
||||
) throws IOException {
|
||||
return handleMessage("default", sessionId, request);
|
||||
return handleMessage("default", sessionId, authorization, request);
|
||||
}
|
||||
|
||||
@PostMapping(path = "/mcp/{contextPath}/messages", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<?> contextMessage(
|
||||
@PathVariable String contextPath,
|
||||
@RequestParam(required = false) String sessionId,
|
||||
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
||||
@RequestBody JsonNode request
|
||||
) throws IOException {
|
||||
return handleMessage(contextPath, sessionId, request);
|
||||
return handleMessage(contextPath, sessionId, authorization, request);
|
||||
}
|
||||
|
||||
private SseEmitter openSse(String contextPath) throws IOException {
|
||||
@@ -84,9 +91,15 @@ public class McpSseController {
|
||||
return emitter;
|
||||
}
|
||||
|
||||
private ResponseEntity<?> handleMessage(String contextPath, String sessionId, JsonNode request) throws IOException {
|
||||
private ResponseEntity<?> handleMessage(
|
||||
String contextPath,
|
||||
String sessionId,
|
||||
String authorization,
|
||||
JsonNode request
|
||||
) throws IOException {
|
||||
String normalizedContextPath = normalizeContextPath(contextPath);
|
||||
ObjectNode response = mcpSseService.handle(normalizedContextPath, request);
|
||||
ObjectNode response = mcpSseService.handle(
|
||||
normalizedContextPath, request, bearerToken(authorization));
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
@@ -119,6 +132,13 @@ public class McpSseController {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
private String bearerToken(String authorization) {
|
||||
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
return "";
|
||||
}
|
||||
return authorization.substring(7).trim();
|
||||
}
|
||||
|
||||
private record McpSseSession(String contextPath, SseEmitter emitter) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.MaskingRuleService;
|
||||
import com.cloudhandson.vpdbackoffice.service.OperationStatusService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -9,14 +10,17 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
public class OperationStatusController {
|
||||
|
||||
private final OperationStatusService service;
|
||||
private final MaskingRuleService maskingRuleService;
|
||||
|
||||
public OperationStatusController(OperationStatusService service) {
|
||||
public OperationStatusController(OperationStatusService service, MaskingRuleService maskingRuleService) {
|
||||
this.service = service;
|
||||
this.maskingRuleService = maskingRuleService;
|
||||
}
|
||||
|
||||
@GetMapping("/operation-status")
|
||||
public String status(Model model) {
|
||||
model.addAttribute("rows", service.findRows());
|
||||
model.addAttribute("maskingPolicyStatuses", maskingRuleService.findPolicyStatuses());
|
||||
return "operation-status";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.cloudhandson.vpdbackoffice.service.PermissionService;
|
||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||
import com.cloudhandson.vpdbackoffice.service.UserService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -85,17 +84,20 @@ public class PermissionController {
|
||||
long rolesAt = System.nanoTime();
|
||||
var roleImpact = buildRoleImpact(roles);
|
||||
long roleImpactAt = System.nanoTime();
|
||||
var protectedColumnsByObject = protectedObjectService.findColumnsByObjectIds(
|
||||
objects.stream().map(object -> object.objectId()).toList()
|
||||
);
|
||||
var columnsByObject = objects.stream()
|
||||
.collect(Collectors.toMap(
|
||||
object -> object.objectId(),
|
||||
object -> protectedObjectService.findColumns(object.objectId()).stream()
|
||||
object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream()
|
||||
.map(column -> column.columnName())
|
||||
.toList()
|
||||
));
|
||||
var maskableColumnsByObject = objects.stream()
|
||||
.collect(Collectors.toMap(
|
||||
object -> object.objectId(),
|
||||
object -> protectedObjectService.findColumns(object.objectId()).stream()
|
||||
object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream()
|
||||
.filter(ProtectedColumn::sensitive)
|
||||
.map(column -> column.columnName())
|
||||
.toList()
|
||||
@@ -103,7 +105,7 @@ public class PermissionController {
|
||||
var maskableColumnLabelsByObject = objects.stream()
|
||||
.collect(Collectors.toMap(
|
||||
object -> object.objectId(),
|
||||
object -> protectedObjectService.findColumns(object.objectId()).stream()
|
||||
object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream()
|
||||
.filter(ProtectedColumn::sensitive)
|
||||
.map(column -> column.columnName() + " [" + column.policyLabel() + "]")
|
||||
.toList()
|
||||
@@ -112,11 +114,17 @@ public class PermissionController {
|
||||
var dbObjects = protectedObjectService.findDatabaseObjects();
|
||||
long dbObjectsAt = System.nanoTime();
|
||||
var permissions = permissionService.findPermissionViews();
|
||||
var permissionCountByObject = permissions.stream()
|
||||
.filter(permission -> permission.objectId() > 0)
|
||||
.collect(Collectors.groupingBy(
|
||||
permission -> permission.objectId(),
|
||||
Collectors.counting()
|
||||
));
|
||||
var lastPermissionByPermissionId = permissions.stream()
|
||||
.collect(Collectors.toMap(
|
||||
permission -> permission.permissionId(),
|
||||
permission -> permission.objectId() > 0
|
||||
&& permissionService.countPermissionsByObjectId(permission.objectId()) <= 1,
|
||||
&& permissionCountByObject.getOrDefault(permission.objectId(), 0L) <= 1,
|
||||
(left, right) -> left,
|
||||
LinkedHashMap::new
|
||||
));
|
||||
@@ -201,7 +209,6 @@ public class PermissionController {
|
||||
@RequestParam(required = false) List<String> ruleColumn,
|
||||
@RequestParam(required = false) List<String> ruleType,
|
||||
@RequestParam(required = false) List<String> ruleValue,
|
||||
@RequestParam(required = false) String visibleColumns,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
@@ -215,7 +222,7 @@ public class PermissionController {
|
||||
"SELECT",
|
||||
permissionEffect,
|
||||
buildRules(ruleColumn, ruleType, ruleValue),
|
||||
splitColumns(visibleColumns)
|
||||
List.of()
|
||||
));
|
||||
redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다.");
|
||||
} catch (AppException | IllegalArgumentException exception) {
|
||||
@@ -263,16 +270,6 @@ public class PermissionController {
|
||||
throw new IllegalArgumentException("보호 객체 형식이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
private List<String> splitColumns(String visibleColumns) {
|
||||
if (visibleColumns == null || visibleColumns.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.stream(visibleColumns.split(","))
|
||||
.map(String::trim)
|
||||
.filter(value -> !value.isBlank())
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/permissions/delete")
|
||||
public String delete(
|
||||
@RequestParam long permissionId,
|
||||
|
||||
@@ -53,7 +53,7 @@ final class RuntimeErrorMessages {
|
||||
if (cause instanceof SQLException sqlException) {
|
||||
return trim(sqlException.getMessage());
|
||||
}
|
||||
return trim(cause == null ? exception.getMessage() : safeMessage(cause));
|
||||
return trim(safeMessage(cause));
|
||||
}
|
||||
|
||||
private static String safeMessage(Throwable throwable) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import com.cloudhandson.vpdbackoffice.service.SchemaMetadataService;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
public class SchemaMetadataController {
|
||||
|
||||
private final SchemaMetadataService schemaMetadataService;
|
||||
|
||||
public SchemaMetadataController(SchemaMetadataService schemaMetadataService) {
|
||||
this.schemaMetadataService = schemaMetadataService;
|
||||
}
|
||||
|
||||
@GetMapping("/schema-metadata")
|
||||
public String schemaMetadata(@RequestParam(required = false) String table, Model model) {
|
||||
String selectedKey = table == null || table.isBlank() ? schemaMetadataService.defaultKey() : table;
|
||||
model.addAttribute("tables", schemaMetadataService.tables());
|
||||
model.addAttribute("selectedKey", selectedKey);
|
||||
try {
|
||||
model.addAttribute("metadata", schemaMetadataService.find(selectedKey));
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
model.addAttribute("errorMessage", safeMessage(exception));
|
||||
}
|
||||
return "schema-metadata";
|
||||
}
|
||||
|
||||
@PostMapping("/schema-metadata/table-comment")
|
||||
public String updateTableComment(
|
||||
@RequestParam String table,
|
||||
@RequestParam(required = false) String comment,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
schemaMetadataService.updateTableComment(table, comment);
|
||||
redirectAttributes.addFlashAttribute("message", "테이블 comment를 저장했습니다.");
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
|
||||
}
|
||||
return redirect(table);
|
||||
}
|
||||
|
||||
@PostMapping("/schema-metadata/column-comment")
|
||||
public String updateColumnComment(
|
||||
@RequestParam String table,
|
||||
@RequestParam String column,
|
||||
@RequestParam(required = false) String comment,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
schemaMetadataService.updateColumnComment(table, column, comment);
|
||||
redirectAttributes.addFlashAttribute("message", column + " 컬럼 comment를 저장했습니다.");
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
|
||||
}
|
||||
return redirect(table);
|
||||
}
|
||||
|
||||
@PostMapping("/schema-metadata/table-annotation")
|
||||
public String updateTableAnnotation(
|
||||
@RequestParam String table,
|
||||
@RequestParam String annotationName,
|
||||
@RequestParam(required = false) String annotationValue,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
schemaMetadataService.updateTableAnnotation(table, annotationName, annotationValue);
|
||||
redirectAttributes.addFlashAttribute("message", "테이블 annotation을 저장했습니다.");
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
|
||||
}
|
||||
return redirect(table);
|
||||
}
|
||||
|
||||
@PostMapping("/schema-metadata/column-annotation")
|
||||
public String updateColumnAnnotation(
|
||||
@RequestParam String table,
|
||||
@RequestParam String column,
|
||||
@RequestParam String annotationName,
|
||||
@RequestParam(required = false) String annotationValue,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
schemaMetadataService.updateColumnAnnotation(table, column, annotationName, annotationValue);
|
||||
redirectAttributes.addFlashAttribute("message", column + " 컬럼 annotation을 저장했습니다.");
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
|
||||
}
|
||||
return redirect(table);
|
||||
}
|
||||
|
||||
private String redirect(String table) {
|
||||
return "redirect:/schema-metadata?table=" + (table == null ? "" : table);
|
||||
}
|
||||
|
||||
private String safeMessage(Exception exception) {
|
||||
return exception instanceof AppException
|
||||
? exception.getMessage()
|
||||
: "DB 메타데이터를 저장하지 못했습니다. 권한, 식별자, Oracle annotation 문법을 확인하세요.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
@ControllerAdvice
|
||||
public class SecurityModelAdvice {
|
||||
|
||||
@ModelAttribute("canMutate")
|
||||
public boolean canMutate() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return false;
|
||||
}
|
||||
return authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch("ROLE_ADMIN"::equals);
|
||||
}
|
||||
|
||||
@ModelAttribute("readOnlyMode")
|
||||
public boolean readOnlyMode() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
return authentication != null && authentication.isAuthenticated() && !canMutate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import com.cloudhandson.vpdbackoffice.service.SecuritySqlScriptExplanationService;
|
||||
import com.cloudhandson.vpdbackoffice.service.SecuritySqlScriptService;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
/** Read-only page for the curated ASO, ORDS, and Select AI deployment scripts. */
|
||||
@Controller
|
||||
public class SecuritySqlScriptController {
|
||||
|
||||
private final SecuritySqlScriptService securitySqlScriptService;
|
||||
private final SecuritySqlScriptExplanationService explanationService;
|
||||
|
||||
public SecuritySqlScriptController(
|
||||
SecuritySqlScriptService securitySqlScriptService,
|
||||
SecuritySqlScriptExplanationService explanationService
|
||||
) {
|
||||
this.securitySqlScriptService = securitySqlScriptService;
|
||||
this.explanationService = explanationService;
|
||||
}
|
||||
|
||||
@GetMapping("/security-sql-scripts")
|
||||
public String scripts(@RequestParam(required = false) String script, Model model) {
|
||||
List<SecuritySqlScriptSummary> scripts = securitySqlScriptService.list();
|
||||
model.addAttribute("scripts", scripts);
|
||||
if (scripts.isEmpty()) {
|
||||
model.addAttribute("errorMessage", "표시할 보안 SQL 스크립트가 없습니다.");
|
||||
return "security-sql-scripts";
|
||||
}
|
||||
|
||||
String selectedId = script == null || script.isBlank() ? scripts.getFirst().scriptId() : script;
|
||||
try {
|
||||
model.addAttribute("selectedScript", securitySqlScriptService.find(selectedId));
|
||||
} catch (AppException exception) {
|
||||
model.addAttribute("errorMessage", exception.getMessage());
|
||||
model.addAttribute("selectedScript", securitySqlScriptService.find(scripts.getFirst().scriptId()));
|
||||
}
|
||||
return "security-sql-scripts";
|
||||
}
|
||||
|
||||
@PostMapping("/security-sql-scripts/explanation")
|
||||
public String explain(@RequestParam String script, Model model) {
|
||||
try {
|
||||
model.addAttribute("explanation", explanationService.explain(script));
|
||||
} catch (AppException exception) {
|
||||
model.addAttribute("errorMessage", exception.getMessage());
|
||||
}
|
||||
return "fragments/security-sql-script-explanation :: explanation";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import com.cloudhandson.vpdbackoffice.service.MaskingRuleService;
|
||||
import com.cloudhandson.vpdbackoffice.service.UserService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
public class UserMaskingRuleController {
|
||||
|
||||
private final MaskingRuleService maskingRuleService;
|
||||
private final UserService userService;
|
||||
|
||||
public UserMaskingRuleController(MaskingRuleService maskingRuleService, UserService userService) {
|
||||
this.maskingRuleService = maskingRuleService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping("/user-masking-rules")
|
||||
public String userMaskingRules(Model model) {
|
||||
model.addAttribute("users", userService.findAll());
|
||||
model.addAttribute("columnRules", maskingRuleService.findColumnRules().stream()
|
||||
.filter(columnRule -> columnRule.ruleEnabled())
|
||||
.toList());
|
||||
model.addAttribute("userRules", maskingRuleService.findUserRules());
|
||||
return "user-masking-rules";
|
||||
}
|
||||
|
||||
@PostMapping("/user-masking-rules")
|
||||
public String assign(
|
||||
@RequestParam long userId,
|
||||
@RequestParam long columnId,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
maskingRuleService.assignUserRule(userId, columnId, "UNMASK");
|
||||
redirectAttributes.addFlashAttribute("message", "컬럼 원문 표시 허용 사용자를 저장했습니다. 이 설정은 행 접근 권한을 추가하지 않습니다.");
|
||||
} catch (AppException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||
}
|
||||
return "redirect:/user-masking-rules";
|
||||
}
|
||||
|
||||
@PostMapping("/user-masking-rules/delete")
|
||||
public String remove(
|
||||
@RequestParam long userId,
|
||||
@RequestParam long columnId,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
maskingRuleService.removeUserRule(userId, columnId);
|
||||
redirectAttributes.addFlashAttribute("message", "컬럼 원문 표시 허용을 해제하고 컬럼의 기본 컬럼 마스킹 규칙으로 되돌렸습니다.");
|
||||
} catch (AppException exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||
}
|
||||
return "redirect:/user-masking-rules";
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView;
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -17,6 +20,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
@Controller
|
||||
public class VpdPolicyController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VpdPolicyController.class);
|
||||
private final VpdPolicyService vpdPolicyService;
|
||||
|
||||
public VpdPolicyController(VpdPolicyService vpdPolicyService) {
|
||||
@@ -28,7 +32,7 @@ public class VpdPolicyController {
|
||||
@RequestParam(required = false) String schemaOwner,
|
||||
Model model
|
||||
) {
|
||||
populatePolicyModel(schemaOwner, model);
|
||||
populatePolicyModel(schemaOwner, false, model);
|
||||
return "vpd-policies";
|
||||
}
|
||||
|
||||
@@ -37,41 +41,95 @@ public class VpdPolicyController {
|
||||
@RequestParam(required = false) String schemaOwner,
|
||||
Model model
|
||||
) {
|
||||
populatePolicyModel(schemaOwner, model);
|
||||
populatePolicyModel(schemaOwner, true, model);
|
||||
return "vpd-filter-policies";
|
||||
}
|
||||
|
||||
private void populatePolicyModel(String schemaOwner, Model model) {
|
||||
/** Read-only operational view of the default dynamic permission filter. */
|
||||
@GetMapping("/vpd-filter-runtime")
|
||||
public String filterRuntime(Model model) {
|
||||
try {
|
||||
List<VpdPolicyView> policies = vpdPolicyService.findPolicies().stream()
|
||||
.filter(VpdPolicyView::permissionSystemDefault)
|
||||
.toList();
|
||||
model.addAttribute("policies", policies);
|
||||
if (!policies.isEmpty()) {
|
||||
VpdPolicyView filter = policies.get(0);
|
||||
model.addAttribute("source", vpdPolicyService.findFunctionSource(
|
||||
filter.functionOwner(), filter.packageName(), filter.functionName()));
|
||||
}
|
||||
} catch (DataAccessException exception) {
|
||||
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
|
||||
model.addAttribute("runtimeError", message);
|
||||
model.addAttribute("policies", List.of());
|
||||
} catch (AppException exception) {
|
||||
model.addAttribute("errorMessage", exception.getMessage());
|
||||
model.addAttribute("policies", List.of());
|
||||
}
|
||||
return "vpd-filter-runtime";
|
||||
}
|
||||
|
||||
private void populatePolicyModel(String schemaOwner, boolean includeFilterEditor, Model model) {
|
||||
try {
|
||||
long started = System.nanoTime();
|
||||
String selectedSchemaOwner = schemaOwner == null ? "" : schemaOwner.trim().toUpperCase();
|
||||
List<com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView> policies = vpdPolicyService.findPolicies();
|
||||
long policiesAt = System.nanoTime();
|
||||
Map<String, String> policyDescriptions = new LinkedHashMap<>();
|
||||
policies.forEach(policy -> policyDescriptions.put(
|
||||
policy.objectDisplayName() + "|" + policy.policyName(),
|
||||
vpdPolicyService.findPolicyDescription(policy.objectOwner(), policy.objectName(), policy.policyName())
|
||||
));
|
||||
if (!includeFilterEditor) {
|
||||
policyDescriptions.putAll(vpdPolicyService.findPolicyDescriptionMap());
|
||||
policies.forEach(policy -> policyDescriptions.put(
|
||||
policy.objectDisplayName() + "|" + policy.policyName(),
|
||||
policyDescriptions.getOrDefault(
|
||||
policy.objectDisplayName() + "|" + policy.policyName(),
|
||||
policy.objectDisplayName() + "에 요청마다 현재 권한체계의 행 접근 조건을 적용하는 "
|
||||
+ policy.policyName() + " policy입니다."
|
||||
)
|
||||
));
|
||||
}
|
||||
long policyDescriptionsAt = System.nanoTime();
|
||||
model.addAttribute("policies", policies);
|
||||
model.addAttribute("policyDescriptions", policyDescriptions);
|
||||
model.addAttribute("vpdTargets", vpdPolicyService.findVpdTargets(selectedSchemaOwner));
|
||||
model.addAttribute("vpdTargets", includeFilterEditor
|
||||
? List.of()
|
||||
: vpdPolicyService.findVpdTargets(selectedSchemaOwner));
|
||||
long targetsAt = System.nanoTime();
|
||||
model.addAttribute("selectedSchemaOwner", selectedSchemaOwner);
|
||||
var formOptions = vpdPolicyService.formOptions();
|
||||
long formOptionsAt = System.nanoTime();
|
||||
Map<String, String> filterDescriptions = new LinkedHashMap<>();
|
||||
formOptions.functions().forEach(function -> filterDescriptions.put(
|
||||
function.owner() + "|" + function.functionName(),
|
||||
vpdPolicyService.findFilterDescription(function.owner(), function.functionName())
|
||||
));
|
||||
filterDescriptions.putAll(vpdPolicyService.findFilterDescriptionMap());
|
||||
policies.forEach(policy -> filterDescriptions.putIfAbsent(
|
||||
policy.functionOwner() + "|" + policy.functionName(),
|
||||
vpdPolicyService.findFilterDescription(policy.functionOwner(), policy.functionName())
|
||||
defaultFilterDescription(policy.functionName())
|
||||
));
|
||||
if (includeFilterEditor) {
|
||||
formOptions.functions().forEach(function -> filterDescriptions.putIfAbsent(
|
||||
function.owner() + "|" + function.functionName(),
|
||||
defaultFilterDescription(function.functionName())
|
||||
));
|
||||
}
|
||||
long filterDescriptionsAt = System.nanoTime();
|
||||
Map<String, String> filterPredicates = new LinkedHashMap<>();
|
||||
formOptions.functions().forEach(function -> filterPredicates.put(
|
||||
function.owner() + "|" + function.functionName(),
|
||||
vpdPolicyService.findFilterPredicate(function.owner(), function.packageName(), function.functionName())
|
||||
));
|
||||
if (includeFilterEditor) {
|
||||
formOptions.functions().forEach(function -> filterPredicates.put(
|
||||
function.owner() + "|" + function.functionName(),
|
||||
vpdPolicyService.findFilterPredicate(function.owner(), function.packageName(), function.functionName())
|
||||
));
|
||||
}
|
||||
long predicatesAt = System.nanoTime();
|
||||
model.addAttribute("formOptions", formOptions);
|
||||
model.addAttribute("filterDescriptions", filterDescriptions);
|
||||
model.addAttribute("filterPredicates", filterPredicates);
|
||||
log.info("vpd page timings: editor={} policies={}ms policyDescriptions={}ms targets={}ms formOptions={}ms filterDescriptions={}ms predicates={}ms total={}ms",
|
||||
includeFilterEditor,
|
||||
elapsedMillis(started, policiesAt),
|
||||
elapsedMillis(policiesAt, policyDescriptionsAt),
|
||||
elapsedMillis(policyDescriptionsAt, targetsAt),
|
||||
elapsedMillis(targetsAt, formOptionsAt),
|
||||
elapsedMillis(formOptionsAt, filterDescriptionsAt),
|
||||
elapsedMillis(filterDescriptionsAt, predicatesAt),
|
||||
elapsedMillis(started, predicatesAt));
|
||||
} catch (DataAccessException exception) {
|
||||
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
|
||||
model.addAttribute("runtimeError", message);
|
||||
@@ -94,6 +152,16 @@ public class VpdPolicyController {
|
||||
}
|
||||
}
|
||||
|
||||
private static String defaultFilterDescription(String functionName) {
|
||||
return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName)
|
||||
? "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다."
|
||||
: "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다.";
|
||||
}
|
||||
|
||||
private static long elapsedMillis(long started, long finished) {
|
||||
return (finished - started) / 1_000_000;
|
||||
}
|
||||
|
||||
@PostMapping("/vpd-policies")
|
||||
public String createPolicy(
|
||||
@RequestParam String objectKey,
|
||||
|
||||
Reference in New Issue
Block a user