diff --git a/docs/design/492-token-selection-for-probe-reasoning/README.md b/docs/design/492-token-selection-for-probe-reasoning/README.md new file mode 100644 index 0000000..25b9f70 --- /dev/null +++ b/docs/design/492-token-selection-for-probe-reasoning/README.md @@ -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 없음 diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java index 02086b9..3b137a4 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java @@ -1,6 +1,7 @@ package com.cloudhandson.vpdbackoffice.domain.mcp; public record McpReasoningCommand( + Long tokenKeyId, long objectId, String bearerToken, int limit, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java index 90b69a7..a830e74 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java @@ -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 diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java new file mode 100644 index 0000000..01cb575 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java @@ -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 directRoles, + List groups, + List 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(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java index fcb1547..cafd48d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java @@ -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 findTokenContextOptions() { + List tokens = tokenMapper.findAll(); + List groupUsers = groupMapper.findGroupUsers(); + Map> directRolesByUser = directRolesByUser(); + Map> groupsByUser = groupsByUser(groupUsers); + Map> 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> directRolesByUser() { + Map> grouped = new LinkedHashMap<>(); + for (UserRoleView view : userMapper.findUserRoles()) { + grouped.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).add(view.roleName()); + } + return toListMap(grouped); + } + + private Map> groupsByUser(List groupUsers) { + Map> grouped = new LinkedHashMap<>(); + for (GroupUserView view : groupUsers) { + grouped.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()) + .add(view.groupCode() + " / " + view.groupName()); + } + return toListMap(grouped); + } + + private Map> inheritedRolesByUser(List groupUsers) { + Map> rolesByGroup = new LinkedHashMap<>(); + for (GroupRoleView view : groupMapper.findGroupRoles()) { + rolesByGroup.computeIfAbsent(view.groupId(), key -> new LinkedHashSet<>()).add(view.roleName()); + } + + Map> rolesByUser = new LinkedHashMap<>(); + for (GroupUserView view : groupUsers) { + Set roles = rolesByGroup.getOrDefault(view.groupId(), Set.of()); + if (!roles.isEmpty()) { + rolesByUser.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).addAll(roles); + } + } + return toListMap(rolesByUser); + } + + private Map> toListMap(Map> source) { + Map> 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()); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java index 017083b..46e1bf0 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java @@ -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()) diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 25cf700..4cf636b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -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()); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java index c750588..abca541 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java @@ -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(); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java index 13daef6..c20c3f6 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java @@ -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"; } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java index 96d956c..c6563c2 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java @@ -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"; } } diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 66f7c24..3c7589b 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -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; diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 74db992..c76a12e 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -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', () => { diff --git a/src/main/resources/templates/mcp-reasoning.html b/src/main/resources/templates/mcp-reasoning.html index 98aaf48..db7cb49 100644 --- a/src/main/resources/templates/mcp-reasoning.html +++ b/src/main/resources/templates/mcp-reasoning.html @@ -22,12 +22,63 @@

권한 결과 해석

도구 JSON -
+ + +