fix #492: add token context selection

This commit is contained in:
devmrko
2026-06-26 13:53:10 +09:00
parent cd7860e253
commit 210e0bd4d0
14 changed files with 383 additions and 12 deletions

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.domain.mcp;
public record McpReasoningCommand(
Long tokenKeyId,
long objectId,
String bearerToken,
int limit,

View File

@@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
public record ProbeCommand(
Long tokenKeyId,
@Positive long objectId,
@NotBlank String bearerToken,
@Min(1) @Max(500) int limit

View File

@@ -0,0 +1,37 @@
package com.cloudhandson.vpdbackoffice.domain.token;
import java.time.LocalDateTime;
import java.util.List;
public record TokenContextView(
long keyId,
long userId,
String username,
String keyPrefix,
LocalDateTime expiresAt,
LocalDateTime revokedAt,
String description,
boolean active,
List<String> directRoles,
List<String> groups,
List<String> inheritedRoles
) {
public String statusLabel() {
if (active) {
return "ACTIVE";
}
return revokedAt == null ? "EXPIRED" : "REVOKED";
}
public String maskedToken() {
if (keyPrefix == null || keyPrefix.isBlank()) {
return "****";
}
return keyPrefix + "****";
}
public String displayLabel() {
return "#" + keyId + " / " + username + " / " + statusLabel();
}
}

View File

@@ -2,17 +2,27 @@ package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView;
import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken;
import com.cloudhandson.vpdbackoffice.domain.token.TokenContextView;
import com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand;
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import com.cloudhandson.vpdbackoffice.domain.user.UserRoleView;
import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper;
import com.cloudhandson.vpdbackoffice.mapper.GroupMapper;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
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 org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -21,6 +31,7 @@ public class BearerTokenService {
private final BearerTokenMapper tokenMapper;
private final UserMapper userMapper;
private final GroupMapper groupMapper;
private final AuditService auditService;
private final TokenGenerator tokenGenerator;
private final TokenHasher tokenHasher;
@@ -30,6 +41,7 @@ public class BearerTokenService {
public BearerTokenService(
BearerTokenMapper tokenMapper,
UserMapper userMapper,
GroupMapper groupMapper,
AuditService auditService,
TokenGenerator tokenGenerator,
TokenHasher tokenHasher,
@@ -38,6 +50,7 @@ public class BearerTokenService {
) {
this.tokenMapper = tokenMapper;
this.userMapper = userMapper;
this.groupMapper = groupMapper;
this.auditService = auditService;
this.tokenGenerator = tokenGenerator;
this.tokenHasher = tokenHasher;
@@ -49,6 +62,31 @@ public class BearerTokenService {
return tokenMapper.findAll();
}
public List<TokenContextView> findTokenContextOptions() {
List<BearerTokenRecord> tokens = tokenMapper.findAll();
List<GroupUserView> groupUsers = groupMapper.findGroupUsers();
Map<Long, List<String>> directRolesByUser = directRolesByUser();
Map<Long, List<String>> groupsByUser = groupsByUser(groupUsers);
Map<Long, List<String>> inheritedRolesByUser = inheritedRolesByUser(groupUsers);
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
return tokens.stream()
.map(token -> new TokenContextView(
token.keyId(),
token.userId(),
token.username(),
token.keyPrefix(),
token.expiresAt(),
token.revokedAt(),
token.description(),
token.active(now),
directRolesByUser.getOrDefault(token.userId(), List.of()),
groupsByUser.getOrDefault(token.userId(), List.of()),
inheritedRolesByUser.getOrDefault(token.userId(), List.of())
))
.toList();
}
public BearerTokenRecord findById(long keyId) {
return tokenMapper.findById(keyId);
}
@@ -68,6 +106,45 @@ public class BearerTokenService {
return hash.equalsIgnoreCase(record.keyHash());
}
private Map<Long, List<String>> directRolesByUser() {
Map<Long, Set<String>> grouped = new LinkedHashMap<>();
for (UserRoleView view : userMapper.findUserRoles()) {
grouped.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).add(view.roleName());
}
return toListMap(grouped);
}
private Map<Long, List<String>> groupsByUser(List<GroupUserView> groupUsers) {
Map<Long, Set<String>> grouped = new LinkedHashMap<>();
for (GroupUserView view : groupUsers) {
grouped.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>())
.add(view.groupCode() + " / " + view.groupName());
}
return toListMap(grouped);
}
private Map<Long, List<String>> inheritedRolesByUser(List<GroupUserView> groupUsers) {
Map<Long, Set<String>> rolesByGroup = new LinkedHashMap<>();
for (GroupRoleView view : groupMapper.findGroupRoles()) {
rolesByGroup.computeIfAbsent(view.groupId(), key -> new LinkedHashSet<>()).add(view.roleName());
}
Map<Long, Set<String>> rolesByUser = new LinkedHashMap<>();
for (GroupUserView view : groupUsers) {
Set<String> roles = rolesByGroup.getOrDefault(view.groupId(), Set.of());
if (!roles.isEmpty()) {
rolesByUser.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).addAll(roles);
}
}
return toListMap(rolesByUser);
}
private Map<Long, List<String>> toListMap(Map<Long, Set<String>> source) {
Map<Long, List<String>> result = new LinkedHashMap<>();
source.forEach((key, value) -> result.put(key, new ArrayList<>(value)));
return result;
}
@Transactional
public IssuedToken issueToken(TokenIssueCommand command) {
AppUser user = userMapper.findById(command.userId());

View File

@@ -41,6 +41,7 @@ public class McpReasoningService {
ProtectedObject object = protectedObjectService.assertEnabled(command.objectId());
McpToolView tool = toolRegistry.toolFor(object);
ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(
command.tokenKeyId(),
command.objectId(),
command.bearerToken(),
normalizeLimit(command.limit())

View File

@@ -111,7 +111,7 @@ public class McpSseService {
McpToolView tool = findTool(toolName);
String bearerToken = arguments.path("bearerToken").asText("");
int limit = normalizeLimit(arguments.path("limit").asInt(50));
ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(tool.objectId(), bearerToken, limit));
ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(null, tool.objectId(), bearerToken, limit));
ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", tool.name());

View File

@@ -75,10 +75,23 @@ public class OrdsProbeService {
));
}
BearerTokenRecord token = tokenService.findByPlainToken(command.bearerToken());
if (token == null) {
return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다."));
BearerTokenRecord token;
if (command.tokenKeyId() == null) {
token = tokenService.findByPlainToken(command.bearerToken());
if (token == null) {
return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다."));
}
} else {
token = tokenService.findById(command.tokenKeyId());
if (token == null) {
return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.INVALID_TOKEN, "INVALID_TOKEN", "선택한 등록 토큰을 찾을 수 없습니다."));
}
if (!tokenService.matches(token, command.bearerToken())) {
return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.INVALID_TOKEN, "INVALID_TOKEN", "입력한 Bearer Token 원문이 선택한 등록 토큰과 일치하지 않습니다."));
}
}
if (!token.active(LocalDateTime.now(clock.withZone(ZoneId.systemDefault())))) {
return auditAndReturn(command, ProbeResult.blocked(
@@ -232,6 +245,9 @@ public class OrdsProbeService {
}
private Long tokenKeyId(ProbeCommand command) {
if (command.tokenKeyId() != null) {
return command.tokenKeyId();
}
BearerTokenRecord token = tokenService.findByPlainToken(command.bearerToken());
return token == null ? null : token.keyId();
}

View File

@@ -1,7 +1,7 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand;
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.McpReasoningService;
import com.cloudhandson.vpdbackoffice.service.McpToolRegistry;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
@@ -22,15 +22,18 @@ public class McpReasoningController {
private final McpToolRegistry toolRegistry;
private final McpReasoningService reasoningService;
private final ProtectedObjectService protectedObjectService;
private final BearerTokenService tokenService;
public McpReasoningController(
McpToolRegistry toolRegistry,
McpReasoningService reasoningService,
ProtectedObjectService protectedObjectService
ProtectedObjectService protectedObjectService,
BearerTokenService tokenService
) {
this.toolRegistry = toolRegistry;
this.reasoningService = reasoningService;
this.protectedObjectService = protectedObjectService;
this.tokenService = tokenService;
}
@GetMapping("/mcp-reasoning")
@@ -38,10 +41,12 @@ public class McpReasoningController {
try {
model.addAttribute("objects", protectedObjectService.findEnabled());
model.addAttribute("tools", toolRegistry.listTools());
model.addAttribute("tokens", tokenService.findTokenContextOptions());
} 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("runtimeError", message);
}
return "mcp-reasoning";
@@ -77,6 +82,7 @@ public class McpReasoningController {
@PostMapping("/mcp-reasoning")
public String reason(
@RequestParam(required = false) Long tokenKeyId,
@RequestParam long objectId,
@RequestParam String bearerToken,
@RequestParam(defaultValue = "50") int limit,
@@ -84,7 +90,7 @@ public class McpReasoningController {
Model model
) {
model.addAttribute("result", reasoningService.reason(
new McpReasoningCommand(objectId, bearerToken, limit, question)));
new McpReasoningCommand(tokenKeyId, objectId, bearerToken, limit, question)));
return "fragments/mcp-reasoning-result :: result";
}
}

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.OrdsProbeService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import org.springframework.stereotype.Controller;
@@ -14,29 +15,34 @@ public class ProbeController {
private final OrdsProbeService probeService;
private final ProtectedObjectService protectedObjectService;
private final BearerTokenService tokenService;
public ProbeController(
OrdsProbeService probeService,
ProtectedObjectService protectedObjectService
ProtectedObjectService protectedObjectService,
BearerTokenService tokenService
) {
this.probeService = probeService;
this.protectedObjectService = protectedObjectService;
this.tokenService = tokenService;
}
@GetMapping("/probe")
public String probe(Model model) {
model.addAttribute("objects", protectedObjectService.findEnabled());
model.addAttribute("tokens", tokenService.findTokenContextOptions());
return "probe";
}
@PostMapping("/probe")
public String run(
@RequestParam(required = false) Long tokenKeyId,
@RequestParam long objectId,
@RequestParam String bearerToken,
@RequestParam(defaultValue = "50") int limit,
Model model
) {
model.addAttribute("result", probeService.runProbe(new ProbeCommand(objectId, bearerToken, limit)));
model.addAttribute("result", probeService.runProbe(new ProbeCommand(tokenKeyId, objectId, bearerToken, limit)));
return "fragments/probe-result :: result";
}
}