fix #557: guide permission-driven VPD flow

This commit is contained in:
devmrko
2026-06-29 12:11:25 +09:00
parent fbe3d4682b
commit 908ac9a386
57 changed files with 1952 additions and 312 deletions

View File

@@ -1,7 +1,11 @@
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;
@@ -13,13 +17,27 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
public class McpChatbotController {
private final McpChatbotService chatbotService;
private final BearerTokenService tokenService;
private final UserMapper userMapper;
public McpChatbotController(McpChatbotService chatbotService) {
public McpChatbotController(
McpChatbotService chatbotService,
BearerTokenService tokenService,
UserMapper userMapper
) {
this.chatbotService = chatbotService;
this.tokenService = tokenService;
this.userMapper = userMapper;
}
@GetMapping("/mcp-chatbot")
public String page() {
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";
}
@@ -28,6 +46,7 @@ public class McpChatbotController {
@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
@@ -38,7 +57,20 @@ public class McpChatbotController {
.replaceQuery(null)
.build()
.toUriString();
model.addAttribute("result", chatbotService.chat(serverOrigin, contextPath, question, bearerToken, limit));
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());
}

View File

@@ -9,7 +9,6 @@ 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
@@ -31,10 +30,6 @@ public class McpClientDemoController {
@PostMapping("/mcp-client-demo")
public String run(
@RequestParam(defaultValue = "vpd-live") String contextPath,
@RequestParam(defaultValue = "") String toolName,
@RequestParam(defaultValue = "") String bearerToken,
@RequestParam(defaultValue = "50") int limit,
HttpServletRequest request,
Model model
) {
@@ -45,7 +40,7 @@ public class McpClientDemoController {
.replaceQuery(null)
.build()
.toUriString();
model.addAttribute("result", demoService.run(serverOrigin, contextPath, toolName, bearerToken, limit));
model.addAttribute("result", demoService.run(serverOrigin, "default", "", "", 50));
} catch (Exception e) {
model.addAttribute("errorMessage", e.getMessage());
}

View File

@@ -1,10 +1,10 @@
package com.cloudhandson.vpdbackoffice.web;
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.McpToolRegistry;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -21,32 +21,30 @@ public class McpReasoningController {
private final McpToolRegistry toolRegistry;
private final McpReasoningService reasoningService;
private final ProtectedObjectService protectedObjectService;
private final BearerTokenService tokenService;
private final UserMapper userMapper;
public McpReasoningController(
McpToolRegistry toolRegistry,
McpReasoningService reasoningService,
ProtectedObjectService protectedObjectService,
BearerTokenService tokenService
BearerTokenService tokenService,
UserMapper userMapper
) {
this.toolRegistry = toolRegistry;
this.reasoningService = reasoningService;
this.protectedObjectService = protectedObjectService;
this.tokenService = tokenService;
this.userMapper = userMapper;
}
@GetMapping("/mcp-reasoning")
public String page(Model model) {
try {
model.addAttribute("objects", protectedObjectService.findEnabled());
model.addAttribute("tools", toolRegistry.listTools());
model.addAttribute("tokens", tokenService.findTokenContextOptions());
model.addAttribute("users", userMapper.findAll());
} catch (DataAccessException e) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
model.addAttribute("objects", List.of());
model.addAttribute("tools", List.of());
model.addAttribute("tokens", List.of());
model.addAttribute("users", List.of());
model.addAttribute("runtimeError", message);
}
return "mcp-reasoning";
@@ -82,15 +80,29 @@ public class McpReasoningController {
@PostMapping("/mcp-reasoning")
public String reason(
@RequestParam(required = false) Long tokenKeyId,
@RequestParam long objectId,
@RequestParam String bearerToken,
@RequestParam(defaultValue = "") String bearerToken,
@RequestParam(required = false) Long tempUserId,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(defaultValue = "") String question,
Model model
) {
model.addAttribute("result", reasoningService.reason(
new McpReasoningCommand(tokenKeyId, objectId, bearerToken, limit, question)));
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

@@ -6,6 +6,7 @@ import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.OrdsProbeService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
@@ -23,17 +24,20 @@ public class ProbeController {
private final ProtectedObjectService protectedObjectService;
private final BearerTokenService tokenService;
private final VpdPolicyService vpdPolicyService;
private final UserMapper userMapper;
public ProbeController(
OrdsProbeService probeService,
ProtectedObjectService protectedObjectService,
BearerTokenService tokenService,
VpdPolicyService vpdPolicyService
VpdPolicyService vpdPolicyService,
UserMapper userMapper
) {
this.probeService = probeService;
this.protectedObjectService = protectedObjectService;
this.tokenService = tokenService;
this.vpdPolicyService = vpdPolicyService;
this.userMapper = userMapper;
}
@GetMapping("/probe")
@@ -48,19 +52,35 @@ public class ProbeController {
.toList();
model.addAttribute("objects", objects);
model.addAttribute("defaultObjectKeys", defaultObjectKeys);
model.addAttribute("users", userMapper.findAll());
return "probe";
}
@PostMapping("/probe")
public String run(
@RequestParam long objectId,
@RequestParam String bearerToken,
@RequestParam(defaultValue = "") String bearerToken,
@RequestParam(required = false) Long tempUserId,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String requestBody,
Model model
) {
String normalizedToken = bearerToken == null ? "" : bearerToken.trim();
model.addAttribute("result", probeService.runProbe(new ProbeCommand(null, objectId, normalizedToken, limit)));
model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken));
Long temporaryKeyId = null;
if (tempUserId != null) {
var issued = tokenService.issueTemporaryToken(tempUserId, "ORDS 검증 임시 실행");
normalizedToken = issued.plainToken();
temporaryKeyId = issued.keyId();
}
try {
model.addAttribute("result", probeService.runProbe(
new ProbeCommand(temporaryKeyId, objectId, normalizedToken, limit, requestBody)));
model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken));
} finally {
if (temporaryKeyId != null) {
tokenService.revokeToken(temporaryKeyId, "temporary probe completed");
}
}
model.addAttribute("selectedObject", protectedObjectService.findEnabled().stream()
.filter(object -> object.objectId() == objectId)
.findFirst()

View File

@@ -44,12 +44,13 @@ public class ProtectedObjectController {
public String create(
@RequestParam String owner,
@RequestParam String objectName,
@RequestParam String ordsPath,
@RequestParam(required = false) String ordsPath,
@RequestParam(required = false) String description,
RedirectAttributes redirectAttributes
) {
try {
protectedObjectService.createObject(
new ProtectedObjectCreateCommand(owner, objectName, ordsPath, null, null));
new ProtectedObjectCreateCommand(owner, objectName, ordsPath, null, null, description));
redirectAttributes.addFlashAttribute("message", "ORDS 조회 Handler 대상을 추가했습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
@@ -57,6 +58,21 @@ public class ProtectedObjectController {
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,
@@ -81,7 +97,7 @@ public class ProtectedObjectController {
) {
try {
protectedObjectService.updateColumnPolicy(columnId, sensitivityLevel, redactionMethod);
redirectAttributes.addFlashAttribute("message", "컬럼 민감도/마스킹 정책을 수정했습니다.");
redirectAttributes.addFlashAttribute("message", "컬럼 표시 보호 정책을 수정했습니다. 행 접근 권한은 변경되지 않습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}

View File

@@ -30,8 +30,12 @@ public class TokenController {
}
@GetMapping("/tokens")
public String tokens(Model model) {
model.addAttribute("tokens", tokenService.findAll());
public String tokens(
@RequestParam(defaultValue = "false") boolean includeInactive,
Model model
) {
model.addAttribute("tokens", tokenService.findAll(includeInactive));
model.addAttribute("includeInactive", includeInactive);
model.addAttribute("users", userMapper.findAll());
model.addAttribute("defaultExpiresAt", defaultExpiresAt());
return "tokens";

View File

@@ -4,6 +4,8 @@ import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
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.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -42,10 +44,34 @@ public class VpdPolicyController {
private void populatePolicyModel(String schemaOwner, Model model) {
try {
String selectedSchemaOwner = schemaOwner == null ? "" : schemaOwner.trim().toUpperCase();
model.addAttribute("policies", vpdPolicyService.findPolicies());
List<com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView> policies = vpdPolicyService.findPolicies();
Map<String, String> policyDescriptions = new LinkedHashMap<>();
policies.forEach(policy -> policyDescriptions.put(
policy.objectDisplayName() + "|" + policy.policyName(),
vpdPolicyService.findPolicyDescription(policy.objectOwner(), policy.objectName(), policy.policyName())
));
model.addAttribute("policies", policies);
model.addAttribute("policyDescriptions", policyDescriptions);
model.addAttribute("vpdTargets", vpdPolicyService.findVpdTargets(selectedSchemaOwner));
model.addAttribute("selectedSchemaOwner", selectedSchemaOwner);
model.addAttribute("formOptions", vpdPolicyService.formOptions());
var formOptions = vpdPolicyService.formOptions();
Map<String, String> filterDescriptions = new LinkedHashMap<>();
formOptions.functions().forEach(function -> filterDescriptions.put(
function.owner() + "|" + function.functionName(),
vpdPolicyService.findFilterDescription(function.owner(), function.functionName())
));
policies.forEach(policy -> filterDescriptions.putIfAbsent(
policy.functionOwner() + "|" + policy.functionName(),
vpdPolicyService.findFilterDescription(policy.functionOwner(), policy.functionName())
));
Map<String, String> filterPredicates = new LinkedHashMap<>();
formOptions.functions().forEach(function -> filterPredicates.put(
function.owner() + "|" + function.functionName(),
vpdPolicyService.findFilterPredicate(function.owner(), function.packageName(), function.functionName())
));
model.addAttribute("formOptions", formOptions);
model.addAttribute("filterDescriptions", filterDescriptions);
model.addAttribute("filterPredicates", filterPredicates);
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("runtimeError", message);
@@ -53,12 +79,18 @@ public class VpdPolicyController {
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());
}
}
@@ -105,10 +137,18 @@ public class VpdPolicyController {
@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());
@@ -119,6 +159,23 @@ public class VpdPolicyController {
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,