refs #741 #742: reorganize repository by application

This commit is contained in:
devmrko
2026-08-03 10:20:36 +09:00
parent 2e44ed0b97
commit e9d50e6a32
445 changed files with 1523 additions and 1885 deletions

View File

@@ -0,0 +1,33 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.AppException;
import org.springframework.dao.DataAccessException;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class AppExceptionHandler {
@ExceptionHandler(AppException.class)
public String handleAppException(AppException exception, Model model) {
model.addAttribute("errorMessage", exception.getMessage());
return "error";
}
@ExceptionHandler(DataAccessException.class)
public String handleDataAccessException(DataAccessException exception, Model model) {
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("errorTitle", error.title());
model.addAttribute("errorMessage", error.message());
return "error";
}
@ExceptionHandler(Exception.class)
public String handleUnexpectedException(Exception exception, Model model) {
RuntimeErrorMessage error = RuntimeErrorMessages.unexpected(exception);
model.addAttribute("errorTitle", error.title());
model.addAttribute("errorMessage", error.message());
return "error";
}
}

View File

@@ -0,0 +1,39 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.GroupService;
import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.UserService;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class DashboardController {
private final UserService userService;
private final GroupService groupService;
private final PermissionService permissionService;
public DashboardController(
UserService userService,
GroupService groupService,
PermissionService permissionService
) {
this.userService = userService;
this.groupService = groupService;
this.permissionService = permissionService;
}
@GetMapping("/")
public String dashboard(Model model) {
// The backoffice home is an identity-administration landing page.
// It intentionally does not query legacy CB_* VPD catalog objects.
model.addAttribute("users", userService.findAll());
model.addAttribute("groups", groupService.findAll());
model.addAttribute("objects", List.of());
model.addAttribute("roles", permissionService.findRoles());
model.addAttribute("tokens", List.of());
return "dashboard";
}
}

View File

@@ -0,0 +1,31 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.effective.EffectiveMatrixView;
import com.cloudhandson.vpdbackoffice.service.EffectiveMatrixService;
import java.util.List;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class EffectiveMatrixController {
private final EffectiveMatrixService matrixService;
public EffectiveMatrixController(EffectiveMatrixService matrixService) {
this.matrixService = matrixService;
}
@GetMapping("/effective-matrix")
public String matrix(Model model) {
try {
model.addAttribute("matrix", matrixService.matrix());
} catch (DataAccessException e) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
model.addAttribute("matrix", new EffectiveMatrixView(List.of(), List.of(), List.of(), 0));
model.addAttribute("runtimeError", message);
}
return "effective-matrix";
}
}

View File

