Consolidate data access control backoffice updates

This commit is contained in:
devmrko
2026-07-13 23:06:23 +09:00
parent 403298d474
commit e18b30feab
181 changed files with 11571 additions and 954 deletions

View File

@@ -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";
}
}

View File

@@ -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();
}
}

View File

@@ -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";
}

View File

@@ -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) {
}
}

View File

@@ -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";
}
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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 문법을 확인하세요.";
}
}

View File

@@ -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();
}
}

View File

@@ -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";
}
}

View File

@@ -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";
}
}

View File

@@ -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,