feat(mcp): authorize high-risk tool calls and harden auth lifecycle

Tool authorization (HIGH-risk confirmation):
- add AuthorizeToolCall RPC; the gateway calls it before tool execution
- sign/consume confirmation tickets with idempotency-key dedup
- propagate x-dc3-ai metadata (riskLevel/destructive/openWorld/idempotent) from the scan through the registry to the tool catalog

OAuth hardening:
- rotate refresh tokens and revoke the authorization on replay via previous_refresh_token_hash

Password lifecycle:
- require password change on login when flagged or expired (R20303/R20304)
- expose a public POST /token/change_password endpoint

OpenAPI aggregation:
- switch McpOpenApiAggregator to classpath static snapshots, dropping the runtime fetch and McpAggregatorProperties
This commit is contained in:
Vickey
2026-06-18 14:08:05 +08:00
parent cc29d53904
commit a94b2464a4
46 changed files with 45541 additions and 223 deletions
@@ -37,6 +37,9 @@ service McpRuntimeApi {
// Resolve one visible tool to its backend invocation metadata.
rpc ResolveTool (GrpcMcpToolResolveRequest) returns (GrpcRMcpToolResolveDTO);
// Authorize one tool call, enforcing high-risk confirmation and idempotency.
rpc AuthorizeToolCall (GrpcMcpToolAuthorizeRequest) returns (GrpcRMcpToolAuthorizeDTO);
// Store one MCP call audit record.
rpc Audit (GrpcMcpAuditCommand) returns (GrpcRMcpBoolean);
}
@@ -146,6 +149,39 @@ message GrpcMcpToolResolveDTO {
string http_method = 7;
}
// Tool call authorization request.
message GrpcMcpToolAuthorizeRequest {
int64 tenant_id = 1;
int64 principal_id = 2;
int64 mcp_connection_id = 3;
string scope = 4;
string tool_name = 5;
string argument_digest = 6;
string confirm_id = 7;
string idempotency_key = 8;
}
// Tool call authorization response wrapper.
message GrpcRMcpToolAuthorizeDTO {
// Common result wrapper.
GrpcR result = 1;
// Authorization decision.
GrpcMcpToolAuthorizeDTO data = 2;
}
// Tool call authorization decision.
message GrpcMcpToolAuthorizeDTO {
// Decision: AUTHORIZED / CONFIRM_REQUIRED / REJECTED.
string decision = 1;
// Confirmation ticket ID issued when decision is CONFIRM_REQUIRED.
string confirm_id = 2;
// Human readable reason or confirmation prompt.
string message = 3;
// Resolved tool risk level.
string risk_level = 4;
}
// MCP audit insert command.
message GrpcMcpAuditCommand {
string trace_id = 1;
@@ -68,6 +68,24 @@ message GrpcScannedApiDTO {
// e.g. "ApiController". Used to cluster sibling endpoints under the same
// tree node on the permission resource page.
string api_group = 6;
// Declared MCP risk level (LOW / MEDIUM / HIGH); empty when derived automatically.
string risk_level = 7;
// Declared MCP destructive hint ("true" / "false"); empty when derived.
string destructive_hint = 8;
// Declared MCP open-world hint ("true" / "false"); empty when derived.
string open_world_hint = 9;
// Declared MCP idempotent hint ("true" / "false"); empty when derived.
string idempotent_hint = 10;
// AI-facing MCP tool description override; empty when the operation text is used.
string ai_description = 11;
// Whether the MCP tool is hidden from tools/list by default ("true" / "false"); empty = visible.
string hidden = 12;
}
// Response wrapper for the sync result.
@@ -24,6 +24,8 @@ import io.github.pnoker.common.auth.entity.oauth.OAuthRegisteredClientRecord;
import io.github.pnoker.common.entity.common.RequestHeader;
import io.github.pnoker.common.entity.dto.McpAuditCommandDTO;
import io.github.pnoker.common.entity.dto.McpIntrospectResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeRequestDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolDefinitionDTO;
import io.github.pnoker.common.entity.dto.McpToolResolveResponseDTO;
import io.github.pnoker.common.entity.dto.OAuthClientRegistrationRequestDTO;
@@ -82,6 +84,8 @@ public interface OAuthMcpRuntimeService {
McpToolResolveResponseDTO resolveVisibleTool(Long tenantId, Long principalId, Long connectionId, String toolName,
Set<String> scopes);
McpToolAuthorizeResponseDTO authorizeToolCall(McpToolAuthorizeRequestDTO request);
void audit(McpAuditCommandDTO command);
List<McpAuditCommand> listAudit(Long tenantId, Long principalId, String toolId, String status,
@@ -44,6 +44,18 @@ public interface TokenService {
*/
String generateToken(String loginName, String salt, String password, String tenantCode);
/**
* Self-service password change used during login when a credential is flagged for a
* mandatory change or has expired. Validates the tenant membership and current password
* before storing the new password; no token is issued, the client re-authenticates after.
*
* @param loginName login name
* @param currentPassword current raw password
* @param newPassword new raw password
* @param tenantCode tenant code
*/
void changePassword(String loginName, String currentPassword, String newPassword, String tenantCode);
/**
* @param loginName Name
* @param salt
@@ -19,6 +19,9 @@ package io.github.pnoker.common.auth.biz.impl;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.pnoker.common.auth.biz.OAuthMcpRuntimeService;
import io.github.pnoker.common.auth.dal.PrincipalManager;
import io.github.pnoker.common.auth.dal.ServiceAccountManager;
@@ -26,21 +29,19 @@ import io.github.pnoker.common.auth.entity.model.PrincipalDO;
import io.github.pnoker.common.auth.entity.model.ServiceAccountDO;
import io.github.pnoker.common.auth.entity.oauth.McpAuditCommand;
import io.github.pnoker.common.auth.entity.oauth.McpConnectionRecord;
import io.github.pnoker.common.auth.entity.oauth.McpToolConfirmationRecord;
import io.github.pnoker.common.auth.entity.oauth.McpToolRecord;
import io.github.pnoker.common.auth.entity.oauth.OAuthAuthorizationRecord;
import io.github.pnoker.common.auth.entity.oauth.OAuthRegisteredClientRecord;
import io.github.pnoker.common.auth.mapper.OAuthMcpMapper;
import io.github.pnoker.common.auth.tool.McpOpenApiAggregator;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.ObjectProvider;
import io.github.pnoker.common.auth.service.TenantMembershipService;
import io.github.pnoker.common.auth.tool.McpOpenApiAggregator;
import io.github.pnoker.common.constant.service.McpConstant;
import io.github.pnoker.common.entity.common.RequestHeader;
import io.github.pnoker.common.entity.dto.McpAuditCommandDTO;
import io.github.pnoker.common.entity.dto.McpIntrospectResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeRequestDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolDefinitionDTO;
import io.github.pnoker.common.entity.dto.McpToolResolveResponseDTO;
import io.github.pnoker.common.entity.dto.OAuthClientRegistrationRequestDTO;
@@ -113,7 +114,7 @@ public class OAuthMcpRuntimeServiceImpl implements OAuthMcpRuntimeService {
private final TenantMembershipService tenantMembershipService;
private final PrincipalManager principalManager;
private final ServiceAccountManager serviceAccountManager;
private final ObjectProvider<McpOpenApiAggregator> openApiAggregator;
private final McpOpenApiAggregator openApiAggregator;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -132,6 +133,9 @@ public class OAuthMcpRuntimeServiceImpl implements OAuthMcpRuntimeService {
@Value("${dc3.oauth.refresh-token-ttl:P30D}")
private Duration refreshTokenTtl;
@Value("${dc3.mcp.confirm-ttl:PT5M}")
private Duration confirmTtl;
@Value("${dc3.oauth.jwt.private-key:}")
private String privateKeyBase64;
@@ -419,10 +423,10 @@ public class OAuthMcpRuntimeServiceImpl implements OAuthMcpRuntimeService {
@Transactional(rollbackFor = Exception.class)
public int refreshToolCatalog() {
int changed = 0;
// Optional: when dc3.mcp.tool.aggregator.enabled=true, enrich each tool with a real
// request schema pulled from the service's OpenAPI spec. Off by default (no-op).
McpOpenApiAggregator aggregator = openApiAggregator.getIfAvailable();
Map<String, String> schemas = aggregator == null ? Map.of() : aggregator.inputSchemasByApiCode();
// Enrich each tool with a real input schema derived from the static OpenAPI specs
// shipped on the classpath (openapi/openapi-*.json). Empty when no spec is present,
// in which case tools keep their name/title without a parameter schema.
Map<String, String> schemas = openApiAggregator.inputSchemasByApiCode();
for (McpToolRecord candidate : oauthMcpMapper.listRegistryToolCandidates()) {
String schema = schemas.get(candidate.getApiCode());
if (schema != null) {
@@ -589,6 +593,100 @@ public class OAuthMcpRuntimeServiceImpl implements OAuthMcpRuntimeService {
return resolvedTool(tool);
}
@Override
@Transactional(rollbackFor = Exception.class)
public McpToolAuthorizeResponseDTO authorizeToolCall(McpToolAuthorizeRequestDTO request) {
request = Objects.requireNonNullElseGet(request, McpToolAuthorizeRequestDTO::new);
Set<String> scopes = splitValues(request.getScope());
// Re-run the full visibility/whitelist/scope check; this is the authoritative gate and
// also yields the tool's risk level and stable tool_id.
McpToolResolveResponseDTO tool = resolveVisibleTool(request.getTenantId(), request.getPrincipalId(),
request.getMcpConnectionId(), request.getToolName(), scopes);
// Only HIGH-risk tools require platform confirmation; the rest pass straight through.
if (!McpConstant.RiskLevel.HIGH.equals(tool.getRiskLevel())) {
return authorizeDecision(McpConstant.Confirmation.DECISION_AUTHORIZED, "", "authorized",
tool.getRiskLevel());
}
String confirmId = StringUtils.trimToEmpty(request.getConfirmId());
String idempotencyKey = StringUtils.trimToEmpty(request.getIdempotencyKey());
String argumentDigest = StringUtils.trimToEmpty(request.getArgumentDigest());
if (StringUtils.isBlank(confirmId)) {
// Reject an idempotency key already consumed by an earlier high-risk call.
if (StringUtils.isNotBlank(idempotencyKey)
&& oauthMcpMapper.selectConsumedByIdempotencyKey(request.getMcpConnectionId(),
idempotencyKey) != null) {
return authorizeDecision(McpConstant.Confirmation.DECISION_REJECTED, "",
"idempotency key has already been used", tool.getRiskLevel());
}
// Issue a pending confirmation ticket bound to the caller, connection, tool and arguments.
String issuedConfirmId = UUID.randomUUID().toString();
McpToolConfirmationRecord ticket = new McpToolConfirmationRecord();
ticket.setId(IdWorker.getId());
ticket.setConfirmId(issuedConfirmId);
ticket.setTenantId(request.getTenantId());
ticket.setPrincipalId(request.getPrincipalId());
ticket.setConnectionId(request.getMcpConnectionId());
ticket.setToolId(tool.getToolId());
ticket.setArgumentDigest(argumentDigest);
ticket.setIdempotencyKey(idempotencyKey);
ticket.setRiskLevel(tool.getRiskLevel());
ticket.setStatus(McpConstant.Confirmation.STATUS_PENDING);
ticket.setExpireTime(LocalDateTime.now().plus(confirmTtl));
oauthMcpMapper.insertConfirmation(ticket);
return authorizeDecision(McpConstant.Confirmation.DECISION_CONFIRM_REQUIRED, issuedConfirmId,
"High risk tool '" + tool.getToolName() + "' requires confirmation; resend the call with this "
+ "confirmId", tool.getRiskLevel());
}
McpToolConfirmationRecord ticket = oauthMcpMapper.selectConfirmationByConfirmId(confirmId);
String rejection = confirmationRejection(ticket, request, tool, argumentDigest);
if (rejection != null) {
return authorizeDecision(McpConstant.Confirmation.DECISION_REJECTED, "", rejection, tool.getRiskLevel());
}
// Consuming is guarded by status=PENDING in SQL, so a replayed confirmId loses the race.
if (oauthMcpMapper.consumeConfirmation(ticket.getId(), LocalDateTime.now()) <= 0) {
return authorizeDecision(McpConstant.Confirmation.DECISION_REJECTED, "",
"confirmation has already been used", tool.getRiskLevel());
}
return authorizeDecision(McpConstant.Confirmation.DECISION_AUTHORIZED, confirmId, "authorized",
tool.getRiskLevel());
}
private String confirmationRejection(McpToolConfirmationRecord ticket, McpToolAuthorizeRequestDTO request,
McpToolResolveResponseDTO tool, String argumentDigest) {
if (ticket == null) {
return "confirmation does not exist";
}
if (!McpConstant.Confirmation.STATUS_PENDING.equals(ticket.getStatus())) {
return "confirmation has already been used";
}
if (ticket.getExpireTime() == null || ticket.getExpireTime().isBefore(LocalDateTime.now())) {
return "confirmation has expired";
}
if (!Objects.equals(ticket.getPrincipalId(), request.getPrincipalId())
|| !Objects.equals(ticket.getConnectionId(), request.getMcpConnectionId())
|| !Objects.equals(ticket.getToolId(), tool.getToolId())) {
return "confirmation does not match the caller";
}
if (!Objects.equals(StringUtils.trimToEmpty(ticket.getArgumentDigest()), argumentDigest)) {
return "confirmation arguments do not match";
}
return null;
}
private McpToolAuthorizeResponseDTO authorizeDecision(String decision, String confirmId, String message,
String riskLevel) {
return McpToolAuthorizeResponseDTO.builder()
.decision(decision)
.confirmId(confirmId)
.message(message)
.riskLevel(riskLevel)
.build();
}
@Override
public void audit(McpAuditCommandDTO source) {
source = Objects.requireNonNullElseGet(source, McpAuditCommandDTO::new);
@@ -626,7 +724,7 @@ public class OAuthMcpRuntimeServiceImpl implements OAuthMcpRuntimeService {
throw oauthError(BAD_REQUEST.value(), "invalid_grant", "PKCE verification failed");
}
}
return issueAndPersistTokens(authorization, client, true);
return issueAndPersistTokens(authorization, client, true, "");
}
private Map<String, Object> clientCredentialsToken(Map<String, String> form, String authorizationHeader) {
@@ -655,26 +753,39 @@ public class OAuthMcpRuntimeServiceImpl implements OAuthMcpRuntimeService {
authorization.setAuthorizationCodeHash("");
authorization.setTokenMetadata("{}");
oauthMcpMapper.insertAuthorization(authorization);
return issueAndPersistTokens(authorization, client, false);
return issueAndPersistTokens(authorization, client, false, "");
}
private Map<String, Object> refreshToken(Map<String, String> form, String authorizationHeader) {
String refreshToken = form.get(McpConstant.Field.REFRESH_TOKEN);
OAuthAuthorizationRecord authorization = oauthMcpMapper.selectAuthorizationByRefreshTokenHash(
sha256(refreshToken));
if (authorization == null || authorization.getRefreshTokenExpires() == null
String presentedHash = sha256(refreshToken);
OAuthAuthorizationRecord authorization = oauthMcpMapper.selectAuthorizationByRefreshTokenHash(presentedHash);
if (authorization == null) {
// A rotated (previous) refresh token replayed after rotation signals theft per
// RFC 9700; revoke the whole authorization so the leaked chain is dead.
OAuthAuthorizationRecord replayed =
oauthMcpMapper.selectAuthorizationByPreviousRefreshTokenHash(presentedHash);
if (replayed != null) {
oauthMcpMapper.revokeAuthorizationByAccessTokenJti(replayed.getAccessTokenJti(),
"refresh_token_replayed", LocalDateTime.now());
throw oauthError(BAD_REQUEST.value(), "invalid_grant", "refresh token has been revoked");
}
throw oauthError(BAD_REQUEST.value(), "invalid_grant", "refresh token is invalid or expired");
}
if (authorization.getRefreshTokenExpires() == null
|| authorization.getRefreshTokenExpires().isBefore(LocalDateTime.now())
|| authorization.getRevokedTime() != null) {
throw oauthError(BAD_REQUEST.value(), "invalid_grant", "refresh token is invalid or expired");
}
OAuthRegisteredClientRecord client = requireClient(authorization.getClientId());
authenticateClient(client, form, authorizationHeader, false);
return issueAndPersistTokens(authorization, client, true);
return issueAndPersistTokens(authorization, client, true, presentedHash);
}
private Map<String, Object> issueAndPersistTokens(OAuthAuthorizationRecord authorization,
OAuthRegisteredClientRecord client,
boolean issueRefreshToken) {
boolean issueRefreshToken,
String previousRefreshHash) {
PrincipalDO principal = principalManager.getById(authorization.getPrincipalId());
if (principal == null
|| !enabled(principal.getEnableFlag())
@@ -711,7 +822,8 @@ public class OAuthMcpRuntimeServiceImpl implements OAuthMcpRuntimeService {
LocalDateTime refreshIssued = issueRefreshToken ? issued : null;
LocalDateTime refreshExpires = issueRefreshToken ? issued.plus(refreshTokenTtl) : null;
oauthMcpMapper.activateAuthorizationTokens(authorization.getId(), "", jti, issued, accessExpires,
sha256(refreshToken), refreshIssued, refreshExpires, JsonUtil.toJsonString(claims));
sha256(refreshToken), StringUtils.defaultString(previousRefreshHash), refreshIssued, refreshExpires,
JsonUtil.toJsonString(claims));
Map<String, Object> response = new LinkedHashMap<>();
response.put(McpConstant.Field.ACCESS_TOKEN, accessToken);
@@ -849,8 +961,8 @@ public class OAuthMcpRuntimeServiceImpl implements OAuthMcpRuntimeService {
/**
* Resolve the input schema for a tool. When the catalog carries one in {@code tool_ext}
* (populated by {@link McpOpenApiAggregator} when enabled), surface it; otherwise fall back to
* the static default so tools/list never breaks.
* (populated by {@link McpOpenApiAggregator} from the static OpenAPI specs), surface it;
* otherwise fall back to the static default so tools/list never breaks.
*/
private Map<String, Object> inputSchemaOf(McpToolRecord tool) {
String ext = tool.getToolExt();
@@ -651,6 +651,12 @@ public class ResourceRegistrySyncServiceImpl implements ResourceRegistrySyncServ
content.setTitle(spec.getTitle());
content.setUrl(spec.getPath());
content.setRemark(spec.getRemark());
content.setRiskLevel(spec.getRiskLevel());
content.setDestructiveHint(spec.getDestructiveHint());
content.setOpenWorldHint(spec.getOpenWorldHint());
content.setIdempotentHint(spec.getIdempotentHint());
content.setAiDescription(spec.getAiDescription());
content.setHidden(spec.getHidden());
return content;
}
@@ -666,7 +672,13 @@ public class ResourceRegistrySyncServiceImpl implements ResourceRegistrySyncServ
return Objects.isNull(a) && Objects.isNull(b);
}
return Objects.equals(a.getTitle(), b.getTitle()) && Objects.equals(a.getUrl(), b.getUrl())
&& Objects.equals(a.getRemark(), b.getRemark());
&& Objects.equals(a.getRemark(), b.getRemark())
&& Objects.equals(a.getRiskLevel(), b.getRiskLevel())
&& Objects.equals(a.getDestructiveHint(), b.getDestructiveHint())
&& Objects.equals(a.getOpenWorldHint(), b.getOpenWorldHint())
&& Objects.equals(a.getIdempotentHint(), b.getIdempotentHint())
&& Objects.equals(a.getAiDescription(), b.getAiDescription())
&& Objects.equals(a.getHidden(), b.getHidden());
}
@Override
@@ -28,6 +28,8 @@ import io.github.pnoker.common.auth.service.LocalCredentialService;
import io.github.pnoker.common.auth.service.TenantMembershipService;
import io.github.pnoker.common.auth.service.TenantService;
import io.github.pnoker.common.constant.common.ExceptionConstant;
import io.github.pnoker.common.enums.ResponseEnum;
import io.github.pnoker.common.exception.PasswordChangeRequiredException;
import io.github.pnoker.common.exception.UnAuthorizedException;
import io.github.pnoker.common.utils.KeyUtil;
import io.jsonwebtoken.Claims;
@@ -94,10 +96,33 @@ public class TokenServiceImpl implements TokenService {
throw new UnAuthorizedException(ExceptionConstant.NO_AVAILABLE_AUTH);
}
localCredentialService.recordSuccessfulLogin(credential.getId());
if (Objects.nonNull(credential.getPasswordExpireTime())
&& credential.getPasswordExpireTime().isBefore(LocalDateTime.now())) {
throw new PasswordChangeRequiredException(ResponseEnum.PASSWORD_EXPIRED);
}
if (Objects.nonNull(credential.getRequirePasswordChange()) && credential.getRequirePasswordChange() == 1) {
throw new PasswordChangeRequiredException(ResponseEnum.PASSWORD_CHANGE_REQUIRED);
}
markPrincipalLogin(credential.getPrincipalId());
return KeyUtil.generateToken(String.valueOf(credential.getPrincipalId()), salt, tenantBO.getId());
}
@Override
public void changePassword(String loginName, String currentPassword, String newPassword, String tenantCode) {
TenantBO tenantBO = tenantService.getByCode(tenantCode);
if (Objects.isNull(tenantBO)) {
throw new UnAuthorizedException(ExceptionConstant.NO_AVAILABLE_AUTH);
}
LocalCredentialBO credential = localCredentialService.getByLoginName(loginName, false);
if (Objects.isNull(credential)
|| !tenantMembershipService.isTenantMember(tenantBO.getId(), credential.getPrincipalId())) {
throw new UnAuthorizedException(ExceptionConstant.NO_AVAILABLE_AUTH);
}
localCredentialService.changePassword(loginName, currentPassword, newPassword);
}
@Override
public boolean tryCancelToken(String loginName, String tenantCode) {
TenantBO tenantBO = tenantService.getByCode(tenantCode);
@@ -93,6 +93,27 @@ public class TokenController implements BaseController {
});
}
/**
* Self-service password change. Public because a credential flagged for a mandatory
* change or expired password cannot obtain a token until the password is changed.
*
* @param entityVO {@link TokenQuery} carrying name, tenant, current password and newPassword
* @return true when the password was changed
*/
// Public endpoint: invoked during login when no token can be issued yet, so no
// @PreAuthorize. Path is also permitted in WebFluxSecurityConfig (POST /token/change_password).
@PublicEndpoint
@SecurityRequirements
@Operation(summary = "Change Password", description = "Change a local credential password during login")
@PostMapping("/change_password")
public Mono<R<Boolean>> changePassword(@Validated @RequestBody TokenQuery entityVO) {
return async(() -> {
tokenService.changePassword(entityVO.getName(), entityVO.getPassword(), entityVO.getNewPassword(),
entityVO.getTenant());
return R.ok(true, "Password changed");
});
}
/**
* Acknowledge a client-initiated logout for the current token.
*
@@ -72,4 +72,34 @@ public class ResourceRegistryScannedApi {
*/
private String apiGroup;
/**
* Declared MCP risk level (LOW / MEDIUM / HIGH); blank when derived automatically.
*/
private String riskLevel;
/**
* Declared MCP destructive hint ("true" / "false"); blank when derived.
*/
private String destructiveHint;
/**
* Declared MCP open-world hint ("true" / "false"); blank when derived.
*/
private String openWorldHint;
/**
* Declared MCP idempotent hint ("true" / "false"); blank when derived.
*/
private String idempotentHint;
/**
* AI-facing MCP tool description override; blank when the operation text is used.
*/
private String aiDescription;
/**
* Whether the MCP tool is hidden from tools/list by default ("true" / "false"); blank = visible.
*/
private String hidden;
}
@@ -0,0 +1,64 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.auth.entity.oauth;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* High-risk MCP tool call confirmation ticket projection.
*
* @author pnoker
* @version 2026.6.17
* @since 2026.6.17
*/
@Getter
@Setter
@ToString
public class McpToolConfirmationRecord {
private Long id;
private String confirmId;
private Long tenantId;
private Long principalId;
private Long connectionId;
private String toolId;
private String argumentDigest;
private String idempotencyKey;
private String riskLevel;
private String status;
private LocalDateTime expireTime;
private LocalDateTime consumedTime;
private LocalDateTime createTime;
}
@@ -72,6 +72,9 @@ public class OAuthAuthorizationRecord {
@ToString.Exclude
private String refreshTokenHash;
@ToString.Exclude
private String previousRefreshTokenHash;
private LocalDateTime refreshTokenIssued;
private LocalDateTime refreshTokenExpires;
@@ -72,4 +72,11 @@ public class TokenQuery {
@Schema(description = "Authentication token")
private String token;
/**
* New password, used by the self-service password change flow; {@code password} carries
* the current password in that flow.
*/
@Schema(description = "New password for the password change flow")
private String newPassword;
}
@@ -17,26 +17,15 @@
package io.github.pnoker.common.auth.grpc;
import io.github.pnoker.api.center.auth.GrpcMcpAuditCommand;
import io.github.pnoker.api.center.auth.GrpcMcpIntrospectDTO;
import io.github.pnoker.api.center.auth.GrpcMcpIntrospectRequest;
import io.github.pnoker.api.center.auth.GrpcMcpToolAnnotationsDTO;
import io.github.pnoker.api.center.auth.GrpcMcpToolDefinitionDTO;
import io.github.pnoker.api.center.auth.GrpcMcpToolListRequest;
import io.github.pnoker.api.center.auth.GrpcMcpToolMetadataDTO;
import io.github.pnoker.api.center.auth.GrpcMcpToolResolveDTO;
import io.github.pnoker.api.center.auth.GrpcMcpToolResolveRequest;
import io.github.pnoker.api.center.auth.GrpcRMcpBoolean;
import io.github.pnoker.api.center.auth.GrpcRMcpIntrospectDTO;
import io.github.pnoker.api.center.auth.GrpcRMcpToolListDTO;
import io.github.pnoker.api.center.auth.GrpcRMcpToolResolveDTO;
import io.github.pnoker.api.center.auth.McpRuntimeApiGrpc;
import io.github.pnoker.api.center.auth.*;
import io.github.pnoker.api.common.GrpcR;
import io.github.pnoker.common.auth.biz.OAuthMcpRuntimeService;
import io.github.pnoker.common.auth.biz.impl.OAuthMcpRuntimeServiceImpl.OAuthProtocolException;
import io.github.pnoker.common.constant.service.McpConstant;
import io.github.pnoker.common.entity.dto.McpAuditCommandDTO;
import io.github.pnoker.common.entity.dto.McpIntrospectResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeRequestDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolDefinitionDTO;
import io.github.pnoker.common.entity.dto.McpToolResolveResponseDTO;
import io.github.pnoker.common.enums.ResponseEnum;
@@ -119,6 +108,34 @@ public class McpRuntimeServer extends McpRuntimeApiGrpc.McpRuntimeApiImplBase {
responseObserver.onCompleted();
}
@Override
public void authorizeToolCall(GrpcMcpToolAuthorizeRequest request,
StreamObserver<GrpcRMcpToolAuthorizeDTO> responseObserver) {
GrpcRMcpToolAuthorizeDTO.Builder response = GrpcRMcpToolAuthorizeDTO.newBuilder();
try {
McpToolAuthorizeResponseDTO decision = oauthMcpRuntimeService.authorizeToolCall(
McpToolAuthorizeRequestDTO.builder()
.tenantId(request.getTenantId())
.principalId(request.getPrincipalId())
.mcpConnectionId(request.getMcpConnectionId())
.scope(request.getScope())
.toolName(request.getToolName())
.argumentDigest(request.getArgumentDigest())
.confirmId(request.getConfirmId())
.idempotencyKey(request.getIdempotencyKey())
.build());
response.setResult(ok());
response.setData(toGrpc(decision));
} catch (OAuthProtocolException e) {
response.setResult(protocolFailure(e));
} catch (Exception e) {
log.warn("MCP authorize tool call failed", e);
response.setResult(failure(e));
}
responseObserver.onNext(response.build());
responseObserver.onCompleted();
}
@Override
public void audit(GrpcMcpAuditCommand request, StreamObserver<GrpcRMcpBoolean> responseObserver) {
GrpcRMcpBoolean.Builder response = GrpcRMcpBoolean.newBuilder();
@@ -204,6 +221,15 @@ public class McpRuntimeServer extends McpRuntimeApiGrpc.McpRuntimeApiImplBase {
.build();
}
private GrpcMcpToolAuthorizeDTO toGrpc(McpToolAuthorizeResponseDTO source) {
return GrpcMcpToolAuthorizeDTO.newBuilder()
.setDecision(StringUtils.defaultString(source.getDecision()))
.setConfirmId(StringUtils.defaultString(source.getConfirmId()))
.setMessage(StringUtils.defaultString(source.getMessage()))
.setRiskLevel(StringUtils.defaultString(source.getRiskLevel()))
.build();
}
private McpAuditCommandDTO toDTO(GrpcMcpAuditCommand source) {
return McpAuditCommandDTO.builder()
.traceId(source.getTraceId())
@@ -61,6 +61,12 @@ public class ResourceRegistryServer extends ResourceRegistryApiGrpc.ResourceRegi
.title(dto.getTitle())
.remark(dto.getRemark())
.apiGroup(dto.getApiGroup())
.riskLevel(dto.getRiskLevel())
.destructiveHint(dto.getDestructiveHint())
.openWorldHint(dto.getOpenWorldHint())
.idempotentHint(dto.getIdempotentHint())
.aiDescription(dto.getAiDescription())
.hidden(dto.getHidden())
.build());
}
return apis;
@@ -19,6 +19,7 @@ package io.github.pnoker.common.auth.mapper;
import io.github.pnoker.common.auth.entity.oauth.McpAuditCommand;
import io.github.pnoker.common.auth.entity.oauth.McpConnectionRecord;
import io.github.pnoker.common.auth.entity.oauth.McpToolConfirmationRecord;
import io.github.pnoker.common.auth.entity.oauth.McpToolRecord;
import io.github.pnoker.common.auth.entity.oauth.OAuthAuthorizationRecord;
import io.github.pnoker.common.auth.entity.oauth.OAuthRegisteredClientRecord;
@@ -49,6 +50,9 @@ public interface OAuthMcpMapper {
OAuthAuthorizationRecord selectAuthorizationByRefreshTokenHash(@Param("refreshHash") String refreshHash);
OAuthAuthorizationRecord selectAuthorizationByPreviousRefreshTokenHash(
@Param("previousRefreshHash") String previousRefreshHash);
int insertAuthorization(OAuthAuthorizationRecord authorization);
int activateAuthorizationTokens(@Param("id") Long id,
@@ -57,6 +61,7 @@ public interface OAuthMcpMapper {
@Param("accessIssued") LocalDateTime accessIssued,
@Param("accessExpires") LocalDateTime accessExpires,
@Param("refreshHash") String refreshHash,
@Param("previousRefreshHash") String previousRefreshHash,
@Param("refreshIssued") LocalDateTime refreshIssued,
@Param("refreshExpires") LocalDateTime refreshExpires,
@Param("tokenClaims") String tokenClaims);
@@ -132,4 +137,13 @@ public interface OAuthMcpMapper {
int insertAudit(McpAuditCommand command);
int insertConfirmation(McpToolConfirmationRecord confirmation);
McpToolConfirmationRecord selectConfirmationByConfirmId(@Param("confirmId") String confirmId);
McpToolConfirmationRecord selectConsumedByIdempotencyKey(@Param("connectionId") Long connectionId,
@Param("idempotencyKey") String idempotencyKey);
int consumeConfirmation(@Param("id") Long id, @Param("consumedTime") LocalDateTime consumedTime);
}
@@ -38,6 +38,17 @@ public interface LocalCredentialService extends BaseService<LocalCredentialBO, L
void resetPassword(Long id, String rawPassword);
/**
* Self-service password change. Verifies the current password, stores the new password
* hash, clears the require-password-change flag, refreshes the expiry, and resets the
* failed-attempt and lock state. Throws when the current password does not match.
*
* @param loginName login name
* @param currentPassword current raw password
* @param newPassword new raw password
*/
void changePassword(String loginName, String currentPassword, String newPassword);
void recordSuccessfulLogin(Long id);
void recordFailedLogin(Long id);
@@ -36,12 +36,14 @@ import io.github.pnoker.common.exception.DeleteException;
import io.github.pnoker.common.exception.DuplicateException;
import io.github.pnoker.common.exception.EmptyException;
import io.github.pnoker.common.exception.NotFoundException;
import io.github.pnoker.common.exception.UnAuthorizedException;
import io.github.pnoker.common.exception.UpdateException;
import io.github.pnoker.common.utils.PageUtil;
import io.github.pnoker.common.utils.PasswordUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
@@ -65,12 +67,14 @@ public class LocalCredentialServiceImpl implements LocalCredentialService {
private static final int MAX_FAILED_ATTEMPTS = 5;
private static final long LOCK_MINUTES = 15;
private final LocalCredentialBuilder localCredentialBuilder;
private final LocalCredentialManager localCredentialManager;
private final TenantMembershipService tenantMembershipService;
/**
* Password validity in days after a change; {@code 0} (default) means passwords never expire.
*/
@Value("${dc3.auth.password.expire-days:0}")
private long passwordExpireDays;
@Override
public void add(LocalCredentialBO entityBO) {
@@ -179,6 +183,31 @@ public class LocalCredentialServiceImpl implements LocalCredentialService {
update(credential);
}
@Override
public void changePassword(String loginName, String currentPassword, String newPassword) {
if (StringUtils.isBlank(newPassword)) {
throw new EmptyException("The new password is empty");
}
LocalCredentialBO credential = getByLoginName(loginName, true);
if (!PasswordUtil.verify(currentPassword, credential.getPasswordHash())) {
throw new UnAuthorizedException("The current password does not match");
}
LocalCredentialDO entityDO = getDOById(credential.getId(), true);
String hash = PasswordUtil.encode(newPassword);
LocalDateTime now = LocalDateTime.now();
entityDO.setPasswordHash(hash);
entityDO.setPasswordAlgorithm(PasswordUtil.algorithmOfHash(hash).getValue());
entityDO.setPasswordUpdatedTime(now);
entityDO.setPasswordExpireTime(passwordExpireDays > 0 ? now.plusDays(passwordExpireDays) : null);
entityDO.setRequirePasswordChange((byte) 0);
entityDO.setFailedAttempts(0);
entityDO.setLockedUntil(null);
entityDO.setOperateTime(null);
if (!localCredentialManager.updateById(entityDO)) {
throw new UpdateException("The password change failed");
}
}
@Override
public void recordSuccessfulLogin(Long id) {
LocalCredentialDO credential = getDOById(id, false);
@@ -1,54 +0,0 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.auth.tool;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Configuration for the optional OpenAPI-based MCP tool catalog aggregator.
*
* <p>Off by default. When {@code dc3.mcp.tool.aggregator.enabled=true}, the
* {@link McpOpenApiAggregator} fetches each center service's {@code /v3/api-docs} and derives a
* JSON request schema per tool; otherwise the catalog keeps building purely from
* {@code dc3_api} with zero cross-service calls.
*
* @author pnoker
* @version 2026.6.13
* @since 2026.6.13
*/
@Data
@Component
@ConfigurationProperties(prefix = "dc3.mcp.tool.aggregator")
public class McpAggregatorProperties {
/**
* Master switch. Off by default so the default refresh path is unchanged.
*/
private boolean enabled = false;
/**
* {@code dc3_api.service_name} -> base URL that serves {@code /v3/api-docs}, e.g.
* {@code dc3-center-manager -> http://dc3-center-manager:8400}.
*/
private Map<String, String> docs = new LinkedHashMap<>();
}
@@ -21,60 +21,68 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
/**
* Aggregates MCP tool input schemas from each center service's OpenAPI spec.
* Builds MCP tool input schemas from static OpenAPI specs shipped on the auth classpath
* under {@code openapi/openapi-*.json}.
*
* <p>Only instantiated when {@code dc3.mcp.tool.aggregator.enabled=true}. It fetches
* {@code /v3/api-docs} from each configured service, extracts the JSON request-body schema per
* operation (resolving {@code $ref} shallowly), and keys it by
* {@code serviceName:METHOD:path} so it lines up with {@code dc3_api.api_code}.
* <p>The API contract (paths, parameters, request bodies, {@code @Schema} field docs) is a
* compile-time fact that changes rarely, so it is snapshotted to a versioned file rather
* than fetched at runtime. This removes the auth service's dependency on every center
* service being reachable, and removes any need to expose {@code /v3/api-docs} in
* production. Regenerate the snapshots with {@code make openapi} after a contract change.
*
* <p>Best-effort: any fetch or parse failure for a service is logged and skipped — the catalog
* still refreshes from {@code dc3_api}, only without that service's schema enrichment.
* <p>Each file is named {@code openapi-<service>.json} where {@code <service>} is the bare
* center name ({@code auth}/{@code manager}/{@code data}/{@code agentic}); it is expanded to
* the full {@code dc3-center-<service>} so the resulting keys line up with
* {@code dc3_api.api_code} ({@code dc3-center-manager:POST:/device/add}).
*
* <p>Best-effort: a missing directory, an unreadable file, or a parse failure for one
* service is logged and skipped — the catalog still builds from {@code dc3_api}, only
* without that service's schema enrichment.
*
* @author pnoker
* @version 2026.6.13
* @version 2026.6.18
* @since 2026.6.13
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "dc3.mcp.tool.aggregator", name = "enabled", havingValue = "true")
public class McpOpenApiAggregator {
private final McpAggregatorProperties properties;
private static final String SPECS_LOCATION = "classpath*:openapi/openapi-*.json";
private static final String SERVICE_PREFIX = "dc3-center-";
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* @return map of {@code serviceName:METHOD:path} -> JSON Schema string for the request body.
* @return map of {@code dc3-center-<service>:METHOD:/path} -> merged input JSON Schema string.
*/
public Map<String, String> inputSchemasByApiCode() {
Map<String, String> schemas = new HashMap<>();
if (properties.getDocs() == null || properties.getDocs().isEmpty()) {
Resource[] resources;
try {
resources = new PathMatchingResourcePatternResolver().getResources(SPECS_LOCATION);
} catch (Exception ex) {
log.warn("MCP aggregator: cannot resolve static OpenAPI specs at {}: {}", SPECS_LOCATION,
ex.getMessage());
return schemas;
}
for (Map.Entry<String, String> entry : properties.getDocs().entrySet()) {
String serviceName = entry.getKey();
String baseUrl = entry.getValue();
try {
String spec = WebClient.builder().baseUrl(baseUrl).build()
.get().uri("/v3/api-docs")
.retrieve().bodyToMono(String.class).block();
if (StringUtils.isBlank(spec)) {
continue;
}
JsonNode root = objectMapper.readTree(spec);
for (Resource resource : resources) {
String serviceName = serviceNameOf(resource.getFilename());
if (serviceName == null) {
continue;
}
try (InputStream in = resource.getInputStream()) {
JsonNode root = objectMapper.readTree(in);
JsonNode paths = root.path("paths");
if (paths.isMissingNode() || !paths.isObject()) {
continue;
@@ -87,30 +95,99 @@ public class McpOpenApiAggregator {
}
pathItem.fields().forEachRemaining(opEntry -> {
String method = opEntry.getKey().toUpperCase();
JsonNode schema = opEntry.getValue()
.path("requestBody")
.path("content")
.path("application/json")
.path("schema");
if (schema.isMissingNode() || schema.isEmpty()) {
ObjectNode schema = buildOperationSchema(opEntry.getValue(), root);
if (schema == null) {
return;
}
try {
schemas.put(serviceName + ":" + method + ":" + path,
objectMapper.writeValueAsString(resolveRefs(schema, root, 0)));
objectMapper.writeValueAsString(schema));
} catch (Exception ignore) {
// skip this operation on serialization failure
}
});
});
} catch (Exception ex) {
log.warn("MCP aggregator: failed to fetch OpenAPI from {} ({}): {}",
serviceName, baseUrl, ex.getMessage());
log.warn("MCP aggregator: failed to read static OpenAPI spec {}: {}",
resource.getFilename(), ex.getMessage());
}
}
return schemas;
}
/**
* Expand {@code openapi-<service>.json} to the full {@code dc3-center-<service>} key prefix.
* Returns {@code null} when the filename does not match the expected pattern.
*/
private String serviceNameOf(String filename) {
if (StringUtils.isBlank(filename) || !filename.startsWith("openapi-") || !filename.endsWith(".json")) {
return null;
}
String bare = filename.substring("openapi-".length(), filename.length() - ".json".length());
if (bare.isEmpty()) {
return null;
}
return bare.startsWith(SERVICE_PREFIX) ? bare : SERVICE_PREFIX + bare;
}
/**
* Build a unified MCP input schema for one operation by merging the JSON request body
* (when present) with the operation's query and path {@code parameters}. Returns an
* {@code object} schema with merged {@code properties}/{@code required}, or {@code null}
* when the operation carries neither a body nor parameters.
*/
ObjectNode buildOperationSchema(JsonNode operation, JsonNode root) {
ObjectNode properties = objectMapper.createObjectNode();
ArrayNode required = objectMapper.createArrayNode();
// Request body: only merge object-shaped JSON bodies so their fields become properties.
JsonNode bodySchema = operation.path("requestBody").path("content")
.path("application/json").path("schema");
if (!bodySchema.isMissingNode() && !bodySchema.isEmpty()) {
JsonNode resolved = resolveRefs(bodySchema, root, 0);
JsonNode bodyProps = resolved.path("properties");
if (bodyProps.isObject()) {
bodyProps.fields().forEachRemaining(f -> properties.set(f.getKey(), f.getValue()));
resolved.path("required").forEach(required::add);
}
}
// Query / path parameters: each becomes a property carrying its schema + description.
JsonNode parameters = operation.path("parameters");
if (parameters.isArray()) {
for (JsonNode parameter : parameters) {
String in = parameter.path("in").asText("");
String name = parameter.path("name").asText("");
if (name.isEmpty() || (!"query".equals(in) && !"path".equals(in))) {
continue;
}
JsonNode paramSchema = resolveRefs(parameter.path("schema"), root, 0);
ObjectNode property = paramSchema.isObject()
? ((ObjectNode) paramSchema).deepCopy() : objectMapper.createObjectNode();
String description = parameter.path("description").asText("");
if (!description.isEmpty() && !property.has("description")) {
property.put("description", description);
}
properties.set(name, property);
// Path params are always required; query params follow their declared flag.
if ("path".equals(in) || parameter.path("required").asBoolean(false)) {
required.add(name);
}
}
}
if (properties.isEmpty()) {
return null;
}
ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object");
schema.set("properties", properties);
if (!required.isEmpty()) {
schema.set("required", required);
}
return schema;
}
/**
* Shallow recursive {@code $ref} resolver against {@code components/schemas}, depth-bounded to
* avoid infinite loops on circular references.
@@ -159,6 +159,7 @@
access_token_issued,
access_token_expires,
refresh_token_hash,
previous_refresh_token_hash,
refresh_token_issued,
refresh_token_expires,
token_metadata::TEXT AS token_metadata, revoked_time,
@@ -169,6 +170,36 @@
LIMIT 1
</select>
<select id="selectAuthorizationByPreviousRefreshTokenHash"
resultType="io.github.pnoker.common.auth.entity.oauth.OAuthAuthorizationRecord">
SELECT id,
registered_client_id,
client_id,
principal_id,
principal_type,
tenant_id,
mcp_connection_id,
authorization_grant_type,
authorized_scopes,
state_hash,
authorization_code_hash,
authorization_code_issued,
authorization_code_expires,
access_token_jti,
access_token_issued,
access_token_expires,
refresh_token_hash,
previous_refresh_token_hash,
refresh_token_issued,
refresh_token_expires,
token_metadata::TEXT AS token_metadata, revoked_time,
revoke_reason
FROM dc3_oauth_authorization
WHERE previous_refresh_token_hash = #{previousRefreshHash}
AND deleted = 0
LIMIT 1
</select>
<insert id="insertAuthorization"
parameterType="io.github.pnoker.common.auth.entity.oauth.OAuthAuthorizationRecord">
INSERT INTO dc3_oauth_authorization
@@ -183,33 +214,34 @@
<update id="activateAuthorizationTokens">
UPDATE dc3_oauth_authorization
SET authorization_code_hash = #{codeHash},
access_token_jti = #{accessTokenJti},
access_token_issued = #{accessIssued},
access_token_expires = #{accessExpires},
refresh_token_hash = #{refreshHash},
refresh_token_issued = #{refreshIssued},
refresh_token_expires = #{refreshExpires},
token_claims = CAST(#{tokenClaims} AS JSON),
operate_time = CURRENT_TIMESTAMP
SET authorization_code_hash = #{codeHash},
access_token_jti = #{accessTokenJti},
access_token_issued = #{accessIssued},
access_token_expires = #{accessExpires},
refresh_token_hash = #{refreshHash},
previous_refresh_token_hash = #{previousRefreshHash},
refresh_token_issued = #{refreshIssued},
refresh_token_expires = #{refreshExpires},
token_claims = CAST(#{tokenClaims} AS JSON),
operate_time = CURRENT_TIMESTAMP
WHERE id = #{id}
AND deleted = 0
</update>
<update id="revokeAuthorizationByAccessTokenJti">
UPDATE dc3_oauth_authorization
SET revoked_time = #{revokedTime},
SET revoked_time = #{revokedTime},
revoke_reason = #{reason},
operate_time = CURRENT_TIMESTAMP
operate_time = CURRENT_TIMESTAMP
WHERE access_token_jti = #{jti}
AND deleted = 0
</update>
<update id="revokeAuthorizationByRefreshTokenHash">
UPDATE dc3_oauth_authorization
SET revoked_time = #{revokedTime},
SET revoked_time = #{revokedTime},
revoke_reason = #{reason},
operate_time = CURRENT_TIMESTAMP
operate_time = CURRENT_TIMESTAMP
WHERE refresh_token_hash = #{refreshHash}
AND deleted = 0
</update>
@@ -291,7 +323,7 @@
<update id="revokeConnection">
UPDATE dc3_mcp_connection
SET revoke_time = #{revokeTime},
SET revoke_time = #{revokeTime},
operate_time = CURRENT_TIMESTAMP
WHERE id = #{id}
AND tenant_id = #{tenantId}
@@ -310,7 +342,9 @@
WHEN 2 THEN 'put'
ELSE 'get'
END, '[^a-zA-Z0-9]+', '_', 'g')) AS tool_name,
COALESCE(NULLIF(api.api_name, ''), api.api_code) AS tool_title,
COALESCE(
NULLIF((api.api_ext ->> 'content')::json ->> 'title', ''),
NULLIF(api.api_name, ''), api.api_code) AS tool_title,
api.service_name AS tool_category,
api.service_name,
api.api_code,
@@ -323,17 +357,46 @@
END AS http_method,
regexp_replace(api.api_code, '^[^:]+:[^:]+:', '') AS api_path,
md5(api.api_code || ':' || api.api_name) AS schema_hash,
<!-- Risk level: declared api_ext value wins; otherwise derive from method + name.
A real DELETE, or a destructive/physical/bulk-impact POST whose api_name action
is delete/purge/clear/reset/command/issue/send/dispatch/import/reboot/execute
(DC3 exposes deletes as POST /delete, so the action suffix — not the HTTP method —
carries the risk), is HIGH; other POST/PUT is MEDIUM; GET is LOW. -->
COALESCE(
NULLIF(upper((api.api_ext ->> 'content')::json ->> 'riskLevel'), ''),
CASE
WHEN api.api_type_flag = 1 THEN 'HIGH'
WHEN api.api_type_flag = 0
AND api.api_name ~* '(delete|purge|clear|reset|command|issue|send|dispatch|import|reboot|execute)'
THEN 'HIGH'
WHEN api.api_type_flag IN (0, 2) THEN 'MEDIUM'
ELSE 'LOW'
END AS risk_level,
END) AS risk_level,
CASE WHEN api.api_type_flag = 3 THEN 1 ELSE 0 END AS read_only_hint,
CASE WHEN api.api_type_flag = 1 THEN 1 ELSE 0 END AS destructive_hint,
CASE WHEN api.api_type_flag IN (2, 3) THEN 1 ELSE 0 END AS idempotent_hint,
1 AS open_world_hint,
<!-- destructive: declared wins; else DELETE or reset/purge/clear POST. -->
COALESCE(
CASE lower(NULLIF((api.api_ext ->> 'content')::json ->> 'destructiveHint', ''))
WHEN 'true' THEN 1 WHEN 'false' THEN 0 ELSE NULL END,
CASE
WHEN api.api_type_flag = 1 THEN 1
WHEN api.api_type_flag = 0 AND api.api_name ~* '(reset|purge|clear|delete)' THEN 1
ELSE 0 END) AS destructive_hint,
<!-- idempotent: declared wins; else PUT/GET are idempotent, POST/DELETE are not. -->
COALESCE(
CASE lower(NULLIF((api.api_ext ->> 'content')::json ->> 'idempotentHint', ''))
WHEN 'true' THEN 1 WHEN 'false' THEN 0 ELSE NULL END,
CASE WHEN api.api_type_flag IN (2, 3) THEN 1 ELSE 0 END) AS idempotent_hint,
<!-- open-world: declared wins; else only outward-reaching ops (device command,
notification, external call) are open-world, plain DB read/write is not. -->
COALESCE(
CASE lower(NULLIF((api.api_ext ->> 'content')::json ->> 'openWorldHint', ''))
WHEN 'true' THEN 1 WHEN 'false' THEN 0 ELSE NULL END,
CASE WHEN api.api_name ~* '(command|issue|send|dispatch|notify|reboot|execute|external)'
THEN 1 ELSE 0 END) AS open_world_hint,
0 AS enable_flag,
COALESCE(NULLIF(api.remark, ''), api.api_group) AS remark
COALESCE(
NULLIF((api.api_ext ->> 'content')::json ->> 'remark', ''),
NULLIF(api.remark, ''), api.api_group) AS remark
FROM dc3_api api
JOIN dc3_resource resource
ON resource.entity_id = api.id
@@ -388,23 +451,23 @@
<update id="updateTool"
parameterType="io.github.pnoker.common.auth.entity.oauth.McpToolRecord">
UPDATE dc3_mcp_tool_catalog
SET tool_name = #{toolName},
tool_title = #{toolTitle},
tool_category = #{toolCategory},
service_name = #{serviceName},
api_code = #{apiCode},
permission_code = #{permissionCode},
http_method = #{httpMethod},
api_path = #{apiPath},
schema_hash = #{schemaHash},
risk_level = #{riskLevel},
read_only_hint = #{readOnlyHint},
SET tool_name = #{toolName},
tool_title = #{toolTitle},
tool_category = #{toolCategory},
service_name = #{serviceName},
api_code = #{apiCode},
permission_code = #{permissionCode},
http_method = #{httpMethod},
api_path = #{apiPath},
schema_hash = #{schemaHash},
risk_level = #{riskLevel},
read_only_hint = #{readOnlyHint},
destructive_hint = #{destructiveHint},
idempotent_hint = #{idempotentHint},
open_world_hint = #{openWorldHint},
tool_ext = COALESCE(NULLIF(#{toolExt}, '')::json, '{}'::json),
remark = #{remark},
operate_time = CURRENT_TIMESTAMP
idempotent_hint = #{idempotentHint},
open_world_hint = #{openWorldHint},
tool_ext = COALESCE(NULLIF(#{toolExt}, '')::json, '{}'::json),
remark = #{remark},
operate_time = CURRENT_TIMESTAMP
WHERE id = #{id}
AND deleted = 0
</update>
@@ -490,14 +553,14 @@
<update id="updateConnectionLastUsed">
UPDATE dc3_mcp_connection
SET last_used_time = #{lastUsedTime},
operate_time = CURRENT_TIMESTAMP
operate_time = CURRENT_TIMESTAMP
WHERE id = #{id}
AND deleted = 0
</update>
<update id="deleteConnectionTools">
UPDATE dc3_mcp_connection_tool
SET deleted = 1,
SET deleted = 1,
operate_time = CURRENT_TIMESTAMP
WHERE connection_id = #{connectionId}
AND deleted = 0
@@ -538,10 +601,27 @@
<select id="listAudit"
resultType="io.github.pnoker.common.auth.entity.oauth.McpAuditCommand">
SELECT id, trace_id, tenant_id, principal_id, principal_type, client_id, connection_id,
tool_id, tool_name, permission_code, risk_level, confirm_id, idempotency_key,
argument_digest, status, error_code, duration_ms, client_name, client_version,
remote_ip, create_time
SELECT id,
trace_id,
tenant_id,
principal_id,
principal_type,
client_id,
connection_id,
tool_id,
tool_name,
permission_code,
risk_level,
confirm_id,
idempotency_key,
argument_digest,
status,
error_code,
duration_ms,
client_name,
client_version,
remote_ip,
create_time
FROM dc3_mcp_audit_log
WHERE deleted = 0
AND tenant_id = #{tenantId}
@@ -553,4 +633,66 @@
LIMIT #{limit}
</select>
<insert id="insertConfirmation"
parameterType="io.github.pnoker.common.auth.entity.oauth.McpToolConfirmationRecord">
INSERT INTO dc3_mcp_tool_confirmation
(id, confirm_id, tenant_id, principal_id, connection_id, tool_id, argument_digest, idempotency_key,
risk_level, status, expire_time)
VALUES (#{id}, #{confirmId}, #{tenantId}, #{principalId}, #{connectionId}, #{toolId}, #{argumentDigest},
#{idempotencyKey}, #{riskLevel}, #{status}, #{expireTime})
</insert>
<select id="selectConfirmationByConfirmId"
resultType="io.github.pnoker.common.auth.entity.oauth.McpToolConfirmationRecord">
SELECT id,
confirm_id,
tenant_id,
principal_id,
connection_id,
tool_id,
argument_digest,
idempotency_key,
risk_level,
status,
expire_time,
consumed_time,
create_time
FROM dc3_mcp_tool_confirmation
WHERE confirm_id = #{confirmId}
AND deleted = 0
LIMIT 1
</select>
<select id="selectConsumedByIdempotencyKey"
resultType="io.github.pnoker.common.auth.entity.oauth.McpToolConfirmationRecord">
SELECT id,
confirm_id,
tenant_id,
principal_id,
connection_id,
tool_id,
argument_digest,
idempotency_key,
risk_level,
status,
expire_time,
consumed_time,
create_time
FROM dc3_mcp_tool_confirmation
WHERE connection_id = #{connectionId}
AND idempotency_key = #{idempotencyKey}
AND status = 'CONSUMED'
AND deleted = 0
LIMIT 1
</select>
<update id="consumeConfirmation">
UPDATE dc3_mcp_tool_confirmation
SET status = 'CONSUMED',
consumed_time = #{consumedTime}
WHERE id = #{id}
AND status = 'PENDING'
AND deleted = 0
</update>
</mapper>
@@ -0,0 +1,34 @@
# Static OpenAPI specs for MCP tool schemas
`McpOpenApiAggregator` reads `openapi-<service>.json` from this directory at runtime to
enrich MCP tools with input parameter schemas (request body + query/path parameters).
The API contract is a compile-time fact that changes rarely, so it is snapshotted here as a
versioned file instead of being fetched over HTTP at runtime. This keeps the auth service
free of any dependency on the other center services being reachable, and removes any need to
expose `/v3/api-docs` in production.
## Files
One file per center service, named with the **bare** service name; the aggregator expands it
to `dc3-center-<service>` so keys line up with `dc3_api.api_code`:
- `openapi-auth.json`
- `openapi-manager.json`
- `openapi-data.json`
- `openapi-agentic.json`
## Regenerating after an API contract change
Export from a running dev/test stack and copy the results here:
```bash
# from iot-dc3/
make openapi # writes dc3/doc/openapi/openapi-<svc>.json
cp dc3/doc/openapi/openapi-*.json \
dc3-common/dc3-common-auth/src/main/resources/openapi/
```
Then rebuild and refresh the catalog (`POST /auth/mcp/tool/catalog/refresh`, or wait for the
scheduled refresh). A missing or stale file is non-fatal: affected tools simply ship without
a parameter schema until the file is updated.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -20,11 +20,16 @@ package io.github.pnoker.common.auth.biz.impl;
import io.github.pnoker.common.auth.dal.PrincipalManager;
import io.github.pnoker.common.auth.dal.ServiceAccountManager;
import io.github.pnoker.common.auth.entity.model.ServiceAccountDO;
import io.github.pnoker.common.auth.entity.oauth.McpToolConfirmationRecord;
import io.github.pnoker.common.auth.entity.oauth.McpToolRecord;
import io.github.pnoker.common.auth.entity.oauth.OAuthAuthorizationRecord;
import io.github.pnoker.common.auth.entity.oauth.OAuthRegisteredClientRecord;
import io.github.pnoker.common.auth.mapper.OAuthMcpMapper;
import io.github.pnoker.common.auth.service.TenantMembershipService;
import io.github.pnoker.common.constant.service.McpConstant;
import io.github.pnoker.common.entity.common.RequestHeader;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeRequestDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolDefinitionDTO;
import io.github.pnoker.common.entity.dto.OAuthClientRegistrationRequestDTO;
import io.github.pnoker.common.entity.dto.OAuthClientRegistrationResponseDTO;
@@ -38,6 +43,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -45,6 +51,8 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -73,6 +81,174 @@ class OAuthMcpRuntimeServiceImplTest {
ReflectionTestUtils.setField(service, "authorizationCodeTtl", Duration.ofMinutes(5));
ReflectionTestUtils.setField(service, "accessTokenTtl", Duration.ofMinutes(15));
ReflectionTestUtils.setField(service, "refreshTokenTtl", Duration.ofDays(30));
ReflectionTestUtils.setField(service, "confirmTtl", Duration.ofMinutes(5));
}
private McpToolRecord highRiskTool() {
McpToolRecord tool = new McpToolRecord();
tool.setToolId("manager:POST:/device/delete");
tool.setToolName("manager_device_delete");
tool.setPermissionCode("manager:device:delete");
tool.setRiskLevel(McpConstant.RiskLevel.HIGH);
tool.setServiceName("dc3-center-manager");
tool.setApiPath("/device/delete");
tool.setHttpMethod("POST");
return tool;
}
private McpToolAuthorizeRequestDTO authorizeRequest(String confirmId, String idempotencyKey, String digest) {
return McpToolAuthorizeRequestDTO.builder()
.tenantId(1L)
.principalId(100L)
.mcpConnectionId(300L)
.scope("mcp:tools:call mcp:tools:call:high")
.toolName("manager_device_delete")
.argumentDigest(digest)
.confirmId(confirmId)
.idempotencyKey(idempotencyKey)
.build();
}
@Test
void authorizeLowRiskToolPassesWithoutConfirmation() {
McpToolRecord lowRisk = new McpToolRecord();
lowRisk.setToolId("manager:GET:/device/get");
lowRisk.setToolName("manager_device_get");
lowRisk.setRiskLevel(McpConstant.RiskLevel.LOW);
when(oauthMcpMapper.selectVisibleToolByName(1L, 100L, 300L, "manager_device_get", true))
.thenReturn(lowRisk);
McpToolAuthorizeResponseDTO decision = service.authorizeToolCall(McpToolAuthorizeRequestDTO.builder()
.tenantId(1L).principalId(100L).mcpConnectionId(300L)
.scope("mcp:tools:call mcp:tools:call:high").toolName("manager_device_get").build());
assertThat(decision.getDecision()).isEqualTo("AUTHORIZED");
verify(oauthMcpMapper, never()).insertConfirmation(any());
}
@Test
void authorizeHighRiskWithoutConfirmIdIssuesPendingTicket() {
when(oauthMcpMapper.selectVisibleToolByName(1L, 100L, 300L, "manager_device_delete", true))
.thenReturn(highRiskTool());
McpToolAuthorizeResponseDTO decision = service.authorizeToolCall(authorizeRequest("", "idem-1", "digest-1"));
assertThat(decision.getDecision()).isEqualTo("CONFIRM_REQUIRED");
assertThat(decision.getConfirmId()).isNotBlank();
ArgumentCaptor<McpToolConfirmationRecord> captor = ArgumentCaptor.forClass(McpToolConfirmationRecord.class);
verify(oauthMcpMapper).insertConfirmation(captor.capture());
McpToolConfirmationRecord ticket = captor.getValue();
assertThat(ticket.getStatus()).isEqualTo("PENDING");
assertThat(ticket.getToolId()).isEqualTo("manager:POST:/device/delete");
assertThat(ticket.getArgumentDigest()).isEqualTo("digest-1");
assertThat(ticket.getExpireTime()).isAfter(LocalDateTime.now());
}
@Test
void authorizeHighRiskWithUsedIdempotencyKeyIsRejected() {
when(oauthMcpMapper.selectVisibleToolByName(1L, 100L, 300L, "manager_device_delete", true))
.thenReturn(highRiskTool());
when(oauthMcpMapper.selectConsumedByIdempotencyKey(300L, "idem-used"))
.thenReturn(new McpToolConfirmationRecord());
McpToolAuthorizeResponseDTO decision =
service.authorizeToolCall(authorizeRequest("", "idem-used", "digest-1"));
assertThat(decision.getDecision()).isEqualTo("REJECTED");
assertThat(decision.getMessage()).contains("idempotency key");
verify(oauthMcpMapper, never()).insertConfirmation(any());
}
@Test
void authorizeHighRiskWithValidConfirmIdConsumesTicketAndAuthorizes() {
when(oauthMcpMapper.selectVisibleToolByName(1L, 100L, 300L, "manager_device_delete", true))
.thenReturn(highRiskTool());
McpToolConfirmationRecord ticket = new McpToolConfirmationRecord();
ticket.setId(900L);
ticket.setConfirmId("confirm-ok");
ticket.setPrincipalId(100L);
ticket.setConnectionId(300L);
ticket.setToolId("manager:POST:/device/delete");
ticket.setArgumentDigest("digest-1");
ticket.setStatus("PENDING");
ticket.setExpireTime(LocalDateTime.now().plusMinutes(5));
when(oauthMcpMapper.selectConfirmationByConfirmId("confirm-ok")).thenReturn(ticket);
when(oauthMcpMapper.consumeConfirmation(eq(900L), any())).thenReturn(1);
McpToolAuthorizeResponseDTO decision =
service.authorizeToolCall(authorizeRequest("confirm-ok", "idem-1", "digest-1"));
assertThat(decision.getDecision()).isEqualTo("AUTHORIZED");
verify(oauthMcpMapper).consumeConfirmation(eq(900L), any());
}
@Test
void authorizeHighRiskWithMismatchedDigestIsRejected() {
when(oauthMcpMapper.selectVisibleToolByName(1L, 100L, 300L, "manager_device_delete", true))
.thenReturn(highRiskTool());
McpToolConfirmationRecord ticket = new McpToolConfirmationRecord();
ticket.setId(901L);
ticket.setConfirmId("confirm-mismatch");
ticket.setPrincipalId(100L);
ticket.setConnectionId(300L);
ticket.setToolId("manager:POST:/device/delete");
ticket.setArgumentDigest("original-digest");
ticket.setStatus("PENDING");
ticket.setExpireTime(LocalDateTime.now().plusMinutes(5));
when(oauthMcpMapper.selectConfirmationByConfirmId("confirm-mismatch")).thenReturn(ticket);
McpToolAuthorizeResponseDTO decision =
service.authorizeToolCall(authorizeRequest("confirm-mismatch", "idem-1", "tampered-digest"));
assertThat(decision.getDecision()).isEqualTo("REJECTED");
assertThat(decision.getMessage()).contains("arguments do not match");
verify(oauthMcpMapper, never()).consumeConfirmation(any(), any());
}
@Test
void authorizeHighRiskWithExpiredConfirmIdIsRejected() {
when(oauthMcpMapper.selectVisibleToolByName(1L, 100L, 300L, "manager_device_delete", true))
.thenReturn(highRiskTool());
McpToolConfirmationRecord ticket = new McpToolConfirmationRecord();
ticket.setId(902L);
ticket.setConfirmId("confirm-expired");
ticket.setPrincipalId(100L);
ticket.setConnectionId(300L);
ticket.setToolId("manager:POST:/device/delete");
ticket.setArgumentDigest("digest-1");
ticket.setStatus("PENDING");
ticket.setExpireTime(LocalDateTime.now().minusMinutes(1));
when(oauthMcpMapper.selectConfirmationByConfirmId("confirm-expired")).thenReturn(ticket);
McpToolAuthorizeResponseDTO decision =
service.authorizeToolCall(authorizeRequest("confirm-expired", "idem-1", "digest-1"));
assertThat(decision.getDecision()).isEqualTo("REJECTED");
assertThat(decision.getMessage()).contains("expired");
verify(oauthMcpMapper, never()).consumeConfirmation(any(), any());
}
@Test
void authorizeHighRiskReplayOfConsumedConfirmIdIsRejected() {
when(oauthMcpMapper.selectVisibleToolByName(1L, 100L, 300L, "manager_device_delete", true))
.thenReturn(highRiskTool());
McpToolConfirmationRecord ticket = new McpToolConfirmationRecord();
ticket.setId(903L);
ticket.setConfirmId("confirm-consumed");
ticket.setPrincipalId(100L);
ticket.setConnectionId(300L);
ticket.setToolId("manager:POST:/device/delete");
ticket.setArgumentDigest("digest-1");
ticket.setStatus("CONSUMED");
ticket.setExpireTime(LocalDateTime.now().plusMinutes(5));
when(oauthMcpMapper.selectConfirmationByConfirmId("confirm-consumed")).thenReturn(ticket);
McpToolAuthorizeResponseDTO decision =
service.authorizeToolCall(authorizeRequest("confirm-consumed", "idem-1", "digest-1"));
assertThat(decision.getDecision()).isEqualTo("REJECTED");
assertThat(decision.getMessage()).contains("already been used");
verify(oauthMcpMapper, never()).consumeConfirmation(any(), any());
}
@Test
@@ -238,6 +414,41 @@ class OAuthMcpRuntimeServiceImplTest {
assertThat(visible.get(0).getMeta().getRiskLevel()).isEqualTo("LOW");
}
@Test
void refreshTokenReplayOfRotatedTokenRevokesAuthorization() {
OAuthAuthorizationRecord replayed = new OAuthAuthorizationRecord();
replayed.setAccessTokenJti("jti-leaked");
when(oauthMcpMapper.selectAuthorizationByRefreshTokenHash(any())).thenReturn(null);
when(oauthMcpMapper.selectAuthorizationByPreviousRefreshTokenHash(any())).thenReturn(replayed);
Map<String, String> form = Map.of(
"grant_type", "refresh_token",
"refresh_token", "leaked-old-token"
);
assertThatThrownBy(() -> service.token(form, null))
.isInstanceOf(OAuthMcpRuntimeServiceImpl.OAuthProtocolException.class)
.hasMessageContaining("refresh token has been revoked");
verify(oauthMcpMapper).revokeAuthorizationByAccessTokenJti(eq("jti-leaked"),
eq("refresh_token_replayed"), any());
}
@Test
void refreshTokenRejectsUnknownTokenWithoutRevocation() {
when(oauthMcpMapper.selectAuthorizationByRefreshTokenHash(any())).thenReturn(null);
when(oauthMcpMapper.selectAuthorizationByPreviousRefreshTokenHash(any())).thenReturn(null);
Map<String, String> form = Map.of(
"grant_type", "refresh_token",
"refresh_token", "never-issued"
);
assertThatThrownBy(() -> service.token(form, null))
.isInstanceOf(OAuthMcpRuntimeServiceImpl.OAuthProtocolException.class)
.hasMessageContaining("refresh token is invalid or expired");
verify(oauthMcpMapper, never()).revokeAuthorizationByAccessTokenJti(any(), any(), any());
}
@SuppressWarnings("unchecked")
private List<String> castList(Object value) {
return (List<String>) value;
@@ -26,6 +26,8 @@ import io.github.pnoker.common.auth.entity.model.PrincipalDO;
import io.github.pnoker.common.auth.service.LocalCredentialService;
import io.github.pnoker.common.auth.service.TenantMembershipService;
import io.github.pnoker.common.auth.service.TenantService;
import io.github.pnoker.common.enums.ResponseEnum;
import io.github.pnoker.common.exception.PasswordChangeRequiredException;
import io.github.pnoker.common.exception.UnAuthorizedException;
import io.github.pnoker.common.utils.KeyUtil;
import io.github.pnoker.common.utils.PasswordUtil;
@@ -176,6 +178,59 @@ class TokenServiceImplTest {
verify(localCredentialService).recordFailedLogin(CREDENTIAL_ID);
}
@Test
void generateTokenRejectsExpiredPasswordWithoutIssuingToken() {
credential.setPasswordExpireTime(java.time.LocalDateTime.now().minusDays(1));
when(tenantService.getByCode(TENANT_CODE)).thenReturn(tenant);
when(localCredentialService.getByLoginName(LOGIN, false)).thenReturn(credential);
when(tenantMembershipService.isTenantMember(TENANT_ID, PRINCIPAL_ID)).thenReturn(true);
when(localCredentialService.verifyPassword(credential, RAW_PASSWORD)).thenReturn(true);
assertThatThrownBy(() -> tokenService.generateToken(LOGIN, SALT, RAW_PASSWORD, TENANT_CODE))
.isInstanceOf(PasswordChangeRequiredException.class)
.extracting(e -> ((PasswordChangeRequiredException) e).getResponseEnum())
.isEqualTo(ResponseEnum.PASSWORD_EXPIRED);
verify(localCredentialService).recordSuccessfulLogin(CREDENTIAL_ID);
verify(principalManager, never()).updateById(any(PrincipalDO.class));
}
@Test
void generateTokenRejectsRequirePasswordChangeWithoutIssuingToken() {
credential.setRequirePasswordChange((byte) 1);
when(tenantService.getByCode(TENANT_CODE)).thenReturn(tenant);
when(localCredentialService.getByLoginName(LOGIN, false)).thenReturn(credential);
when(tenantMembershipService.isTenantMember(TENANT_ID, PRINCIPAL_ID)).thenReturn(true);
when(localCredentialService.verifyPassword(credential, RAW_PASSWORD)).thenReturn(true);
assertThatThrownBy(() -> tokenService.generateToken(LOGIN, SALT, RAW_PASSWORD, TENANT_CODE))
.isInstanceOf(PasswordChangeRequiredException.class)
.extracting(e -> ((PasswordChangeRequiredException) e).getResponseEnum())
.isEqualTo(ResponseEnum.PASSWORD_CHANGE_REQUIRED);
verify(principalManager, never()).updateById(any(PrincipalDO.class));
}
@Test
void changePasswordDelegatesAfterTenantMembershipCheck() {
when(tenantService.getByCode(TENANT_CODE)).thenReturn(tenant);
when(localCredentialService.getByLoginName(LOGIN, false)).thenReturn(credential);
when(tenantMembershipService.isTenantMember(TENANT_ID, PRINCIPAL_ID)).thenReturn(true);
tokenService.changePassword(LOGIN, RAW_PASSWORD, "new-secret", TENANT_CODE);
verify(localCredentialService).changePassword(LOGIN, RAW_PASSWORD, "new-secret");
}
@Test
void changePasswordRejectsUnboundPrincipal() {
when(tenantService.getByCode(TENANT_CODE)).thenReturn(tenant);
when(localCredentialService.getByLoginName(LOGIN, false)).thenReturn(credential);
when(tenantMembershipService.isTenantMember(TENANT_ID, PRINCIPAL_ID)).thenReturn(false);
assertThatThrownBy(() -> tokenService.changePassword(LOGIN, RAW_PASSWORD, "new-secret", TENANT_CODE))
.isInstanceOf(UnAuthorizedException.class);
verify(localCredentialService, never()).changePassword(any(), any(), any());
}
@Test
void checkValidRejectsUnknownTenant() {
when(tenantService.getByCode(TENANT_CODE)).thenReturn(null);
@@ -0,0 +1,128 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.auth.tool;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Covers {@link McpOpenApiAggregator#buildOperationSchema} — the merge of JSON request body
* fields with query/path parameters into one MCP input schema.
*/
class McpOpenApiAggregatorTest {
private final ObjectMapper mapper = new ObjectMapper();
private final McpOpenApiAggregator aggregator = new McpOpenApiAggregator();
@Test
void mergesRequestBodyFieldsWithQueryAndPathParameters() throws Exception {
JsonNode operation = mapper.readTree("""
{
"parameters": [
{"name": "tenantId", "in": "path", "schema": {"type": "integer"}},
{"name": "keyword", "in": "query", "required": true,
"description": "search keyword", "schema": {"type": "string"}},
{"name": "page", "in": "query", "schema": {"type": "integer"}}
],
"requestBody": {"content": {"application/json": {"schema": {
"type": "object",
"properties": {"deviceName": {"type": "string"}},
"required": ["deviceName"]
}}}}
}
""");
ObjectNode schema = aggregator.buildOperationSchema(operation, mapper.createObjectNode());
assertThat(schema).isNotNull();
assertThat(schema.get("type").asText()).isEqualTo("object");
JsonNode props = schema.get("properties");
assertThat(props.has("deviceName")).isTrue();
assertThat(props.has("tenantId")).isTrue();
assertThat(props.has("keyword")).isTrue();
assertThat(props.get("keyword").get("description").asText()).isEqualTo("search keyword");
assertThat(props.has("page")).isTrue();
// deviceName (body required), tenantId (path always), keyword (query required) — page is optional.
assertThat(schema.get("required")).extracting(JsonNode::asText)
.containsExactlyInAnyOrder("deviceName", "tenantId", "keyword");
}
@Test
void queryOnlyOperationStillProducesSchema() throws Exception {
JsonNode operation = mapper.readTree("""
{
"parameters": [
{"name": "id", "in": "query", "required": true, "schema": {"type": "integer"}}
]
}
""");
ObjectNode schema = aggregator.buildOperationSchema(operation, mapper.createObjectNode());
assertThat(schema).isNotNull();
assertThat(schema.get("properties").has("id")).isTrue();
assertThat(schema.get("required")).extracting(JsonNode::asText).containsExactly("id");
}
@Test
void loadsStaticSpecFromClasspathWithFullServiceNameKeys() throws Exception {
Map<String, String> schemas = aggregator.inputSchemasByApiCode();
// Keys expand the file's bare service name to dc3-center-<service> to match api_code.
String addKey = "dc3-center-fixturesvc:POST:/device/add";
String listKey = "dc3-center-fixturesvc:POST:/device/list_by_ids";
assertThat(schemas).containsKeys(addKey, listKey);
JsonNode add = mapper.readTree(schemas.get(addKey));
assertThat(add.get("properties").has("deviceName")).isTrue();
assertThat(add.get("properties").has("driverId")).isTrue();
assertThat(add.get("required")).extracting(JsonNode::asText).containsExactly("deviceName");
// A body-less POST that only declares a query param still yields a schema.
JsonNode list = mapper.readTree(schemas.get(listKey));
assertThat(list.get("properties").has("page")).isTrue();
}
@Test
void operationWithoutBodyOrParametersReturnsNull() throws Exception {
JsonNode operation = mapper.readTree("{}");
assertThat(aggregator.buildOperationSchema(operation, mapper.createObjectNode())).isNull();
}
@Test
void headerParametersAreIgnored() throws Exception {
JsonNode operation = mapper.readTree("""
{
"parameters": [
{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}
]
}
""");
// Only query/path params count; a header-only operation yields no schema.
assertThat(aggregator.buildOperationSchema(operation, mapper.createObjectNode())).isNull();
}
}
@@ -0,0 +1,49 @@
{
"openapi": "3.0.1",
"info": {
"title": "manager",
"version": "v1"
},
"paths": {
"/device/add": {
"post": {
"summary": "Add Device",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"deviceName": {
"type": "string",
"description": "device name"
},
"driverId": {
"type": "integer"
}
},
"required": [
"deviceName"
]
}
}
}
}
}
},
"/device/list_by_ids": {
"post": {
"summary": "List Devices by IDs",
"parameters": [
{
"name": "page",
"in": "query",
"schema": {
"type": "integer"
}
}
]
}
}
}
}
@@ -398,6 +398,27 @@ public class McpConstant {
}
/**
* High-risk tool call confirmation ticket constants.
*/
public static class Confirmation {
public static final String DECISION_AUTHORIZED = "AUTHORIZED";
public static final String DECISION_CONFIRM_REQUIRED = "CONFIRM_REQUIRED";
public static final String DECISION_REJECTED = "REJECTED";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_CONSUMED = "CONSUMED";
private Confirmation() {
throw new IllegalStateException(BaseConstant.UTILITY_CLASS);
}
}
/**
* Audit statuses emitted by the gateway runtime.
*/
@@ -38,6 +38,8 @@ public enum ResponseEnum {
OK(200, "R200", "Success"), TOKEN_INVALID(20301, "R20301", "Token is invalid"),
IP_INVALID(20302, "R20302", "Invalid IP"), FAILURE(500, "R500", "Service exception"),
NO_RESOURCE(404, "R404", "Resource does not exist"), OUT_RANGE(900, "R900", "Number out of range"),
PASSWORD_CHANGE_REQUIRED(20303, "R20303", "Password change required"),
PASSWORD_EXPIRED(20304, "R20304", "Password expired"),
ADD_SUCCESS(20001, "R20001", "Added successfully"),
DELETE_SUCCESS(20002, "R20002", "Deleted successfully"),
@@ -0,0 +1,43 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.exception;
import io.github.pnoker.common.enums.ResponseEnum;
import lombok.Getter;
/**
* Raised when a local credential may not be used to log in until its password is changed,
* either because a password change is mandated or because the password has expired. The
* carried {@link ResponseEnum} lets the web layer return a distinct response code so the
* client can route the user to the self-service password change flow.
*
* @author pnoker
* @version 2026.6.17
* @since 2026.6.17
*/
@Getter
public class PasswordChangeRequiredException extends RuntimeException {
private final ResponseEnum responseEnum;
public PasswordChangeRequiredException(ResponseEnum responseEnum) {
super(responseEnum.getRemark());
this.responseEnum = responseEnum;
}
}
@@ -19,6 +19,8 @@ package io.github.pnoker.common.facade.api;
import io.github.pnoker.common.entity.dto.McpAuditCommandDTO;
import io.github.pnoker.common.entity.dto.McpIntrospectResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeRequestDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolListResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolResolveResponseDTO;
@@ -63,6 +65,14 @@ public interface McpRuntimeFacade {
McpToolResolveResponseDTO resolveTool(Long tenantId, Long principalId, Long mcpConnectionId, String scope,
String toolName);
/**
* Authorize one tool call, enforcing high-risk confirmation and idempotency.
*
* @param request authorization request
* @return authorization decision
*/
McpToolAuthorizeResponseDTO authorizeToolCall(McpToolAuthorizeRequestDTO request);
/**
* Store one MCP call audit record.
*
@@ -67,4 +67,28 @@ public class FacadeScannedApiBO {
@Schema(description = "API grouping label")
private String apiGroup;
@Schema(description = "Declared MCP risk level, blank when derived")
private String riskLevel;
@Schema(description = "Declared MCP destructive hint, blank when derived")
private String destructiveHint;
@Schema(description = "Declared MCP open-world hint, blank when derived")
private String openWorldHint;
@Schema(description = "Declared MCP idempotent hint, blank when derived")
private String idempotentHint;
@Schema(description = "AI-facing MCP tool description override")
private String aiDescription;
@Schema(description = "Whether the MCP tool is hidden from tools/list by default")
private String hidden;
}
@@ -17,24 +17,13 @@
package io.github.pnoker.common.facade.grpc;
import io.github.pnoker.api.center.auth.GrpcMcpAuditCommand;
import io.github.pnoker.api.center.auth.GrpcMcpIntrospectDTO;
import io.github.pnoker.api.center.auth.GrpcMcpIntrospectRequest;
import io.github.pnoker.api.center.auth.GrpcMcpToolAnnotationsDTO;
import io.github.pnoker.api.center.auth.GrpcMcpToolDefinitionDTO;
import io.github.pnoker.api.center.auth.GrpcMcpToolListRequest;
import io.github.pnoker.api.center.auth.GrpcMcpToolMetadataDTO;
import io.github.pnoker.api.center.auth.GrpcMcpToolResolveDTO;
import io.github.pnoker.api.center.auth.GrpcMcpToolResolveRequest;
import io.github.pnoker.api.center.auth.GrpcRMcpBoolean;
import io.github.pnoker.api.center.auth.GrpcRMcpIntrospectDTO;
import io.github.pnoker.api.center.auth.GrpcRMcpToolListDTO;
import io.github.pnoker.api.center.auth.GrpcRMcpToolResolveDTO;
import io.github.pnoker.api.center.auth.McpRuntimeApiGrpc;
import io.github.pnoker.api.center.auth.*;
import io.github.pnoker.api.common.GrpcR;
import io.github.pnoker.common.constant.service.McpConstant;
import io.github.pnoker.common.entity.dto.McpAuditCommandDTO;
import io.github.pnoker.common.entity.dto.McpIntrospectResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeRequestDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolDefinitionDTO;
import io.github.pnoker.common.entity.dto.McpToolListResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolResolveResponseDTO;
@@ -106,6 +95,25 @@ public class McpRuntimeGrpcFacade implements McpRuntimeFacade {
return response.hasData() ? toDTO(response.getData()) : new McpToolResolveResponseDTO();
}
@Override
public McpToolAuthorizeResponseDTO authorizeToolCall(McpToolAuthorizeRequestDTO request) {
request = request == null ? new McpToolAuthorizeRequestDTO() : request;
GrpcMcpToolAuthorizeRequest grpcRequest = GrpcMcpToolAuthorizeRequest.newBuilder()
.setTenantId(value(request.getTenantId()))
.setPrincipalId(value(request.getPrincipalId()))
.setMcpConnectionId(value(request.getMcpConnectionId()))
.setScope(StringUtils.defaultString(request.getScope()))
.setToolName(StringUtils.defaultString(request.getToolName()))
.setArgumentDigest(StringUtils.defaultString(request.getArgumentDigest()))
.setConfirmId(StringUtils.defaultString(request.getConfirmId()))
.setIdempotencyKey(StringUtils.defaultString(request.getIdempotencyKey()))
.build();
GrpcRMcpToolAuthorizeDTO response = grpcFacadeSupport.call("McpRuntimeFacade.authorizeToolCall",
mcpRuntimeApiBlockingStub, stub -> stub.authorizeToolCall(grpcRequest));
requireOk("McpRuntimeFacade.authorizeToolCall", response.getResult());
return response.hasData() ? toDTO(response.getData()) : new McpToolAuthorizeResponseDTO();
}
@Override
public void audit(McpAuditCommandDTO command) {
GrpcRMcpBoolean response = grpcFacadeSupport.call("McpRuntimeFacade.audit", mcpRuntimeApiBlockingStub,
@@ -113,6 +121,15 @@ public class McpRuntimeGrpcFacade implements McpRuntimeFacade {
requireOk("McpRuntimeFacade.audit", response.getResult());
}
private McpToolAuthorizeResponseDTO toDTO(GrpcMcpToolAuthorizeDTO source) {
return McpToolAuthorizeResponseDTO.builder()
.decision(source.getDecision())
.confirmId(source.getConfirmId())
.message(source.getMessage())
.riskLevel(source.getRiskLevel())
.build();
}
private McpIntrospectResponseDTO toDTO(GrpcMcpIntrospectDTO source) {
return McpIntrospectResponseDTO.builder()
.active(source.getActive())
@@ -64,6 +64,12 @@ public class ResourceRegistryGrpcFacade implements ResourceRegistryFacade {
.setTitle(Objects.requireNonNullElse(api.getTitle(), ""))
.setRemark(Objects.requireNonNullElse(api.getRemark(), ""))
.setApiGroup(Objects.requireNonNullElse(api.getApiGroup(), ""))
.setRiskLevel(Objects.requireNonNullElse(api.getRiskLevel(), ""))
.setDestructiveHint(Objects.requireNonNullElse(api.getDestructiveHint(), ""))
.setOpenWorldHint(Objects.requireNonNullElse(api.getOpenWorldHint(), ""))
.setIdempotentHint(Objects.requireNonNullElse(api.getIdempotentHint(), ""))
.setAiDescription(Objects.requireNonNullElse(api.getAiDescription(), ""))
.setHidden(Objects.requireNonNullElse(api.getHidden(), ""))
.build());
}
}
@@ -21,6 +21,8 @@ import io.github.pnoker.common.auth.biz.OAuthMcpRuntimeService;
import io.github.pnoker.common.constant.service.McpConstant;
import io.github.pnoker.common.entity.dto.McpAuditCommandDTO;
import io.github.pnoker.common.entity.dto.McpIntrospectResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeRequestDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolListResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolResolveResponseDTO;
import io.github.pnoker.common.facade.api.McpRuntimeFacade;
@@ -64,6 +66,11 @@ public class McpRuntimeLocalFacade implements McpRuntimeFacade {
scopes(scope));
}
@Override
public McpToolAuthorizeResponseDTO authorizeToolCall(McpToolAuthorizeRequestDTO request) {
return oauthMcpRuntimeService.authorizeToolCall(request);
}
@Override
public void audit(McpAuditCommandDTO command) {
oauthMcpRuntimeService.audit(command);
@@ -61,6 +61,12 @@ public class ResourceRegistryLocalFacade implements ResourceRegistryFacade {
.title(bo.getTitle())
.remark(bo.getRemark())
.apiGroup(bo.getApiGroup())
.riskLevel(bo.getRiskLevel())
.destructiveHint(bo.getDestructiveHint())
.openWorldHint(bo.getOpenWorldHint())
.idempotentHint(bo.getIdempotentHint())
.aiDescription(bo.getAiDescription())
.hidden(bo.getHidden())
.build());
}
return out;
@@ -22,6 +22,8 @@ import io.github.pnoker.common.constant.service.McpConstant;
import io.github.pnoker.common.entity.common.RequestHeader;
import io.github.pnoker.common.entity.dto.McpAuditCommandDTO;
import io.github.pnoker.common.entity.dto.McpIntrospectResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeRequestDTO;
import io.github.pnoker.common.entity.dto.McpToolAuthorizeResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolListResponseDTO;
import io.github.pnoker.common.entity.dto.McpToolResolveResponseDTO;
import io.github.pnoker.common.facade.api.McpRuntimeFacade;
@@ -211,36 +213,55 @@ public class McpGatewayController {
ServerWebExchange exchange) {
long start = System.nanoTime();
String traceId = UUID.randomUUID().toString();
String argumentDigest = DecodeUtil.sha256Base64Url(JsonUtil.toJsonString(arguments));
return blocking(() -> mcpRuntimeFacade.resolveTool(context.getTenantId(), context.getPrincipalId(),
context.getMcpConnectionId(), context.getScope(), toolName)).flatMap(tool -> {
McpToolCallControls controls = controlValues(callMeta, exchange);
String policyError = policyError(tool, controls);
if (StringUtils.isNotBlank(policyError)) {
return audit(context, tool, traceId, arguments, controls, McpConstant.Audit.DENIED,
McpConstant.Audit.POLICY_DENIED, start, exchange)
.thenReturn(orderedMap(McpConstant.ToolResult.IS_ERROR, true,
McpConstant.ToolResult.CONTENT, List.of(orderedMap(
return blocking(() -> mcpRuntimeFacade.authorizeToolCall(McpToolAuthorizeRequestDTO.builder()
.tenantId(context.getTenantId())
.principalId(context.getPrincipalId())
.mcpConnectionId(context.getMcpConnectionId())
.scope(context.getScope())
.toolName(toolName)
.argumentDigest(argumentDigest)
.confirmId(controls.confirmId())
.idempotencyKey(controls.idempotencyKey())
.build())).flatMap(decision -> {
if (!McpConstant.Confirmation.DECISION_AUTHORIZED.equals(decision.getDecision())) {
return audit(context, tool, traceId, arguments, controls, McpConstant.Audit.DENIED,
McpConstant.Audit.POLICY_DENIED, start, exchange)
.thenReturn(toolError(authorizationMessage(decision)));
}
return invokeBackend(context, tool, arguments, controls)
.flatMap(result -> audit(context, tool, traceId, arguments, controls,
McpConstant.Audit.SUCCESS, "", start, exchange)
.thenReturn(orderedMap(McpConstant.ToolResult.CONTENT, List.of(orderedMap(
McpConstant.ToolResult.TYPE, McpConstant.ToolResult.TYPE_TEXT,
McpConstant.ToolResult.TEXT, policyError
))));
}
return invokeBackend(context, tool, arguments, controls)
.flatMap(result -> audit(context, tool, traceId, arguments, controls,
McpConstant.Audit.SUCCESS, "", start, exchange)
.thenReturn(orderedMap(McpConstant.ToolResult.CONTENT, List.of(orderedMap(
McpConstant.ToolResult.TYPE, McpConstant.ToolResult.TYPE_TEXT,
McpConstant.ToolResult.TEXT, JsonUtil.toJsonString(result)
)))))
.onErrorResume(e -> audit(context, tool, traceId, arguments, controls,
McpConstant.Audit.ERROR, e.getClass().getSimpleName(), start, exchange)
.thenReturn(orderedMap(McpConstant.ToolResult.IS_ERROR, true,
McpConstant.ToolResult.CONTENT, List.of(orderedMap(
McpConstant.ToolResult.TYPE, McpConstant.ToolResult.TYPE_TEXT,
McpConstant.ToolResult.TEXT, e.getMessage()
)))));
McpConstant.ToolResult.TEXT, JsonUtil.toJsonString(result)
)))))
.onErrorResume(e -> audit(context, tool, traceId, arguments, controls,
McpConstant.Audit.ERROR, e.getClass().getSimpleName(), start, exchange)
.thenReturn(toolError(e.getMessage())));
});
});
}
private Map<String, Object> toolError(String message) {
return orderedMap(McpConstant.ToolResult.IS_ERROR, true,
McpConstant.ToolResult.CONTENT, List.of(orderedMap(
McpConstant.ToolResult.TYPE, McpConstant.ToolResult.TYPE_TEXT,
McpConstant.ToolResult.TEXT, StringUtils.defaultString(message)
)));
}
private String authorizationMessage(McpToolAuthorizeResponseDTO decision) {
if (McpConstant.Confirmation.DECISION_CONFIRM_REQUIRED.equals(decision.getDecision())
&& StringUtils.isNotBlank(decision.getConfirmId())) {
return decision.getMessage() + " (confirmId=" + decision.getConfirmId() + ")";
}
return StringUtils.defaultString(decision.getMessage());
}
private Mono<Map<String, Object>> invokeBackend(McpIntrospectResponseDTO context,
McpToolResolveResponseDTO tool,
Map<String, Object> arguments,
@@ -343,19 +364,6 @@ public class McpGatewayController {
);
}
private String policyError(McpToolResolveResponseDTO tool, McpToolCallControls controls) {
if (!McpConstant.RiskLevel.HIGH.equals(tool.getRiskLevel())) {
return "";
}
if (StringUtils.isBlank(controls.confirmId())) {
return "High risk MCP tool requires confirmation";
}
if (StringUtils.isBlank(controls.idempotencyKey())) {
return "High risk MCP tool requires an idempotency key";
}
return "";
}
private String firstNonBlank(Object... values) {
for (Object value : values) {
String text = Objects.toString(value, "");
@@ -0,0 +1,67 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.entity.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.io.Serial;
import java.io.Serializable;
/**
* Gateway-to-auth request that authorizes one MCP tool call, carrying the high-risk
* confirmation ticket and idempotency key when present.
*
* @author pnoker
* @version 2026.6.17
* @since 2026.6.17
*/
@Getter
@Setter
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class McpToolAuthorizeRequestDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long tenantId;
private Long principalId;
private Long mcpConnectionId;
private String scope;
private String toolName;
private String argumentDigest;
private String confirmId;
private String idempotencyKey;
}
@@ -0,0 +1,60 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.entity.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.io.Serial;
import java.io.Serializable;
/**
* Auth-to-gateway authorization decision for one MCP tool call. {@code decision} is one of
* AUTHORIZED, CONFIRM_REQUIRED, or REJECTED; {@code confirmId} carries the issued ticket when
* a high-risk confirmation is required.
*
* @author pnoker
* @version 2026.6.17
* @since 2026.6.17
*/
@Getter
@Setter
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class McpToolAuthorizeResponseDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String decision;
private String confirmId;
private String message;
private String riskLevel;
}
@@ -72,6 +72,42 @@ public class ApiExt extends BaseExt {
@Schema(description = "Description remark of the API interface")
private String remark;
/**
* Declared MCP risk level (LOW / MEDIUM / HIGH); blank when derived automatically.
*/
@Schema(description = "Declared MCP risk level, blank when derived")
private String riskLevel;
/**
* Declared destructive hint ("true" / "false"); blank when derived.
*/
@Schema(description = "Declared MCP destructive hint, blank when derived")
private String destructiveHint;
/**
* Declared open-world hint ("true" / "false"); blank when derived.
*/
@Schema(description = "Declared MCP open-world hint, blank when derived")
private String openWorldHint;
/**
* Declared idempotent hint ("true" / "false"); blank when derived.
*/
@Schema(description = "Declared MCP idempotent hint, blank when derived")
private String idempotentHint;
/**
* AI-facing description override; blank when the operation text is used.
*/
@Schema(description = "AI-facing MCP tool description override")
private String aiDescription;
/**
* Whether the tool is hidden from tools/list by default ("true" / "false"); blank = visible.
*/
@Schema(description = "Whether the MCP tool is hidden from tools/list by default")
private String hidden;
}
}
@@ -21,7 +21,11 @@ import io.github.pnoker.common.annotation.PublicEndpoint;
import io.github.pnoker.common.constant.common.SymbolConstant;
import io.github.pnoker.common.facade.entity.bo.FacadeScannedApiBO;
import io.github.pnoker.common.resource.registrar.config.ResourceRegistrarProperties;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.extensions.Extension;
import io.swagger.v3.oas.annotations.extensions.ExtensionProperty;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.RequestMethod;
@@ -168,19 +172,60 @@ public class ApiEndpointScanner {
}
boolean isSingleGet = "GET".equals(method.name())
&& (path.contains("{") || path.contains("*"));
Operation operation = handler.getMethodAnnotation(Operation.class);
String title = operation != null && StringUtils.isNotBlank(operation.summary())
? operation.summary() : handler.getMethod().getName();
String remark = operation != null ? StringUtils.defaultString(operation.description()) : "";
Map<String, String> ai = aiMetadata(operation);
out.put(key,
FacadeScannedApiBO.builder()
.method(method.name())
.path(path)
.apiName(buildApiName(handler, method.name(), isSingleGet))
.title(handler.getMethod().getName())
.remark("")
.title(title)
.remark(remark)
.apiGroup(handler.getBeanType().getSimpleName())
.riskLevel(ai.getOrDefault("riskLevel", ""))
.destructiveHint(ai.getOrDefault("destructive", ""))
.openWorldHint(ai.getOrDefault("openWorld", ""))
.idempotentHint(ai.getOrDefault("idempotent", ""))
.aiDescription(ai.getOrDefault("description", ""))
.hidden(ai.getOrDefault("hidden", ""))
.build());
}
}
}
/**
* Read AI tool metadata declared on the operation through the standard OpenAPI
* {@code x-dc3-ai} extension, e.g.
* <pre>
* &#64;Operation(summary = "...", extensions = &#64;Extension(name = "x-dc3-ai", properties = {
* &#64;ExtensionProperty(name = "riskLevel", value = "HIGH"),
* &#64;ExtensionProperty(name = "openWorld", value = "true")}))
* </pre>
* Recognised keys: {@code riskLevel}, {@code destructive}, {@code openWorld},
* {@code idempotent}, {@code description}, {@code hidden}. Absent keys stay unset so the
* tool catalog falls back to its own derivation.
*/
private Map<String, String> aiMetadata(Operation operation) {
if (operation == null) {
return Map.of();
}
Map<String, String> metadata = new LinkedHashMap<>();
for (Extension extension : operation.extensions()) {
if (!"x-dc3-ai".equalsIgnoreCase(extension.name())) {
continue;
}
for (ExtensionProperty property : extension.properties()) {
if (StringUtils.isNotBlank(property.name())) {
metadata.put(property.name(), StringUtils.defaultString(property.value()));
}
}
}
return metadata;
}
private boolean isPublicEndpoint(HandlerMethod handler) {
return handler.hasMethodAnnotation(PublicEndpoint.class)
|| handler.getBeanType().isAnnotationPresent(PublicEndpoint.class);
@@ -20,6 +20,9 @@ package io.github.pnoker.common.resource.registrar.scan;
import io.github.pnoker.common.annotation.PublicEndpoint;
import io.github.pnoker.common.facade.entity.bo.FacadeScannedApiBO;
import io.github.pnoker.common.resource.registrar.config.ResourceRegistrarProperties;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.extensions.Extension;
import io.swagger.v3.oas.annotations.extensions.ExtensionProperty;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -65,6 +68,57 @@ class ApiEndpointScannerTest {
});
}
@Test
void operationAnnotationDrivesTitleAndRemarkWithMethodNameFallback() {
register(OperationController.class);
ApiEndpointScanner scanner = new ApiEndpointScanner(handlerMapping, new ResourceRegistrarProperties());
List<FacadeScannedApiBO> apis = scanner.scan();
FacadeScannedApiBO created = apis.stream()
.filter(a -> "POST".equals(a.getMethod()))
.findFirst().orElseThrow();
assertThat(created.getTitle()).isEqualTo("Add Device");
assertThat(created.getRemark()).isEqualTo("Create a new device record");
FacadeScannedApiBO fetched = apis.stream()
.filter(a -> "GET".equals(a.getMethod()))
.findFirst().orElseThrow();
// No @Operation: title falls back to the handler method name, remark stays blank.
assertThat(fetched.getTitle()).isEqualTo("get");
assertThat(fetched.getRemark()).isEmpty();
}
@Test
void xDc3AiExtensionPopulatesAiMetadataWithBlankDefaults() {
register(McpAnnotatedController.class);
ApiEndpointScanner scanner = new ApiEndpointScanner(handlerMapping, new ResourceRegistrarProperties());
List<FacadeScannedApiBO> apis = scanner.scan();
FacadeScannedApiBO command = apis.stream()
.filter(a -> "/api/mcp/command".equals(a.getPath()))
.findFirst().orElseThrow();
assertThat(command.getRiskLevel()).isEqualTo("HIGH");
assertThat(command.getOpenWorldHint()).isEqualTo("true");
assertThat(command.getHidden()).isEqualTo("true");
// Attributes left at their annotation defaults stay blank, so derivation still applies.
assertThat(command.getDestructiveHint()).isEmpty();
assertThat(command.getIdempotentHint()).isEmpty();
assertThat(command.getAiDescription()).isEmpty();
FacadeScannedApiBO plain = apis.stream()
.filter(a -> "/api/mcp/plain".equals(a.getPath()))
.findFirst().orElseThrow();
// No x-dc3-ai extension: every AI metadata field is blank so the catalog derives them.
assertThat(plain.getRiskLevel()).isEmpty();
assertThat(plain.getDestructiveHint()).isEmpty();
assertThat(plain.getOpenWorldHint()).isEmpty();
assertThat(plain.getIdempotentHint()).isEmpty();
assertThat(plain.getAiDescription()).isEmpty();
assertThat(plain.getHidden()).isEmpty();
}
@Test
void unsupportedMethodsAndDefaultExcludesAreFilteredOut() {
register(PatchOnlyController.class);
@@ -196,6 +250,40 @@ class ApiEndpointScannerTest {
}
}
@RestController
@RequestMapping("/api/op")
static class OperationController {
@Operation(summary = "Add Device", description = "Create a new device record")
@PostMapping
public String create() {
return "ok";
}
@GetMapping("/{id}")
public String get(String id) {
return id;
}
}
@RestController
@RequestMapping("/api/mcp")
static class McpAnnotatedController {
@Operation(summary = "Issue Command", extensions = @Extension(name = "x-dc3-ai", properties = {
@ExtensionProperty(name = "riskLevel", value = "HIGH"),
@ExtensionProperty(name = "openWorld", value = "true"),
@ExtensionProperty(name = "hidden", value = "true")
}))
@PostMapping("/command")
public String command() {
return "ok";
}
@PostMapping("/plain")
public String plain() {
return "ok";
}
}
@RestController
@RequestMapping("/api/duplicate")
static class DuplicateController {
@@ -19,6 +19,7 @@ package io.github.pnoker.common.config;
import io.github.pnoker.common.entity.R;
import io.github.pnoker.common.exception.NotFoundException;
import io.github.pnoker.common.exception.PasswordChangeRequiredException;
import io.github.pnoker.common.exception.RequestException;
import io.github.pnoker.common.exception.UnAuthorizedException;
import io.github.pnoker.common.utils.JsonUtil;
@@ -149,6 +150,22 @@ public class ExceptionConfig {
return Mono.just(R.fail(exception.getMessage()));
}
/**
* Handle PasswordChangeRequiredException
*
* @param exception PasswordChangeRequiredException to handle
* @param request ServerHttpRequest that triggered the exception
* @return Mono containing error response carrying a distinct response code
*/
@ExceptionHandler(PasswordChangeRequiredException.class)
@ResponseStatus(HttpStatus.OK)
public Mono<R<String>> passwordChangeRequiredException(PasswordChangeRequiredException exception,
ServerHttpRequest request) {
log.warn("Password change required, path={}, message={}", request.getURI().getRawPath(),
exception.getMessage());
return Mono.just(R.fail(exception.getResponseEnum(), exception.getMessage()));
}
/**
* Handle validation exceptions
*
@@ -158,6 +158,7 @@ public class WebFluxSecurityConfig {
// /auth base-path; the chain matches the post-strip path /token/salt.
.pathMatchers(HttpMethod.POST, "/token/salt").permitAll()
.pathMatchers(HttpMethod.POST, "/token/generate").permitAll()
.pathMatchers(HttpMethod.POST, "/token/change_password").permitAll()
.pathMatchers(HttpMethod.GET, McpConstant.WELL_KNOWN_AUTHORIZATION_SERVER).permitAll()
.pathMatchers(HttpMethod.GET, McpConstant.OAUTH2_JWKS).permitAll()
.pathMatchers(HttpMethod.POST, McpConstant.OAUTH2_TOKEN).permitAll()