@@ -0,0 +1,136 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.effective.GroupEffectiveAccessView;
import com.cloudhandson.vpdbackoffice.domain.effective.RoleEffectiveImpactView;
import com.cloudhandson.vpdbackoffice.domain.group.GroupCreateCommand;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.EffectiveMatrixService;
import com.cloudhandson.vpdbackoffice.service.GroupService;
import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.UserService;
import java.util.Map;
import java.util.stream.Collectors;
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 GroupController {
private final GroupService groupService;
private final UserService userService;
private final PermissionService permissionService;
private final EffectiveMatrixService effectiveMatrixService;
public GroupController(
GroupService groupService,
UserService userService,
PermissionService permissionService,
EffectiveMatrixService effectiveMatrixService
) {
this.groupService = groupService;
this.userService = userService;
this.permissionService = permissionService;
this.effectiveMatrixService = effectiveMatrixService;
}
@GetMapping("/groups")
public String groups(Model model) {
var matrix = effectiveMatrixService.matrix();
model.addAttribute("groups", groupService.findAll());
model.addAttribute("users", userService.findAll());
model.addAttribute("roles", permissionService.findRoles());
model.addAttribute("groupUsers", groupService.findGroupUsers());
model.addAttribute("groupRoles", groupService.findGroupRoles());
model.addAttribute("groupImpactByGroupId", matrix.groups().stream()
.collect(Collectors.toMap(GroupEffectiveAccessView::groupId, impact -> impact)));
model.addAttribute("roleImpactByRoleId", matrix.roles().stream()
.collect(Collectors.toMap(RoleEffectiveImpactView::roleId, impact -> impact)));
return "groups";
}
@PostMapping("/groups")
public String create(
@RequestParam String groupCode,
@RequestParam String groupName,
@RequestParam(required = false) String description,
RedirectAttributes redirectAttributes
) {
groupService.createGroup(new GroupCreateCommand(groupCode, groupName, description));
redirectAttributes.addFlashAttribute("message", "그룹을 추가했습니다.");
return "redirect:/groups";
}
@PostMapping("/groups/active")
public String active(
@RequestParam long groupId,
@RequestParam boolean active,
@RequestParam(defaultValue = "false") boolean confirmImpact,
RedirectAttributes redirectAttributes
) {
try {
groupService.setActive(groupId, active, active || confirmImpact);
redirectAttributes.addFlashAttribute("message", "그룹 상태를 변경했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("error", exception.getMessage());
}
return "redirect:/groups";
}
@PostMapping("/groups/users")
public String addUser(
@RequestParam long groupId,
@RequestParam long userId,
RedirectAttributes redirectAttributes
) {
groupService.addUser(groupId, userId);
redirectAttributes.addFlashAttribute("message", "그룹에 사용자를 추가했습니다.");
return "redirect:/groups";
}
@PostMapping("/groups/users/delete")
public String removeUser(
@RequestParam long groupId,
@RequestParam long userId,
@RequestParam(defaultValue = "false") boolean confirmImpact,
RedirectAttributes redirectAttributes
) {
try {
groupService.removeUser(groupId, userId, confirmImpact);
redirectAttributes.addFlashAttribute("message", "그룹 사용자를 해제했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("error", exception.getMessage());
}
return "redirect:/groups";
}
@PostMapping("/groups/roles")
public String addRole(
@RequestParam long groupId,
@RequestParam long roleId,
RedirectAttributes redirectAttributes
) {
groupService.addRole(groupId, roleId);
redirectAttributes.addFlashAttribute("message", "그룹에 역할을 부여했습니다.");
return "redirect:/groups";
}
@PostMapping("/groups/roles/delete")
public String removeRole(
@RequestParam long groupId,
@RequestParam long roleId,
@RequestParam(defaultValue = "false") boolean confirmImpact,
RedirectAttributes redirectAttributes
) {
try {
groupService.removeRole(groupId, roleId, confirmImpact);
redirectAttributes.addFlashAttribute("message", "그룹 역할을 해제했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("error", exception.getMessage());
}
return "redirect:/groups";
}
}

View File

@@ -0,0 +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(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

@@ -0,0 +1,79 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.McpChatbotService;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
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.support.ServletUriComponentsBuilder;
@Controller
public class McpChatbotController {
private final McpChatbotService chatbotService;
private final BearerTokenService tokenService;
private final UserMapper userMapper;
public McpChatbotController(
McpChatbotService chatbotService,
BearerTokenService tokenService,
UserMapper userMapper
) {
this.chatbotService = chatbotService;
this.tokenService = tokenService;
this.userMapper = userMapper;
}
@GetMapping("/mcp-chatbot")
public String page(Model model) {
try {
model.addAttribute("users", userMapper.findAll());
} catch (DataAccessException exception) {
model.addAttribute("users", List.of());
model.addAttribute("runtimeError", RuntimeErrorMessages.dataAccess(exception));
}
return "mcp-chatbot";
}
@PostMapping("/mcp-chatbot")
public String chat(
@RequestParam(defaultValue = "vpd-live") String contextPath,
@RequestParam(defaultValue = "") String question,
@RequestParam(defaultValue = "") String bearerToken,
@RequestParam(required = false) Long tempUserId,
@RequestParam(defaultValue = "50") int limit,
HttpServletRequest request,
Model model
) {
try {
String serverOrigin = ServletUriComponentsBuilder.fromRequestUri(request)
.replacePath(null)
.replaceQuery(null)
.build()
.toUriString();
String effectiveToken = bearerToken;
Long temporaryKeyId = null;
if (tempUserId != null) {
var issued = tokenService.issueTemporaryToken(tempUserId, "MCP Chatbot 임시 실행");
effectiveToken = issued.plainToken();
temporaryKeyId = issued.keyId();
}
try {
model.addAttribute("result", chatbotService.chat(serverOrigin, contextPath, question, effectiveToken, limit));
} finally {
if (temporaryKeyId != null) {
tokenService.revokeToken(temporaryKeyId, "temporary chatbot completed");
}
}
} catch (Exception e) {
model.addAttribute("errorMessage", e.getMessage());
}
return "fragments/mcp-chatbot-result :: result";
}
}

View File

@@ -0,0 +1,59 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.McpClientDemoService;
import com.cloudhandson.vpdbackoffice.service.McpToolRegistry;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
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.servlet.support.ServletUriComponentsBuilder;
@Controller
public class McpClientDemoController {
private final McpToolRegistry toolRegistry;
private final McpClientDemoService demoService;
public McpClientDemoController(McpToolRegistry toolRegistry, McpClientDemoService demoService) {
this.toolRegistry = toolRegistry;
this.demoService = demoService;
}
@GetMapping("/mcp-client-demo")
public String page(Model model) {
addTools(model);
return "mcp-client-demo";
}
@PostMapping("/mcp-client-demo")
public String run(
HttpServletRequest request,
Model model
) {
addTools(model);
try {
String serverOrigin = ServletUriComponentsBuilder.fromRequestUri(request)
.replacePath(null)
.replaceQuery(null)
.build()
.toUriString();
model.addAttribute("result", demoService.run(serverOrigin, "default", "", "", 50));
} catch (Exception e) {
model.addAttribute("errorMessage", e.getMessage());
}
return "fragments/mcp-client-demo-result :: result";
}
private void addTools(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);
}
}
}

View File

@@ -0,0 +1,99 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.config.McpProperties;
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.List;
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.bind.annotation.ResponseBody;
@Controller
public class McpReasoningController {
private final McpToolRegistry toolRegistry;
private final McpSseService mcpSseService;
private final McpReasoningService reasoningService;
private final BearerTokenService tokenService;
private final UserMapper userMapper;
private final McpProperties mcpProperties;
public McpReasoningController(
McpToolRegistry toolRegistry,
McpSseService mcpSseService,
McpReasoningService reasoningService,
BearerTokenService tokenService,
UserMapper userMapper,
McpProperties mcpProperties
) {
this.toolRegistry = toolRegistry;
this.mcpSseService = mcpSseService;
this.reasoningService = reasoningService;
this.tokenService = tokenService;
this.userMapper = userMapper;
this.mcpProperties = mcpProperties;
}
@GetMapping("/mcp-reasoning")
public String page(Model model) {
try {
model.addAttribute("tools", toolRegistry.listTools());
model.addAttribute("users", userMapper.findAll());
} catch (DataAccessException e) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
model.addAttribute("tools", List.of());
model.addAttribute("users", List.of());
model.addAttribute("runtimeError", message);
}
return "mcp-reasoning";
}
@GetMapping("/mcp/tools")
@ResponseBody
public Object tools() {
return mcpSseService.registeredTools();
}
@GetMapping("/mcp-sse")
public String ssePage(Model model) {
model.addAttribute("tools", mcpSseService.registeredTools());
model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl());
return "mcp-sse";
}
@PostMapping("/mcp-reasoning")
public String reason(
@RequestParam(defaultValue = "") String bearerToken,
@RequestParam(required = false) Long tempUserId,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(defaultValue = "") String question,
Model model
) {
String effectiveToken = bearerToken;
Long temporaryKeyId = null;
try {
if (tempUserId != null) {
var issued = tokenService.issueTemporaryToken(tempUserId, "MCP Reasoning 임시 실행");
effectiveToken = issued.plainToken();
temporaryKeyId = issued.keyId();
}
model.addAttribute("result", reasoningService.reason(
new McpReasoningCommand(temporaryKeyId, effectiveToken, limit, question)));
} catch (Exception exception) {
model.addAttribute("errorMessage", exception.getMessage());
} finally {
if (temporaryKeyId != null) {
tokenService.revokeToken(temporaryKeyId, "temporary reasoning completed");
}
}
return "fragments/mcp-reasoning-result :: result";
}
}

View File

