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

@@ -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 없음

View File

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

View File

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

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.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());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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