fix #492: add token context selection
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
# Redmine #492 - ORDS 검증/Reasoning 토큰 선택 흐름 개선 설계
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이블로 제어하기 위한 Spring Boot 관리 도구다. ORDS 검증과 MCP Reasoning은 Bearer Token으로 보호 객체를 호출하고, VPD/컬럼 NULL 처리 결과를 확인한다.
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
ORDS 검증과 MCP Reasoning에서 등록된 토큰을 선택해 사용자/역할/만료 상태를 확인한 뒤 검증할 수 있게 한다.
|
||||||
|
|
||||||
|
## 제약
|
||||||
|
|
||||||
|
토큰 원문은 발급 직후 한 번만 표시되고 DB에는 `key_hash`와 prefix만 저장된다. 따라서 기존 등록 토큰을 선택하더라도 백오피스가 원문 bearer 값을 복원해 ORDS 호출에 사용할 수 없다.
|
||||||
|
|
||||||
|
## 설계
|
||||||
|
|
||||||
|
- 등록 토큰 select를 추가한다.
|
||||||
|
- 선택한 토큰의 사용자, prefix, 만료, 회수 상태, 직접 역할, 그룹, 그룹 상속 역할을 preview로 표시한다.
|
||||||
|
- 실제 ORDS 호출에는 기존처럼 bearer token 원문 입력이 필요하다.
|
||||||
|
- 사용자가 등록 토큰을 선택한 경우, 입력한 원문이 선택 token id의 hash와 일치하는지 service에서 검증한다.
|
||||||
|
- 선택 토큰과 원문이 맞지 않으면 ORDS 호출 전에 `INVALID_TOKEN`으로 차단한다.
|
||||||
|
- 원문 입력 없이 선택 토큰만으로 호출할 수 없다는 안내를 화면에 표시한다.
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
- `/probe`에서 등록 토큰 선택과 token context preview가 표시된다.
|
||||||
|
- `/mcp-reasoning`에서 등록 토큰 선택과 token context preview가 표시된다.
|
||||||
|
- 선택 토큰 변경 시 사용자/역할/만료 상태 preview가 즉시 갱신된다.
|
||||||
|
- 선택 토큰과 입력 원문이 불일치하면 ORDS 호출 전에 `INVALID_TOKEN`으로 실패한다.
|
||||||
|
- 기존 raw bearer token만 입력하는 흐름은 유지된다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `mvn test`
|
||||||
|
- Playwright:
|
||||||
|
- `/probe` token select/context preview 확인
|
||||||
|
- `/mcp-reasoning` token select/context preview 확인
|
||||||
|
- 390px 모바일 body overflow 없음
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.domain.mcp;
|
package com.cloudhandson.vpdbackoffice.domain.mcp;
|
||||||
|
|
||||||
public record McpReasoningCommand(
|
public record McpReasoningCommand(
|
||||||
|
Long tokenKeyId,
|
||||||
long objectId,
|
long objectId,
|
||||||
String bearerToken,
|
String bearerToken,
|
||||||
int limit,
|
int limit,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
|
|
||||||
public record ProbeCommand(
|
public record ProbeCommand(
|
||||||
|
Long tokenKeyId,
|
||||||
@Positive long objectId,
|
@Positive long objectId,
|
||||||
@NotBlank String bearerToken,
|
@NotBlank String bearerToken,
|
||||||
@Min(1) @Max(500) int limit
|
@Min(1) @Max(500) int limit
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,27 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
|
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.BearerTokenRecord;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken;
|
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.token.TokenIssueCommand;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
|
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.BearerTokenMapper;
|
||||||
|
import com.cloudhandson.vpdbackoffice.mapper.GroupMapper;
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -21,6 +31,7 @@ public class BearerTokenService {
|
|||||||
|
|
||||||
private final BearerTokenMapper tokenMapper;
|
private final BearerTokenMapper tokenMapper;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
|
private final GroupMapper groupMapper;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final TokenGenerator tokenGenerator;
|
private final TokenGenerator tokenGenerator;
|
||||||
private final TokenHasher tokenHasher;
|
private final TokenHasher tokenHasher;
|
||||||
@@ -30,6 +41,7 @@ public class BearerTokenService {
|
|||||||
public BearerTokenService(
|
public BearerTokenService(
|
||||||
BearerTokenMapper tokenMapper,
|
BearerTokenMapper tokenMapper,
|
||||||
UserMapper userMapper,
|
UserMapper userMapper,
|
||||||
|
GroupMapper groupMapper,
|
||||||
AuditService auditService,
|
AuditService auditService,
|
||||||
TokenGenerator tokenGenerator,
|
TokenGenerator tokenGenerator,
|
||||||
TokenHasher tokenHasher,
|
TokenHasher tokenHasher,
|
||||||
@@ -38,6 +50,7 @@ public class BearerTokenService {
|
|||||||
) {
|
) {
|
||||||
this.tokenMapper = tokenMapper;
|
this.tokenMapper = tokenMapper;
|
||||||
this.userMapper = userMapper;
|
this.userMapper = userMapper;
|
||||||
|
this.groupMapper = groupMapper;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
this.tokenGenerator = tokenGenerator;
|
this.tokenGenerator = tokenGenerator;
|
||||||
this.tokenHasher = tokenHasher;
|
this.tokenHasher = tokenHasher;
|
||||||
@@ -49,6 +62,31 @@ public class BearerTokenService {
|
|||||||
return tokenMapper.findAll();
|
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) {
|
public BearerTokenRecord findById(long keyId) {
|
||||||
return tokenMapper.findById(keyId);
|
return tokenMapper.findById(keyId);
|
||||||
}
|
}
|
||||||
@@ -68,6 +106,45 @@ public class BearerTokenService {
|
|||||||
return hash.equalsIgnoreCase(record.keyHash());
|
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
|
@Transactional
|
||||||
public IssuedToken issueToken(TokenIssueCommand command) {
|
public IssuedToken issueToken(TokenIssueCommand command) {
|
||||||
AppUser user = userMapper.findById(command.userId());
|
AppUser user = userMapper.findById(command.userId());
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public class McpReasoningService {
|
|||||||
ProtectedObject object = protectedObjectService.assertEnabled(command.objectId());
|
ProtectedObject object = protectedObjectService.assertEnabled(command.objectId());
|
||||||
McpToolView tool = toolRegistry.toolFor(object);
|
McpToolView tool = toolRegistry.toolFor(object);
|
||||||
ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(
|
ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(
|
||||||
|
command.tokenKeyId(),
|
||||||
command.objectId(),
|
command.objectId(),
|
||||||
command.bearerToken(),
|
command.bearerToken(),
|
||||||
normalizeLimit(command.limit())
|
normalizeLimit(command.limit())
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ public class McpSseService {
|
|||||||
McpToolView tool = findTool(toolName);
|
McpToolView tool = findTool(toolName);
|
||||||
String bearerToken = arguments.path("bearerToken").asText("");
|
String bearerToken = arguments.path("bearerToken").asText("");
|
||||||
int limit = normalizeLimit(arguments.path("limit").asInt(50));
|
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();
|
ObjectNode payload = objectMapper.createObjectNode();
|
||||||
payload.put("toolName", tool.name());
|
payload.put("toolName", tool.name());
|
||||||
|
|||||||
@@ -75,10 +75,23 @@ public class OrdsProbeService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
BearerTokenRecord token = tokenService.findByPlainToken(command.bearerToken());
|
BearerTokenRecord token;
|
||||||
if (token == null) {
|
if (command.tokenKeyId() == null) {
|
||||||
return auditAndReturn(command, ProbeResult.blocked(
|
token = tokenService.findByPlainToken(command.bearerToken());
|
||||||
ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다."));
|
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())))) {
|
if (!token.active(LocalDateTime.now(clock.withZone(ZoneId.systemDefault())))) {
|
||||||
return auditAndReturn(command, ProbeResult.blocked(
|
return auditAndReturn(command, ProbeResult.blocked(
|
||||||
@@ -232,6 +245,9 @@ public class OrdsProbeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Long tokenKeyId(ProbeCommand command) {
|
private Long tokenKeyId(ProbeCommand command) {
|
||||||
|
if (command.tokenKeyId() != null) {
|
||||||
|
return command.tokenKeyId();
|
||||||
|
}
|
||||||
BearerTokenRecord token = tokenService.findByPlainToken(command.bearerToken());
|
BearerTokenRecord token = tokenService.findByPlainToken(command.bearerToken());
|
||||||
return token == null ? null : token.keyId();
|
return token == null ? null : token.keyId();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.web;
|
package com.cloudhandson.vpdbackoffice.web;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand;
|
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.McpReasoningService;
|
||||||
import com.cloudhandson.vpdbackoffice.service.McpToolRegistry;
|
import com.cloudhandson.vpdbackoffice.service.McpToolRegistry;
|
||||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||||
@@ -22,15 +22,18 @@ public class McpReasoningController {
|
|||||||
private final McpToolRegistry toolRegistry;
|
private final McpToolRegistry toolRegistry;
|
||||||
private final McpReasoningService reasoningService;
|
private final McpReasoningService reasoningService;
|
||||||
private final ProtectedObjectService protectedObjectService;
|
private final ProtectedObjectService protectedObjectService;
|
||||||
|
private final BearerTokenService tokenService;
|
||||||
|
|
||||||
public McpReasoningController(
|
public McpReasoningController(
|
||||||
McpToolRegistry toolRegistry,
|
McpToolRegistry toolRegistry,
|
||||||
McpReasoningService reasoningService,
|
McpReasoningService reasoningService,
|
||||||
ProtectedObjectService protectedObjectService
|
ProtectedObjectService protectedObjectService,
|
||||||
|
BearerTokenService tokenService
|
||||||
) {
|
) {
|
||||||
this.toolRegistry = toolRegistry;
|
this.toolRegistry = toolRegistry;
|
||||||
this.reasoningService = reasoningService;
|
this.reasoningService = reasoningService;
|
||||||
this.protectedObjectService = protectedObjectService;
|
this.protectedObjectService = protectedObjectService;
|
||||||
|
this.tokenService = tokenService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/mcp-reasoning")
|
@GetMapping("/mcp-reasoning")
|
||||||
@@ -38,10 +41,12 @@ public class McpReasoningController {
|
|||||||
try {
|
try {
|
||||||
model.addAttribute("objects", protectedObjectService.findEnabled());
|
model.addAttribute("objects", protectedObjectService.findEnabled());
|
||||||
model.addAttribute("tools", toolRegistry.listTools());
|
model.addAttribute("tools", toolRegistry.listTools());
|
||||||
|
model.addAttribute("tokens", tokenService.findTokenContextOptions());
|
||||||
} catch (DataAccessException e) {
|
} catch (DataAccessException e) {
|
||||||
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
|
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
|
||||||
model.addAttribute("objects", List.of());
|
model.addAttribute("objects", List.of());
|
||||||
model.addAttribute("tools", List.of());
|
model.addAttribute("tools", List.of());
|
||||||
|
model.addAttribute("tokens", List.of());
|
||||||
model.addAttribute("runtimeError", message);
|
model.addAttribute("runtimeError", message);
|
||||||
}
|
}
|
||||||
return "mcp-reasoning";
|
return "mcp-reasoning";
|
||||||
@@ -77,6 +82,7 @@ public class McpReasoningController {
|
|||||||
|
|
||||||
@PostMapping("/mcp-reasoning")
|
@PostMapping("/mcp-reasoning")
|
||||||
public String reason(
|
public String reason(
|
||||||
|
@RequestParam(required = false) Long tokenKeyId,
|
||||||
@RequestParam long objectId,
|
@RequestParam long objectId,
|
||||||
@RequestParam String bearerToken,
|
@RequestParam String bearerToken,
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
@@ -84,7 +90,7 @@ public class McpReasoningController {
|
|||||||
Model model
|
Model model
|
||||||
) {
|
) {
|
||||||
model.addAttribute("result", reasoningService.reason(
|
model.addAttribute("result", reasoningService.reason(
|
||||||
new McpReasoningCommand(objectId, bearerToken, limit, question)));
|
new McpReasoningCommand(tokenKeyId, objectId, bearerToken, limit, question)));
|
||||||
return "fragments/mcp-reasoning-result :: result";
|
return "fragments/mcp-reasoning-result :: result";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.web;
|
package com.cloudhandson.vpdbackoffice.web;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
|
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||||
import com.cloudhandson.vpdbackoffice.service.OrdsProbeService;
|
import com.cloudhandson.vpdbackoffice.service.OrdsProbeService;
|
||||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
@@ -14,29 +15,34 @@ public class ProbeController {
|
|||||||
|
|
||||||
private final OrdsProbeService probeService;
|
private final OrdsProbeService probeService;
|
||||||
private final ProtectedObjectService protectedObjectService;
|
private final ProtectedObjectService protectedObjectService;
|
||||||
|
private final BearerTokenService tokenService;
|
||||||
|
|
||||||
public ProbeController(
|
public ProbeController(
|
||||||
OrdsProbeService probeService,
|
OrdsProbeService probeService,
|
||||||
ProtectedObjectService protectedObjectService
|
ProtectedObjectService protectedObjectService,
|
||||||
|
BearerTokenService tokenService
|
||||||
) {
|
) {
|
||||||
this.probeService = probeService;
|
this.probeService = probeService;
|
||||||
this.protectedObjectService = protectedObjectService;
|
this.protectedObjectService = protectedObjectService;
|
||||||
|
this.tokenService = tokenService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/probe")
|
@GetMapping("/probe")
|
||||||
public String probe(Model model) {
|
public String probe(Model model) {
|
||||||
model.addAttribute("objects", protectedObjectService.findEnabled());
|
model.addAttribute("objects", protectedObjectService.findEnabled());
|
||||||
|
model.addAttribute("tokens", tokenService.findTokenContextOptions());
|
||||||
return "probe";
|
return "probe";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/probe")
|
@PostMapping("/probe")
|
||||||
public String run(
|
public String run(
|
||||||
|
@RequestParam(required = false) Long tokenKeyId,
|
||||||
@RequestParam long objectId,
|
@RequestParam long objectId,
|
||||||
@RequestParam String bearerToken,
|
@RequestParam String bearerToken,
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
Model model
|
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";
|
return "fragments/probe-result :: result";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,12 +335,29 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token-form {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-form .rw-btn-primary {
|
||||||
|
align-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
.form-grid label {
|
.form-grid label {
|
||||||
color: var(--rw-muted);
|
color: var(--rw-muted);
|
||||||
font-size: .875rem;
|
font-size: .875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
color: var(--rw-muted);
|
||||||
|
display: block;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-top: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.form-grid .span-2 {
|
.form-grid .span-2 {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
@@ -547,6 +564,29 @@ body {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token-context-preview {
|
||||||
|
align-self: stretch;
|
||||||
|
background: var(--rw-surface);
|
||||||
|
border: 1px solid var(--rw-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-context-preview .compact-heading {
|
||||||
|
gap: .5rem;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-context-preview .compact-heading h3 {
|
||||||
|
font-size: .92rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-context-preview .form-hint {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.policy-apply-flow {
|
.policy-apply-flow {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -441,6 +441,51 @@ function setWizardPreview(wizard, name, value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setTokenPreview(preview, name, value) {
|
||||||
|
preview.querySelectorAll(`[data-token-preview="${name}"]`).forEach((target) => {
|
||||||
|
target.textContent = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTokenContextPreview(select) {
|
||||||
|
const form = select.closest('form');
|
||||||
|
const preview = form?.querySelector('[data-token-context-preview]');
|
||||||
|
if (!preview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const option = selectedOption(select);
|
||||||
|
if (!option || !option.value) {
|
||||||
|
setTokenPreview(preview, 'status', '미선택');
|
||||||
|
setTokenPreview(preview, 'username', '원문 직접 입력');
|
||||||
|
setTokenPreview(preview, 'prefix', '-');
|
||||||
|
setTokenPreview(preview, 'expiresAt', '-');
|
||||||
|
setTokenPreview(preview, 'directRoles', '-');
|
||||||
|
setTokenPreview(preview, 'groups', '-');
|
||||||
|
setTokenPreview(preview, 'inheritedRoles', '-');
|
||||||
|
setTokenPreview(preview, 'description', '토큰을 선택하면 등록된 사용자/역할 컨텍스트를 먼저 확인할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directRoles = splitList(option.dataset.directRoles || '');
|
||||||
|
const groups = splitList(option.dataset.groups || '');
|
||||||
|
const inheritedRoles = splitList(option.dataset.inheritedRoles || '');
|
||||||
|
setTokenPreview(preview, 'status', option.dataset.status || '-');
|
||||||
|
setTokenPreview(preview, 'username', option.dataset.username || '-');
|
||||||
|
setTokenPreview(preview, 'prefix', option.dataset.prefix || '****');
|
||||||
|
setTokenPreview(preview, 'expiresAt', option.dataset.expiresAt || '-');
|
||||||
|
setTokenPreview(preview, 'directRoles', formatList(directRoles, '직접 역할 없음'));
|
||||||
|
setTokenPreview(preview, 'groups', formatList(groups, '소속 그룹 없음'));
|
||||||
|
setTokenPreview(preview, 'inheritedRoles', formatList(inheritedRoles, '그룹 상속 역할 없음'));
|
||||||
|
setTokenPreview(preview, 'description', option.dataset.description || '설명 없음');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTokenContextPreviews() {
|
||||||
|
document.querySelectorAll('[data-token-context-select]').forEach((select) => {
|
||||||
|
select.addEventListener('change', () => updateTokenContextPreview(select));
|
||||||
|
updateTokenContextPreview(select);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function sqlLiteral(value) {
|
function sqlLiteral(value) {
|
||||||
return `'${String(value || '').replaceAll("'", "''")}'`;
|
return `'${String(value || '').replaceAll("'", "''")}'`;
|
||||||
}
|
}
|
||||||
@@ -745,6 +790,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
select.addEventListener('change', () => filterGroupDetail(select.id));
|
select.addEventListener('change', () => filterGroupDetail(select.id));
|
||||||
filterGroupDetail(select.id);
|
filterGroupDetail(select.id);
|
||||||
});
|
});
|
||||||
|
initTokenContextPreviews();
|
||||||
const objectSelect = document.querySelector('select[name="objectRef"]');
|
const objectSelect = document.querySelector('select[name="objectRef"]');
|
||||||
if (objectSelect) {
|
if (objectSelect) {
|
||||||
objectSelect.addEventListener('change', () => {
|
objectSelect.addEventListener('change', () => {
|
||||||
|
|||||||
@@ -22,12 +22,63 @@
|
|||||||
<h2>권한 결과 해석</h2>
|
<h2>권한 결과 해석</h2>
|
||||||
<a class="btn btn-sm btn-outline-secondary" href="/mcp/tools" target="_blank" rel="noreferrer">도구 JSON</a>
|
<a class="btn btn-sm btn-outline-secondary" href="/mcp/tools" target="_blank" rel="noreferrer">도구 JSON</a>
|
||||||
</div>
|
</div>
|
||||||
<form hx-post="/mcp-reasoning" hx-target="#mcp-result" hx-swap="innerHTML" class="form-grid">
|
<form hx-post="/mcp-reasoning" hx-target="#mcp-result" hx-swap="innerHTML" class="form-grid token-form">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<label>
|
||||||
|
등록 토큰
|
||||||
|
<select class="form-select" name="tokenKeyId" data-token-context-select>
|
||||||
|
<option value="">원문 직접 입력</option>
|
||||||
|
<option th:each="token : ${tokens}"
|
||||||
|
th:value="${token.keyId()}"
|
||||||
|
th:text="${token.displayLabel()}"
|
||||||
|
th:attr="data-username=${token.username()},
|
||||||
|
data-prefix=${token.maskedToken()},
|
||||||
|
data-status=${token.statusLabel()},
|
||||||
|
data-expires-at=${token.expiresAt()},
|
||||||
|
data-description=${token.description()},
|
||||||
|
data-direct-roles=${#strings.listJoin(token.directRoles(), '|')},
|
||||||
|
data-groups=${#strings.listJoin(token.groups(), '|')},
|
||||||
|
data-inherited-roles=${#strings.listJoin(token.inheritedRoles(), '|')}"></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Bearer Token 원문
|
Bearer Token 원문
|
||||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
|
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
|
||||||
|
<span class="form-hint">선택 토큰은 컨텍스트 확인용입니다. ORDS 호출에는 원문 입력이 필요합니다.</span>
|
||||||
</label>
|
</label>
|
||||||
|
<aside class="token-context-preview effective-preview span-2" data-token-context-preview>
|
||||||
|
<div class="section-heading compact-heading">
|
||||||
|
<h3>선택 토큰 컨텍스트</h3>
|
||||||
|
<span class="badge text-bg-secondary" data-token-preview="status">미선택</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>사용자</dt>
|
||||||
|
<dd data-token-preview="username">원문 직접 입력</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Prefix</dt>
|
||||||
|
<dd><code data-token-preview="prefix">-</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>만료</dt>
|
||||||
|
<dd data-token-preview="expiresAt">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>직접 역할</dt>
|
||||||
|
<dd data-token-preview="directRoles">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>그룹</dt>
|
||||||
|
<dd data-token-preview="groups">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>그룹 상속 역할</dt>
|
||||||
|
<dd data-token-preview="inheritedRoles">-</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="form-hint" data-token-preview="description">질문 전에 이 토큰이 어느 사용자/역할 컨텍스트인지 확인합니다.</p>
|
||||||
|
</aside>
|
||||||
<label>
|
<label>
|
||||||
조회 대상
|
조회 대상
|
||||||
<select class="form-select" id="mcp-reasoning-object" name="objectId" required>
|
<select class="form-select" id="mcp-reasoning-object" name="objectId" required>
|
||||||
|
|||||||
@@ -16,12 +16,63 @@
|
|||||||
<h2>ORDS 호출</h2>
|
<h2>ORDS 호출</h2>
|
||||||
<a class="btn btn-sm rw-btn-secondary" href="/mcp-reasoning">MCP Reasoning으로 해석</a>
|
<a class="btn btn-sm rw-btn-secondary" href="/mcp-reasoning">MCP Reasoning으로 해석</a>
|
||||||
</div>
|
</div>
|
||||||
<form hx-post="/probe" hx-target="#probe-result" hx-swap="innerHTML" class="form-grid">
|
<form hx-post="/probe" hx-target="#probe-result" hx-swap="innerHTML" class="form-grid token-form">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<label>
|
||||||
|
등록 토큰
|
||||||
|
<select class="form-select" name="tokenKeyId" data-token-context-select>
|
||||||
|
<option value="">원문 직접 입력</option>
|
||||||
|
<option th:each="token : ${tokens}"
|
||||||
|
th:value="${token.keyId()}"
|
||||||
|
th:text="${token.displayLabel()}"
|
||||||
|
th:attr="data-username=${token.username()},
|
||||||
|
data-prefix=${token.maskedToken()},
|
||||||
|
data-status=${token.statusLabel()},
|
||||||
|
data-expires-at=${token.expiresAt()},
|
||||||
|
data-description=${token.description()},
|
||||||
|
data-direct-roles=${#strings.listJoin(token.directRoles(), '|')},
|
||||||
|
data-groups=${#strings.listJoin(token.groups(), '|')},
|
||||||
|
data-inherited-roles=${#strings.listJoin(token.inheritedRoles(), '|')}"></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Bearer Token 원문
|
Bearer Token 원문
|
||||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
|
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
|
||||||
|
<span class="form-hint">등록 토큰을 선택해도 원문은 저장되지 않아 실제 호출 시 필요합니다.</span>
|
||||||
</label>
|
</label>
|
||||||
|
<aside class="token-context-preview effective-preview span-2" data-token-context-preview>
|
||||||
|
<div class="section-heading compact-heading">
|
||||||
|
<h3>선택 토큰 컨텍스트</h3>
|
||||||
|
<span class="badge text-bg-secondary" data-token-preview="status">미선택</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>사용자</dt>
|
||||||
|
<dd data-token-preview="username">원문 직접 입력</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Prefix</dt>
|
||||||
|
<dd><code data-token-preview="prefix">-</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>만료</dt>
|
||||||
|
<dd data-token-preview="expiresAt">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>직접 역할</dt>
|
||||||
|
<dd data-token-preview="directRoles">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>그룹</dt>
|
||||||
|
<dd data-token-preview="groups">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>그룹 상속 역할</dt>
|
||||||
|
<dd data-token-preview="inheritedRoles">-</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="form-hint" data-token-preview="description">토큰을 선택하면 등록된 사용자/역할 컨텍스트를 먼저 확인할 수 있습니다.</p>
|
||||||
|
</aside>
|
||||||
<label>
|
<label>
|
||||||
ORDS 트랙
|
ORDS 트랙
|
||||||
<select class="form-select" name="objectId" required>
|
<select class="form-select" name="objectId" required>
|
||||||
|
|||||||
Reference in New Issue
Block a user