@@ -0,0 +1,156 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.McpSseService;
import com.cloudhandson.vpdbackoffice.service.McpUnauthorizedException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
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;
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;
@Controller
public class McpSseController {
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
private final McpSseService mcpSseService;
private final Map<String, McpSseSession> sessions = new ConcurrentHashMap<>();
public McpSseController(McpSseService mcpSseService) {
this.mcpSseService = mcpSseService;
}
/**
* Streamable HTTP transport for Codex and other current MCP clients.
* 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(
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@RequestBody JsonNode request
) {
try {
ObjectNode response = mcpSseService.handle(
"default", request, bearerToken(authorization));
// 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(response);
} catch (McpUnauthorizedException exception) {
return ResponseEntity.status(401).build();
}
}
@GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter defaultSse() throws IOException {
return openSse("default");
}
@GetMapping(path = "/mcp/{contextPath}/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter contextSse(@PathVariable String contextPath) throws IOException {
return openSse(contextPath);
}
@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, 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, authorization, request);
}
private SseEmitter openSse(String contextPath) throws IOException {
String normalizedContextPath = normalizeContextPath(contextPath);
String sessionId = UUID.randomUUID().toString();
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
sessions.put(sessionId, new McpSseSession(normalizedContextPath, emitter));
emitter.onCompletion(() -> sessions.remove(sessionId));
emitter.onTimeout(() -> sessions.remove(sessionId));
emitter.onError(error -> sessions.remove(sessionId));
emitter.send(SseEmitter.event()
.name("endpoint")
.data(messageEndpoint(normalizedContextPath, sessionId)));
return emitter;
}
private ResponseEntity<?> handleMessage(
String contextPath,
String sessionId,
String authorization,
JsonNode request
) throws IOException {
String normalizedContextPath = normalizeContextPath(contextPath);
ObjectNode response;
try {
response = mcpSseService.handle(
normalizedContextPath, request, bearerToken(authorization));
} catch (McpUnauthorizedException exception) {
return ResponseEntity.status(401).build();
}
if (sessionId == null || sessionId.isBlank()) {
return ResponseEntity.ok(response);
}
McpSseSession session = sessions.get(sessionId);
if (session == null || !session.contextPath().equals(normalizedContextPath)) {
return ResponseEntity.notFound().build();
}
try {
session.emitter().send(SseEmitter.event().name("message").data(response));
return ResponseEntity.accepted().build();
} catch (IOException e) {
sessions.remove(sessionId);
throw e;
}
}
private String messageEndpoint(String contextPath, String sessionId) {
if ("default".equals(contextPath)) {
return "/mcp/messages?sessionId=" + sessionId;
}
return "/mcp/" + contextPath + "/messages?sessionId=" + sessionId;
}
private String normalizeContextPath(String contextPath) {
String normalized = contextPath == null || contextPath.isBlank() ? "default" : contextPath.trim();
if (!normalized.matches("[A-Za-z0-9][A-Za-z0-9_-]{0,63}")) {
throw new IllegalArgumentException("MCP context path는 영문/숫자로 시작하고 영문/숫자/_/-만 사용할 수 있습니다: " + contextPath);
}
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

@@ -0,0 +1,30 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.operation.OperationHealthSummary;
import com.cloudhandson.vpdbackoffice.service.MaskingRuleService;
import com.cloudhandson.vpdbackoffice.service.OperationStatusService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class OperationStatusController {
private final OperationStatusService service;
private final MaskingRuleService maskingRuleService;
public OperationStatusController(OperationStatusService service, MaskingRuleService maskingRuleService) {
this.service = service;
this.maskingRuleService = maskingRuleService;
}
@GetMapping("/operation-status")
public String status(Model model) {
var rows = service.findRows();
var maskingPolicyStatuses = maskingRuleService.findPolicyStatuses();
model.addAttribute("rows", rows);
model.addAttribute("maskingPolicyStatuses", maskingPolicyStatuses);
model.addAttribute("summary", OperationHealthSummary.from(rows, maskingPolicyStatuses));
return "operation-status";
}
}

View File

@@ -0,0 +1,47 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.ords.OrdsHandlerUpdateCommand;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.OrdsMetadataService;
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 OrdsHandlerController {
private final OrdsMetadataService ordsMetadataService;
public OrdsHandlerController(OrdsMetadataService ordsMetadataService) {
this.ordsMetadataService = ordsMetadataService;
}
@GetMapping("/ords-handlers")
public String handlers(Model model) {
model.addAttribute("handlers", ordsMetadataService.findHandlers());
return "ords-handlers";
}
@PostMapping("/ords-handlers/update")
public String update(
@RequestParam long handlerId,
@RequestParam String source,
RedirectAttributes redirectAttributes
) {
try {
ordsMetadataService.updateHandlerSource(new OrdsHandlerUpdateCommand(handlerId, source));
redirectAttributes.addFlashAttribute("message", "ORDS Handler Source를 저장했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", error.message());
}
return "redirect:/ords-handlers";
}
}

View File

@@ -0,0 +1,287 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.GroupService;
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.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
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;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class PermissionController {
private static final Logger log = LoggerFactory.getLogger(PermissionController.class);
private final PermissionService permissionService;
private final ProtectedObjectService protectedObjectService;
private final UserService userService;
private final GroupService groupService;
public PermissionController(
PermissionService permissionService,
ProtectedObjectService protectedObjectService,
UserService userService,
GroupService groupService
) {
this.permissionService = permissionService;
this.protectedObjectService = protectedObjectService;
this.userService = userService;
this.groupService = groupService;
}
@GetMapping("/permissions/object-columns")
@ResponseBody
public List<String> objectColumns(@RequestParam String objectRef) {
if (objectRef == null || objectRef.isBlank()) {
return List.of();
}
if (objectRef.startsWith("protected:")) {
long objectId;
try {
objectId = Long.parseLong(objectRef.substring("protected:".length()));
} catch (NumberFormatException exception) {
return List.of();
}
return protectedObjectService.findColumns(objectId).stream()
.map(column -> column.columnName())
.toList();
}
if (objectRef.startsWith("db:")) {
String value = objectRef.substring("db:".length());
int dot = value.indexOf('.');
if (dot < 1 || dot == value.length() - 1) {
return List.of();
}
return protectedObjectService.findDatabaseColumns(value.substring(0, dot), value.substring(dot + 1));
}
return List.of();
}
@GetMapping("/permissions")
public String permissions(Model model) {
long started = System.nanoTime();
var objects = protectedObjectService.findEnabledWithPermissions();
long objectsAt = System.nanoTime();
var roles = permissionService.findRoles();
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 -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream()
.map(column -> column.columnName())
.toList()
));
var maskableColumnsByObject = objects.stream()
.collect(Collectors.toMap(
object -> object.objectId(),
object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream()
.filter(ProtectedColumn::sensitive)
.map(column -> column.columnName())
.toList()
));
var maskableColumnLabelsByObject = objects.stream()
.collect(Collectors.toMap(
object -> object.objectId(),
object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream()
.filter(ProtectedColumn::sensitive)
.map(column -> column.columnName() + " [" + column.policyLabel() + "]")
.toList()
));
long columnsAt = System.nanoTime();
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
&& permissionCountByObject.getOrDefault(permission.objectId(), 0L) <= 1,
(left, right) -> left,
LinkedHashMap::new
));
long permissionsAt = System.nanoTime();
log.info("permissions page timings: objects={}ms roles={}ms roleImpact={}ms columns={}ms dbObjects={}ms permissions={}ms total={}ms",
elapsedMillis(started, objectsAt),
elapsedMillis(objectsAt, rolesAt),
elapsedMillis(rolesAt, roleImpactAt),
elapsedMillis(roleImpactAt, columnsAt),
elapsedMillis(columnsAt, dbObjectsAt),
elapsedMillis(dbObjectsAt, permissionsAt),
elapsedMillis(started, permissionsAt));
model.addAttribute("roles", roles);
model.addAttribute("objects", objects);
model.addAttribute("columnsByObject", columnsByObject);
model.addAttribute("maskableColumnsByObject", maskableColumnsByObject);
model.addAttribute("maskableColumnLabelsByObject", maskableColumnLabelsByObject);
model.addAttribute("directUsersByRole", roleImpact.directUsersByRole());
model.addAttribute("groupsByRole", roleImpact.groupsByRole());
model.addAttribute("groupUsersByRole", roleImpact.groupUsersByRole());
model.addAttribute("dbObjects", dbObjects);
model.addAttribute("permissions", permissions);
model.addAttribute("lastPermissionByPermissionId", lastPermissionByPermissionId);
return "permissions";
}
private RoleImpact buildRoleImpact(List<AppRole> roles) {
Map<Long, Set<String>> directUsersByRole = emptyRoleSetMap(roles);
userService.findUserRoles().forEach(row ->
directUsersByRole.computeIfAbsent(row.roleId(), ignored -> new LinkedHashSet<>()).add(row.username()));
Map<Long, Set<String>> groupsByRole = emptyRoleSetMap(roles);
Map<Long, List<String>> usersByGroup = groupService.findGroupUsers().stream()
.collect(Collectors.groupingBy(
row -> row.groupId(),
LinkedHashMap::new,
Collectors.mapping(row -> row.username(), Collectors.toList())
));
Map<Long, Set<String>> groupUsersByRole = emptyRoleSetMap(roles);
groupService.findGroupRoles().forEach(row -> {
groupsByRole.computeIfAbsent(row.roleId(), ignored -> new LinkedHashSet<>())
.add(row.groupCode() + " / " + row.groupName());
groupUsersByRole.computeIfAbsent(row.roleId(), ignored -> new LinkedHashSet<>())
.addAll(usersByGroup.getOrDefault(row.groupId(), List.of()));
});
return new RoleImpact(
toListMap(directUsersByRole),
toListMap(groupsByRole),
toListMap(groupUsersByRole)
);
}
private Map<Long, Set<String>> emptyRoleSetMap(List<AppRole> roles) {
Map<Long, Set<String>> map = new LinkedHashMap<>();
roles.forEach(role -> map.put(role.roleId(), new LinkedHashSet<>()));
return map;
}
private Map<Long, List<String>> toListMap(Map<Long, Set<String>> source) {
Map<Long, List<String>> map = new LinkedHashMap<>();
source.forEach((key, values) -> map.put(key, new ArrayList<>(values)));
return map;
}
private record RoleImpact(
Map<Long, List<String>> directUsersByRole,
Map<Long, List<String>> groupsByRole,
Map<Long, List<String>> groupUsersByRole
) {
}
private long elapsedMillis(long from, long to) {
return (to - from) / 1_000_000;
}
@PostMapping("/permissions")
public String save(
@RequestParam(required = false) Long roleId,
@RequestParam(required = false) String objectRef,
@RequestParam(defaultValue = "ALLOW") String permissionEffect,
@RequestParam(required = false) List<String> ruleColumn,
@RequestParam(required = false) List<String> ruleType,
@RequestParam(required = false) List<String> ruleValue,
RedirectAttributes redirectAttributes
) {
try {
if (roleId == null) {
throw new AppException("권한을 적용할 역할을 선택하세요.");
}
long objectId = resolveObjectId(objectRef);
permissionService.savePermissionSet(new PermissionSetCommand(
roleId,
objectId,
"SELECT",
permissionEffect,
buildRules(ruleColumn, ruleType, ruleValue),
List.of()
));
redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다.");
} catch (AppException | IllegalArgumentException exception) {
redirectAttributes.addFlashAttribute("error", exception.getMessage());
}
return "redirect:/permissions";
}
private List<RuleCommand> buildRules(
List<String> ruleColumns,
List<String> ruleTypes,
List<String> ruleValues
) {
if (ruleTypes == null || ruleTypes.isEmpty()) {
return List.of();
}
return java.util.stream.IntStream.range(0, ruleTypes.size())
.mapToObj(index -> new RuleCommand(
valueAt(ruleColumns, index),
valueAt(ruleTypes, index),
valueAt(ruleValues, index)
))
.toList();
}
private String valueAt(List<String> values, int index) {
return values == null || index >= values.size() ? null : values.get(index);
}
private long resolveObjectId(String objectRef) {
if (objectRef == null || objectRef.isBlank()) {
throw new IllegalArgumentException("보호 객체를 선택하세요.");
}
if (objectRef.startsWith("protected:")) {
return Long.parseLong(objectRef.substring("protected:".length()));
}
if (objectRef.startsWith("db:")) {
String value = objectRef.substring("db:".length());
int dot = value.indexOf('.');
if (dot < 1 || dot == value.length() - 1) {
throw new IllegalArgumentException("DB 객체 형식이 올바르지 않습니다.");
}
return protectedObjectService.ensureProtectedObject(value.substring(0, dot), value.substring(dot + 1)).objectId();
}
throw new IllegalArgumentException("보호 객체 형식이 올바르지 않습니다.");
}
@PostMapping("/permissions/delete")
public String delete(
@RequestParam long permissionId,
@RequestParam(defaultValue = "false") boolean confirmImpact,
RedirectAttributes redirectAttributes
) {
try {
permissionService.deletePermission(permissionId, confirmImpact);
redirectAttributes.addFlashAttribute("message", "권한을 삭제했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("error", exception.getMessage());
}
return "redirect:/permissions";
}
}

View File

@@ -0,0 +1,120 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorQueryEmbedding;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.OrdsProbeService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import com.cloudhandson.vpdbackoffice.service.VectorKnowledgeService;
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
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;
@Controller
public class ProbeController {
private final OrdsProbeService probeService;
private final ProtectedObjectService protectedObjectService;
private final BearerTokenService tokenService;
private final VpdPolicyService vpdPolicyService;
private final UserMapper userMapper;
private final VectorKnowledgeService vectorKnowledgeService;
public ProbeController(
OrdsProbeService probeService,
ProtectedObjectService protectedObjectService,
BearerTokenService tokenService,
VpdPolicyService vpdPolicyService,
UserMapper userMapper,
VectorKnowledgeService vectorKnowledgeService
) {
this.probeService = probeService;
this.protectedObjectService = protectedObjectService;
this.tokenService = tokenService;
this.vpdPolicyService = vpdPolicyService;
this.userMapper = userMapper;
this.vectorKnowledgeService = vectorKnowledgeService;
}
@GetMapping("/probe")
public String probe(Model model) {
Set<String> defaultObjectKeys = defaultPermissionObjectKeys();
List<ProtectedObject> objects =
protectedObjectService.findEnabled().stream()
.sorted(Comparator
.comparing((ProtectedObject object) ->
!defaultObjectKeys.contains(object.displayName()))
.thenComparing(ProtectedObject::displayName))
.toList();
model.addAttribute("objects", objects);
model.addAttribute("defaultObjectKeys", defaultObjectKeys);
model.addAttribute("users", userMapper.findAll());
model.addAttribute("aiEmbeddingConfigured", vectorKnowledgeService.aiEmbeddingConfigured());
return "probe";
}
@PostMapping("/probe")
public String run(
@RequestParam long objectId,
@RequestParam(defaultValue = "") String bearerToken,
@RequestParam(required = false) Long tempUserId,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String requestBody,
@RequestParam(defaultValue = "DEMO") String embeddingMode,
Model model
) {
String normalizedToken = bearerToken == null ? "" : bearerToken.trim();
ProtectedObject selectedObject = protectedObjectService.findEnabled().stream()
.filter(object -> object.objectId() == objectId)
.findFirst()
.orElse(null);
boolean vectorSearch = selectedObject != null
&& VectorKnowledgeService.VECTOR_OBJECT.equalsIgnoreCase(selectedObject.objectName());
model.addAttribute("vectorSearch", vectorSearch);
Long temporaryKeyId = null;
if (tempUserId != null) {
var issued = tokenService.issueTemporaryToken(tempUserId, "ORDS 검증 임시 실행");
normalizedToken = issued.plainToken();
temporaryKeyId = issued.keyId();
}
try {
if (vectorSearch) {
VectorQueryEmbedding vectorQuery = vectorKnowledgeService.vectorizeQuery(requestBody, embeddingMode);
requestBody = vectorQuery.requestBody();
model.addAttribute("vectorQuery", vectorQuery.query());
model.addAttribute("vectorEmbeddingMode", vectorQuery.embeddingMode());
model.addAttribute("vectorEmbeddingModel", vectorQuery.embeddingModel());
}
model.addAttribute("result", probeService.runProbe(
new ProbeCommand(temporaryKeyId, objectId, normalizedToken, limit, requestBody)));
model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
} finally {
if (temporaryKeyId != null) {
tokenService.revokeToken(temporaryKeyId, "temporary probe completed");
}
}
model.addAttribute("selectedObject", selectedObject);
return "fragments/probe-result :: result";
}
private Set<String> defaultPermissionObjectKeys() {
Set<String> result = new LinkedHashSet<>();
vpdPolicyService.findPolicies().stream()
.filter(policy -> policy.permissionSystemDefault() && "YES".equalsIgnoreCase(policy.enabled()))
.map(policy -> policy.objectDisplayName().toUpperCase())
.forEach(result::add);
return result;
}
}

View File

@@ -0,0 +1,41 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.config.McpProperties;
import com.cloudhandson.vpdbackoffice.config.ProductProperties;
import com.cloudhandson.vpdbackoffice.service.DataCatalog;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
/** Supplies deployment labels to every server-rendered page. */
@ControllerAdvice
public class ProductModelAdvice {
private final ProductProperties product;
private final DataCatalog catalog;
private final McpProperties mcp;
public ProductModelAdvice(
ProductProperties product,
DataCatalog catalog,
McpProperties mcp
) {
this.product = product;
this.catalog = catalog;
this.mcp = mcp;
}
@ModelAttribute("product")
ProductProperties product() {
return product;
}
@ModelAttribute("catalogOwner")
String catalogOwner() {
return catalog.owner();
}
@ModelAttribute("mcp")
McpProperties mcp() {
return mcp;
}
}

View File

@@ -0,0 +1,138 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObjectCreateCommand;
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;
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 ProtectedObjectController {
private final ProtectedObjectService protectedObjectService;
private final OrdsMetadataService ordsMetadataService;
public ProtectedObjectController(
ProtectedObjectService protectedObjectService,
OrdsMetadataService ordsMetadataService
) {
this.protectedObjectService = protectedObjectService;
this.ordsMetadataService = ordsMetadataService;
}
@GetMapping("/objects")
public String objects(Model model) {
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";
}
@PostMapping("/objects")
public String create(
@RequestParam String owner,
@RequestParam String objectName,
@RequestParam(required = false) String ordsPath,
@RequestParam(required = false) String description,
RedirectAttributes redirectAttributes
) {
try {
protectedObjectService.createObject(
new ProtectedObjectCreateCommand(owner, objectName, ordsPath, null, null, description));
redirectAttributes.addFlashAttribute("message", "ORDS 조회 Handler 대상을 추가했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/objects";
}
@PostMapping("/objects/description")
public String updateDescription(
@RequestParam long objectId,
@RequestParam(required = false) String description,
RedirectAttributes redirectAttributes
) {
try {
protectedObjectService.updateDescription(objectId, description);
redirectAttributes.addFlashAttribute("message", "조회 대상 설명을 저장했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/objects";
}
@PostMapping("/objects/ords-path")
public String updateOrdsPath(
@RequestParam long objectId,
@RequestParam String ordsPath,
RedirectAttributes redirectAttributes
) {
try {
protectedObjectService.updateOrdsPath(objectId, ordsPath);
redirectAttributes.addFlashAttribute("message", "ORDS Path를 수정했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
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 {
var result = ordsMetadataService.createObjectQueryHandler(objectId);
redirectAttributes.addFlashAttribute("message", "ORDS 조회 Handler를 생성했습니다: " + result.ordsPath());
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", error.message());
}
return "redirect:/objects";
}
@GetMapping("/objects/ords-handler-source")
public String ordsHandlerSource(@RequestParam long objectId, Model model) {
try {
model.addAttribute("object", protectedObjectService.assertEnabled(objectId));
model.addAttribute("source", ordsMetadataService.objectQueryHandlerSource(objectId));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
}
return "fragments/ords-handler-source :: source";
}
@PostMapping("/objects/disable")
public String disable(@RequestParam long objectId, RedirectAttributes redirectAttributes) {
protectedObjectService.disableObject(objectId);
redirectAttributes.addFlashAttribute("message", "ORDS 조회 Handler 대상을 비활성화했습니다.");
return "redirect:/objects";
}
}

View File

@@ -0,0 +1,73 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.effective.RoleEffectiveImpactView;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.EffectiveMatrixService;
import com.cloudhandson.vpdbackoffice.service.PermissionService;
import java.util.Map;
import java.util.stream.Collectors;
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 RoleController {
private final PermissionService permissionService;
private final EffectiveMatrixService effectiveMatrixService;
public RoleController(PermissionService permissionService, EffectiveMatrixService effectiveMatrixService) {
this.permissionService = permissionService;
this.effectiveMatrixService = effectiveMatrixService;
}
@GetMapping("/roles")
public String roles(Model model) {
model.addAttribute("roles", permissionService.findRoles());
Map<Long, RoleEffectiveImpactView> roleImpactByRoleId = effectiveMatrixService.matrix().roles().stream()
.collect(Collectors.toMap(RoleEffectiveImpactView::roleId, impact -> impact));
model.addAttribute("roleImpactByRoleId", roleImpactByRoleId);
return "roles";
}
@PostMapping("/roles")
public String create(
@RequestParam String roleName,
@RequestParam(required = false) String description,
@RequestParam(defaultValue = "PUBLIC") String maxSensitivityLevel,
RedirectAttributes redirectAttributes
) {
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,
@RequestParam(defaultValue = "false") boolean confirmImpact,
RedirectAttributes redirectAttributes
) {
try {
permissionService.deleteRole(roleId, confirmImpact);
redirectAttributes.addFlashAttribute("message", "역할을 삭제했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("error", exception.getMessage());
}
return "redirect:/roles";
}
}

View File

@@ -0,0 +1,8 @@
package com.cloudhandson.vpdbackoffice.web;
public record RuntimeErrorMessage(
String title,
String message,
boolean showSupportCommand
) {
}

View File

@@ -0,0 +1,71 @@
package com.cloudhandson.vpdbackoffice.web;
import java.sql.SQLException;
import java.util.Locale;
import org.springframework.dao.DataAccessException;
final class RuntimeErrorMessages {
private RuntimeErrorMessages() {
}
static RuntimeErrorMessage dataAccess(DataAccessException exception) {
String detail = detail(exception);
if (looksLikeConnectionProblem(exception, detail)) {
return new RuntimeErrorMessage(
"DB 연결 설정이 필요합니다.",
"ADB에 연결할 수 없습니다. BACKOFFICE_DB_URL, BACKOFFICE_DB_USERNAME, "
+ "BACKOFFICE_DB_PASSWORD를 확인하고 ./run.sh backoffice-support를 다시 실행하세요. 상세: "
+ detail,
true
);
}
return new RuntimeErrorMessage(
"데이터 처리 오류가 발생했습니다.",
"요청을 처리하는 중 DB 데이터 타입, SQL, 또는 매핑 오류가 발생했습니다. "
+ "입력값과 백오피스 테이블 스키마를 확인하세요. 상세: " + detail,
false
);
}
static RuntimeErrorMessage unexpected(Exception exception) {
return new RuntimeErrorMessage(
"요청 처리 중 오류가 발생했습니다.",
"예상하지 못한 오류가 발생했습니다. 입력값을 확인한 뒤 다시 시도하세요. 상세: "
+ safeMessage(exception),
false
);
}
private static boolean looksLikeConnectionProblem(DataAccessException exception, String detail) {
String text = (exception.getMessage() + " " + detail).toLowerCase(Locale.ROOT);
return text.contains("connection refused")
|| text.contains("the network adapter could not establish the connection")
|| text.contains("io error")
|| text.contains("ora-01017")
|| text.contains("ora-12154")
|| text.contains("ora-12514")
|| text.contains("ora-12541");
}
private static String detail(DataAccessException exception) {
Throwable cause = exception.getMostSpecificCause();
if (cause instanceof SQLException sqlException) {
return trim(sqlException.getMessage());
}
return trim(safeMessage(cause));
}
private static String safeMessage(Throwable throwable) {
String message = throwable.getMessage();
return trim(message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message);
}
private static String trim(String value) {
if (value == null || value.isBlank()) {
return "상세 메시지가 없습니다.";
}
String normalized = value.replaceAll("\\s+", " ").trim();
return normalized.length() <= 300 ? normalized : normalized.substring(0, 300) + "...";
}
}

View File

@@ -0,0 +1,109 @@
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("catalog", schemaMetadataService.catalog());
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,91 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.BackofficeSchemaService;
import com.cloudhandson.vpdbackoffice.service.SettingService;
import com.cloudhandson.vpdbackoffice.config.McpProperties;
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 SettingController {
private final SettingService settingService;
private final BackofficeSchemaService backofficeSchemaService;
private final McpProperties mcpProperties;
public SettingController(
SettingService settingService,
BackofficeSchemaService backofficeSchemaService,
McpProperties mcpProperties
) {
this.settingService = settingService;
this.backofficeSchemaService = backofficeSchemaService;
this.mcpProperties = mcpProperties;
}
@GetMapping("/settings")
public String settings(Model model) {
model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl());
try {
model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl());
} catch (DataAccessException exception) {
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("ordsBaseUrl", "");
model.addAttribute("runtimeErrorTitle", error.title());
model.addAttribute("runtimeErrorMessage", error.message());
}
return "settings";
}
@GetMapping("/settings/database")
public String databaseSettings(Model model) {
try {
model.addAttribute("schemaPreflight", backofficeSchemaService.preflight());
} catch (DataAccessException exception) {
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("schemaPreflightErrorTitle", error.title());
model.addAttribute("schemaPreflightErrorMessage", error.message());
}
return "settings-database";
}
@PostMapping("/settings/ords")
public String updateOrds(
@RequestParam String ordsBaseUrl,
RedirectAttributes redirectAttributes
) {
settingService.updateOrdsBaseUrl(ordsBaseUrl);
redirectAttributes.addFlashAttribute("message", ordsBaseUrl == null || ordsBaseUrl.isBlank()
? "레거시 ORDS 설정을 해제했습니다. HMM MCP 및 HR 질의에는 영향이 없습니다."
: "레거시 ORDS 설정을 저장했습니다.");
return "redirect:/settings";
}
@PostMapping("/settings/database/initialize")
public String initializeSchema(
@RequestParam String confirmation,
RedirectAttributes redirectAttributes
) {
if (!"DB 준비 실행".equals(confirmation)) {
redirectAttributes.addFlashAttribute(
"error", "DB 지원 DDL을 실행하려면 확인 문구 DB 준비 실행’을 정확히 입력하세요.");
return "redirect:/settings/database";
}
var results = backofficeSchemaService.initializeSchema();
long failures = results.stream()
.filter(result -> "FAILED".equalsIgnoreCase(result.status()))
.count();
redirectAttributes.addFlashAttribute("schemaResults", results);
if (failures > 0) {
redirectAttributes.addFlashAttribute("error", "백오피스 지원 DDL 실행 중 " + failures + "개 작업이 실패했습니다. 결과와 SQL을 확인하세요.");
} else {
redirectAttributes.addFlashAttribute("message", "백오피스 지원 테이블 DDL을 실행했습니다.");
}
return "redirect:/settings/database";
}
}

View File

@@ -0,0 +1,35 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.StructuredDataService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class StructuredDataController {
private final StructuredDataService structuredDataService;
public StructuredDataController(StructuredDataService structuredDataService) {
this.structuredDataService = structuredDataService;
}
@GetMapping("/structured-data")
public String structuredData(
@RequestParam(required = false) String table,
Model model
) {
String selectedKey = table == null || table.isBlank() ? structuredDataService.defaultKey() : table;
model.addAttribute("catalog", structuredDataService.catalog());
model.addAttribute("tables", structuredDataService.tables());
model.addAttribute("selectedKey", selectedKey);
try {
model.addAttribute("preview", structuredDataService.preview(selectedKey));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
}
return "structured-data";
}
}

View File

@@ -0,0 +1,79 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.UserService;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.time.format.DateTimeFormatter;
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 TokenController {
private final BearerTokenService tokenService;
private final UserService userService;
private final Clock clock;
public TokenController(BearerTokenService tokenService, UserService userService, Clock clock) {
this.tokenService = tokenService;
this.userService = userService;
this.clock = clock;
}
@GetMapping("/tokens")
public String tokens(
@RequestParam(defaultValue = "false") boolean includeInactive,
Model model
) {
model.addAttribute("tokens", tokenService.findAll(includeInactive));
model.addAttribute("includeInactive", includeInactive);
model.addAttribute("users", userService.findAll().stream().filter(user -> user.active()).toList());
model.addAttribute("defaultExpiresAt", defaultExpiresAt());
return "tokens";
}
@PostMapping("/tokens")
public String issue(
@RequestParam long userId,
@RequestParam String expiresAt,
@RequestParam(required = false) String description,
RedirectAttributes redirectAttributes
) {
var issued = tokenService.issueToken(new com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand(
userId, parseBrowserDateTime(expiresAt), description));
redirectAttributes.addFlashAttribute("issued", issued);
return "redirect:/tokens";
}
@PostMapping("/tokens/revoke")
public String revoke(
@RequestParam long keyId,
@RequestParam(required = false) String reason,
RedirectAttributes redirectAttributes
) {
tokenService.revokeToken(keyId, reason);
redirectAttributes.addFlashAttribute("message", "토큰을 회수했습니다.");
return "redirect:/tokens";
}
private String defaultExpiresAt() {
return LocalDateTime.now(clock.withZone(ZoneId.systemDefault()))
.plusMonths(1)
.truncatedTo(ChronoUnit.MINUTES)
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
private OffsetDateTime parseBrowserDateTime(String expiresAt) {
return LocalDateTime.parse(expiresAt)
.atZone(ZoneId.systemDefault())
.toOffsetDateTime();
}
}

View File

@@ -0,0 +1,76 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.user.UserCreateCommand;
import com.cloudhandson.vpdbackoffice.service.PermissionService;
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 UserController {
private final UserService userService;
private final PermissionService permissionService;
public UserController(UserService userService, PermissionService permissionService) {
this.userService = userService;
this.permissionService = permissionService;
}
@GetMapping("/users")
public String users(Model model) {
model.addAttribute("users", userService.findAll());
model.addAttribute("roles", permissionService.findRoles());
model.addAttribute("userRoles", userService.findUserRoles());
return "users";
}
@PostMapping("/users")
public String create(
@RequestParam String username,
@RequestParam String empNo,
@RequestParam String deptCode,
RedirectAttributes redirectAttributes
) {
userService.createUser(new UserCreateCommand(username, empNo, deptCode));
redirectAttributes.addFlashAttribute("message", "사용자를 추가했습니다.");
return "redirect:/users";
}
@PostMapping("/users/active")
public String active(
@RequestParam long userId,
@RequestParam boolean active,
RedirectAttributes redirectAttributes
) {
userService.setActive(userId, active);
redirectAttributes.addFlashAttribute("message", "사용자 상태를 변경했습니다.");
return "redirect:/users";
}
@PostMapping("/users/roles")
public String grantRole(
@RequestParam long userId,
@RequestParam long roleId,
RedirectAttributes redirectAttributes
) {
userService.grantRole(userId, roleId);
redirectAttributes.addFlashAttribute("message", "역할을 부여했습니다.");
return "redirect:/users";
}
@PostMapping("/users/roles/delete")
public String revokeRole(
@RequestParam long userId,
@RequestParam long roleId,
RedirectAttributes redirectAttributes
) {
userService.revokeRole(userId, roleId);
redirectAttributes.addFlashAttribute("message", "역할을 해제했습니다.");
return "redirect:/users";
}
}

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

@@ -0,0 +1,78 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestResult;
import com.cloudhandson.vpdbackoffice.service.VectorKnowledgeService;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
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 VectorKnowledgeController {
private final VectorKnowledgeService vectorKnowledgeService;
private final UserMapper userMapper;
public VectorKnowledgeController(VectorKnowledgeService vectorKnowledgeService, UserMapper userMapper) {
this.vectorKnowledgeService = vectorKnowledgeService;
this.userMapper = userMapper;
}
@GetMapping("/vector-knowledge")
public String page(Model model) {
model.addAttribute("users", userMapper.findAll());
model.addAttribute("aiEmbeddingConfigured", vectorKnowledgeService.aiEmbeddingConfigured());
try {
model.addAttribute("summary", vectorKnowledgeService.summary());
} catch (DataAccessException exception) {
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("summary", null);
model.addAttribute("runtimeError", error);
}
return "vector-knowledge";
}
@PostMapping("/vector-knowledge/ingest")
public String ingest(
@RequestParam String documentId,
@RequestParam String title,
@RequestParam(required = false, defaultValue = "") String sourceUri,
@RequestParam String content,
@RequestParam String techTags,
@RequestParam(defaultValue = "600") int chunkSize,
@RequestParam(defaultValue = "DEMO") String embeddingMode,
RedirectAttributes redirectAttributes
) {
try {
VectorIngestResult result = vectorKnowledgeService.ingest(new VectorIngestCommand(
documentId, title, sourceUri, content, techTags, chunkSize, embeddingMode));
redirectAttributes.addFlashAttribute("ingestResult", result);
redirectAttributes.addFlashAttribute("message",
result.chunkCount() + "개 청크를 저장했습니다. 태그 " + result.tagCount() + "개가 각 청크에 연결되었습니다.");
} catch (Exception exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/vector-knowledge";
}
@PostMapping("/vector-knowledge/search")
public String search(
@RequestParam long userId,
@RequestParam String query,
@RequestParam(defaultValue = "10") int limit,
@RequestParam(defaultValue = "DEMO") String embeddingMode,
Model model
) {
try {
model.addAttribute("searchResult", vectorKnowledgeService.search(userId, query, limit, embeddingMode));
} catch (Exception exception) {
model.addAttribute("errorMessage", exception.getMessage());
}
return "fragments/vector-search-result :: result";
}
}

View File

@@ -0,0 +1,458 @@
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;
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 VpdPolicyController {
private static final Logger log = LoggerFactory.getLogger(VpdPolicyController.class);
private final VpdPolicyService vpdPolicyService;
public VpdPolicyController(VpdPolicyService vpdPolicyService) {
this.vpdPolicyService = vpdPolicyService;
}
@GetMapping("/vpd-policies")
public String policies(
@RequestParam(required = false) String schemaOwner,
Model model
) {
populatePolicyModel(schemaOwner, false, model);
return "vpd-policies";
}
@GetMapping("/vpd-filter-policies")
public String filterPolicies(
@RequestParam(required = false) String schemaOwner,
Model model
) {
populatePolicyModel(schemaOwner, true, model);
return "vpd-filter-policies";
}
/** 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<>();
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", 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<>();
filterDescriptions.putAll(vpdPolicyService.findFilterDescriptionMap());
policies.forEach(policy -> filterDescriptions.putIfAbsent(
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<>();
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);
model.addAttribute("policies", List.of());
model.addAttribute("vpdTargets", List.of());
model.addAttribute("selectedSchemaOwner", "");
model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions());
model.addAttribute("policyDescriptions", Map.of());
model.addAttribute("filterDescriptions", Map.of());
model.addAttribute("filterPredicates", Map.of());
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
model.addAttribute("policies", List.of());
model.addAttribute("vpdTargets", List.of());
model.addAttribute("selectedSchemaOwner", "");
model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions());
model.addAttribute("policyDescriptions", Map.of());
model.addAttribute("filterDescriptions", Map.of());
model.addAttribute("filterPredicates", Map.of());
}
}
private static String defaultFilterDescription(String functionName) {
if ("HMM_LEAVE_VPD_FILTER".equalsIgnoreCase(functionName)) {
return "HMM 직원 본인·직접 보고 팀원·HR 관리자 역할을 행 조건으로 변환합니다.";
}
if ("CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName)) {
return "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다.";
}
return "이 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,
@RequestParam String policyName,
@RequestParam(required = false) String functionKey,
@RequestParam(required = false) String functionOwner,
@RequestParam(required = false) String functionName,
@RequestParam(defaultValue = "SELECT") List<String> statementTypes,
@RequestParam(defaultValue = "false") boolean enabled,
@RequestParam(defaultValue = "false") boolean updateCheck,
@RequestParam(required = false) String filterPredicate,
RedirectAttributes redirectAttributes
) {
createPolicyInternal(objectKey, policyName, functionKey, functionOwner, functionName, statementTypes, enabled,
updateCheck, filterPredicate, redirectAttributes);
return "redirect:/vpd-policies";
}
@PostMapping("/vpd-policies/default")
public String createDefaultPolicy(
@RequestParam String objectKey,
RedirectAttributes redirectAttributes
) {
try {
vpdPolicyService.createDefaultPermissionPolicy(objectKey);
redirectAttributes.addFlashAttribute(
"successMessage",
"권한체계 자동 필터를 연결했습니다: " + objectKey
);
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
}
return "redirect:/vpd-policies";
}
@PostMapping("/vpd-filter-policies/filters")
public String saveFilter(
@RequestParam(required = false) String functionOwner,
@RequestParam String functionName,
@RequestParam String filterPredicate,
@RequestParam(defaultValue = "Filter function이 반환하는 predicate로 조회 행을 제한합니다.") String description,
RedirectAttributes redirectAttributes
) {
try {
vpdPolicyService.saveFilterFunction(functionOwner, functionName, filterPredicate);
vpdPolicyService.saveFilterDescription(
functionOwner == null || functionOwner.isBlank()
? vpdPolicyService.currentUser()
: functionOwner,
functionName,
description
);
redirectAttributes.addFlashAttribute("successMessage", "Filter function을 저장했습니다: " + functionName);
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
}
return "redirect:/vpd-filter-policies";
}
@PostMapping("/vpd-policies/description")
public String savePolicyDescription(
@RequestParam String objectOwner,
@RequestParam String objectName,
@RequestParam String policyName,
@RequestParam String description,
RedirectAttributes redirectAttributes
) {
try {
vpdPolicyService.savePolicyDescription(objectOwner, objectName, policyName, description);
redirectAttributes.addFlashAttribute("successMessage", "Policy 설명을 저장했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/vpd-policies";
}
@PostMapping("/vpd-filter-policies/replace")
public String replaceFilterPolicy(
@RequestParam String oldObjectKey,
@RequestParam String oldPolicyName,
@RequestParam String objectKey,
@RequestParam String policyName,
@RequestParam(required = false) String functionKey,
@RequestParam(required = false) String functionOwner,
@RequestParam(required = false) String functionName,
@RequestParam(defaultValue = "SELECT") List<String> statementTypes,
@RequestParam(defaultValue = "false") boolean enabled,
@RequestParam(defaultValue = "false") boolean updateCheck,
@RequestParam(required = false) String filterPredicate,
RedirectAttributes redirectAttributes
) {
try {
String[] objectParts = objectKey.split("\\.", 2);
if (objectParts.length != 2) {
throw new AppException("조회 대상 형식이 올바르지 않습니다: " + objectKey);
}
vpdPolicyService.replacePolicy(oldObjectKey, oldPolicyName, new VpdPolicyCreateCommand(
objectParts[0],
objectParts[1],
policyName,
functionKey,
functionOwner,
functionName,
String.join(",", statementTypes),
enabled,
updateCheck,
filterPredicate
));
redirectAttributes.addFlashAttribute("successMessage", "Policy를 수정했습니다: " + policyName);
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
}
return "redirect:/vpd-filter-policies";
}
private void createPolicyInternal(
String objectKey,
String policyName,
String functionKey,
String functionOwner,
String functionName,
List<String> statementTypes,
boolean enabled,
boolean updateCheck,
String filterPredicate,
RedirectAttributes redirectAttributes
) {
try {
String[] objectParts = objectKey.split("\\.", 2);
if (objectParts.length != 2) {
throw new AppException("조회 대상 형식이 올바르지 않습니다: " + objectKey);
}
vpdPolicyService.createPolicy(new VpdPolicyCreateCommand(
objectParts[0],
objectParts[1],
policyName,
functionKey,
functionOwner,
functionName,
String.join(",", statementTypes),
enabled,
updateCheck,
filterPredicate
));
redirectAttributes.addFlashAttribute("successMessage", "VPD policy를 등록했습니다: " + policyName);
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
}
}
@PostMapping("/vpd-policies/bulk")
public String bulkApplyPolicy(
@RequestParam String schemaOwner,
@RequestParam(defaultValue = "false") boolean includeTables,
@RequestParam(defaultValue = "false") boolean includeViews,
@RequestParam(required = false) String policyName,
@RequestParam(required = false) String functionKey,
@RequestParam(required = false) String functionOwner,
@RequestParam(required = false) String functionName,
@RequestParam(defaultValue = "SELECT") List<String> statementTypes,
@RequestParam(defaultValue = "false") boolean enabled,
@RequestParam(defaultValue = "false") boolean updateCheck,
@RequestParam(required = false) String filterPredicate,
RedirectAttributes redirectAttributes
) {
bulkApplyPolicyInternal(schemaOwner, includeTables, includeViews, policyName, functionKey, functionOwner, functionName,
statementTypes, enabled, updateCheck, filterPredicate, redirectAttributes);
return "redirect:/vpd-policies";
}
private void bulkApplyPolicyInternal(
String schemaOwner,
boolean includeTables,
boolean includeViews,
String policyName,
String functionKey,
String functionOwner,
String functionName,
List<String> statementTypes,
boolean enabled,
boolean updateCheck,
String filterPredicate,
RedirectAttributes redirectAttributes
) {
try {
var result = vpdPolicyService.bulkApplySchema(
schemaOwner,
includeTables,
includeViews,
policyName,
functionKey,
functionOwner,
functionName,
String.join(",", statementTypes),
enabled,
updateCheck,
filterPredicate
);
redirectAttributes.addFlashAttribute("successMessage", result.summary());
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
}
}
@GetMapping("/vpd-policies/function-source")
public String functionSource(
@RequestParam String owner,
@RequestParam(required = false) String packageName,
@RequestParam String functionName,
Model model
) {
try {
model.addAttribute("source", vpdPolicyService.findFunctionSource(owner, packageName, functionName));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("errorMessage", message.message());
}
return "fragments/vpd-function-source :: source";
}
@GetMapping("/vpd-policies/policy-detail")
public String policyDetail(
@RequestParam String objectOwner,
@RequestParam String objectName,
@RequestParam String policyName,
Model model
) {
try {
model.addAttribute("detail", vpdPolicyService.findPolicyDetail(objectOwner, objectName, policyName));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("errorMessage", message.message());
}
return "fragments/vpd-policy-detail :: detail";
}
@GetMapping("/vpd-policies/object-filter-detail")
public String objectFilterDetail(
@RequestParam String objectOwner,
@RequestParam String objectName,
Model model
) {
try {
model.addAttribute("detail", vpdPolicyService.findObjectFilterDetail(objectOwner, objectName));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("errorMessage", message.message());
}
return "fragments/vpd-object-filter-detail :: detail";
}
@GetMapping("/vpd-policies/policy-explanation")
public String policyExplanation(
@RequestParam String objectOwner,
@RequestParam String objectName,
@RequestParam String policyName,
Model model
) {
try {
model.addAttribute("explanation", vpdPolicyService.explainPolicy(objectOwner, objectName, policyName));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("errorMessage", message.message());
}
return "fragments/vpd-policy-explanation :: explanation";
}
}