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;
|
||||
|
||||
public record McpReasoningCommand(
|
||||
Long tokenKeyId,
|
||||
long objectId,
|
||||
String bearerToken,
|
||||
int limit,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.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());
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -75,11 +75,24 @@ public class OrdsProbeService {
|
||||
));
|
||||
}
|
||||
|
||||
BearerTokenRecord token = tokenService.findByPlainToken(command.bearerToken());
|
||||
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(
|
||||
ProbeStatus.TOKEN_INACTIVE, "TOKEN_INACTIVE", "만료되었거나 회수된 토큰입니다."));
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,12 +335,29 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.token-form {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.token-form .rw-btn-primary {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
color: var(--rw-muted);
|
||||
font-size: .875rem;
|
||||
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 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
@@ -547,6 +564,29 @@ body {
|
||||
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 {
|
||||
align-items: center;
|
||||
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) {
|
||||
return `'${String(value || '').replaceAll("'", "''")}'`;
|
||||
}
|
||||
@@ -745,6 +790,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
select.addEventListener('change', () => filterGroupDetail(select.id));
|
||||
filterGroupDetail(select.id);
|
||||
});
|
||||
initTokenContextPreviews();
|
||||
const objectSelect = document.querySelector('select[name="objectRef"]');
|
||||
if (objectSelect) {
|
||||
objectSelect.addEventListener('change', () => {
|
||||
|
||||
@@ -22,12 +22,63 @@
|
||||
<h2>권한 결과 해석</h2>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/mcp/tools" target="_blank" rel="noreferrer">도구 JSON</a>
|
||||
</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}">
|
||||
<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>
|
||||
Bearer Token 원문
|
||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
|
||||
<span class="form-hint">선택 토큰은 컨텍스트 확인용입니다. ORDS 호출에는 원문 입력이 필요합니다.</span>
|
||||
</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>
|
||||
조회 대상
|
||||
<select class="form-select" id="mcp-reasoning-object" name="objectId" required>
|
||||
|
||||
@@ -16,12 +16,63 @@
|
||||
<h2>ORDS 호출</h2>
|
||||
<a class="btn btn-sm rw-btn-secondary" href="/mcp-reasoning">MCP Reasoning으로 해석</a>
|
||||
</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}">
|
||||
<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>
|
||||
Bearer Token 원문
|
||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
|
||||
<span class="form-hint">등록 토큰을 선택해도 원문은 저장되지 않아 실제 호출 시 필요합니다.</span>
|
||||
</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>
|
||||
ORDS 트랙
|
||||
<select class="form-select" name="objectId" required>
|
||||
|
||||
Reference in New Issue
Block a user