🐛 fix(jvm): 强化变更确认令牌校验

将 JVM 变更确认从可重算校验值升级为服务端发放的一次性令牌,避免未预览、重放或上下文变更后继续执行高风险变更。
This commit is contained in:
Syngnat
2026-04-28 09:42:21 +08:00
parent 1b31c54917
commit ffc4f2c2d9
7 changed files with 1742 additions and 55 deletions

View File

@@ -2,6 +2,9 @@ package jvm
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
@@ -76,7 +79,17 @@ func BuildChangePreview(
}
}
if !preview.Allowed || provider == nil {
if !preview.Allowed {
return preview, nil
}
if provider == nil {
if preview.RequiresConfirmation {
confirmationToken, tokenErr := buildChangeConfirmationToken(normalized, req, preview)
if tokenErr != nil {
return ChangePreview{}, tokenErr
}
preview.ConfirmationToken = confirmationToken
}
return preview, nil
}
@@ -106,6 +119,16 @@ func BuildChangePreview(
if hasSnapshotOverride(providerPreview.After) {
preview.After = mergeValueSnapshot(preview.After, providerPreview.After)
}
if strings.EqualFold(strings.TrimSpace(preview.RiskLevel), "high") {
preview.RequiresConfirmation = true
}
if preview.Allowed && preview.RequiresConfirmation {
confirmationToken, tokenErr := buildChangeConfirmationToken(normalized, req, preview)
if tokenErr != nil {
return ChangePreview{}, tokenErr
}
preview.ConfirmationToken = confirmationToken
}
return preview, nil
}
@@ -118,6 +141,7 @@ func NormalizeChangeRequest(req ChangeRequest) (ChangeRequest, error) {
normalized.Reason = strings.TrimSpace(normalized.Reason)
normalized.Source = strings.TrimSpace(normalized.Source)
normalized.ExpectedVersion = strings.TrimSpace(normalized.ExpectedVersion)
normalized.ConfirmationToken = strings.TrimSpace(normalized.ConfirmationToken)
if normalized.ResourceID == "" {
return ChangeRequest{}, fmt.Errorf("resource id is required")
@@ -138,7 +162,8 @@ func hasSnapshotOverride(snapshot ValueSnapshot) bool {
strings.TrimSpace(snapshot.Format) != "" ||
strings.TrimSpace(snapshot.Version) != "" ||
snapshot.Value != nil ||
snapshot.Metadata != nil
snapshot.Metadata != nil ||
snapshot.Sensitive
}
func mergeValueSnapshot(base ValueSnapshot, override ValueSnapshot) ValueSnapshot {
@@ -161,5 +186,67 @@ func mergeValueSnapshot(base ValueSnapshot, override ValueSnapshot) ValueSnapsho
if override.Metadata != nil {
merged.Metadata = override.Metadata
}
if override.Sensitive {
merged.Sensitive = true
}
return merged
}
func ValidateChangeConfirmation(preview ChangePreview, req ChangeRequest) error {
if !preview.RequiresConfirmation {
return nil
}
previewToken := strings.TrimSpace(preview.ConfirmationToken)
requestToken := strings.TrimSpace(req.ConfirmationToken)
if previewToken == "" {
return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交")
}
if requestToken == "" {
return fmt.Errorf("缺少确认令牌,请先完成预览确认")
}
if previewToken != requestToken {
return fmt.Errorf("确认令牌不匹配,请重新预览并确认")
}
return nil
}
type confirmationTokenInput struct {
ConnectionID string `json:"connectionId"`
ProviderMode string `json:"providerMode"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source"`
ExpectedVersion string `json:"expectedVersion"`
Payload map[string]any `json:"payload"`
Summary string `json:"summary"`
RiskLevel string `json:"riskLevel"`
BeforeVersion string `json:"beforeVersion"`
AfterVersion string `json:"afterVersion"`
}
func buildChangeConfirmationToken(cfg connection.ConnectionConfig, req ChangeRequest, preview ChangePreview) (string, error) {
input := confirmationTokenInput{
ConnectionID: strings.TrimSpace(cfg.ID),
ProviderMode: strings.TrimSpace(cfg.JVM.PreferredMode),
ResourceID: strings.TrimSpace(req.ResourceID),
Action: strings.TrimSpace(req.Action),
Reason: strings.TrimSpace(req.Reason),
Source: strings.TrimSpace(req.Source),
ExpectedVersion: strings.TrimSpace(req.ExpectedVersion),
Payload: req.Payload,
Summary: strings.TrimSpace(preview.Summary),
RiskLevel: strings.TrimSpace(preview.RiskLevel),
BeforeVersion: strings.TrimSpace(preview.Before.Version),
AfterVersion: strings.TrimSpace(preview.After.Version),
}
encoded, err := json.Marshal(input)
if err != nil {
return "", fmt.Errorf("生成 JVM 变更确认令牌失败: %w", err)
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}

View File

@@ -68,6 +68,9 @@ func TestPreviewChangeBlocksReadOnlyConnection(t *testing.T) {
if preview.BlockingReason == "" || !strings.Contains(preview.BlockingReason, "只读") {
t.Fatalf("expected readonly blocking reason, got %#v", preview)
}
if strings.TrimSpace(preview.ConfirmationToken) != "" {
t.Fatalf("expected blocked preview to not include confirmation token, got %#v", preview)
}
if preview.Before.ResourceID != "/cache/orders" {
t.Fatalf("expected before snapshot resource id to be preserved, got %#v", preview.Before)
}
@@ -169,6 +172,93 @@ func TestPreviewChangeMarksProdWritesAsConfirmationRequired(t *testing.T) {
}
}
func TestPreviewChangeMarksHighRiskWritesAsConfirmationRequired(t *testing.T) {
readOnly := false
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "provider high risk preview",
RiskLevel: "high",
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-writable",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"args": []any{},
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if !preview.RequiresConfirmation {
t.Fatalf("expected high risk preview to require confirmation, got %#v", preview)
}
if strings.TrimSpace(preview.ConfirmationToken) == "" {
t.Fatalf("expected high risk preview to include confirmation token, got %#v", preview)
}
}
func TestPreviewChangeMergesProviderSensitiveFlag(t *testing.T) {
readOnly := false
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{
before: ValueSnapshot{
ResourceID: "/cache/orders/password",
Kind: "attribute",
Format: "string",
Value: "old-secret",
},
preview: ChangePreview{
Allowed: true,
Summary: "provider preview",
RiskLevel: "high",
Before: ValueSnapshot{
Value: "old-secret",
Sensitive: true,
},
After: ValueSnapshot{
Value: "new-secret",
Sensitive: true,
},
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-writable",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/cache/orders/password",
Action: "set",
Reason: "rotate secret",
Payload: map[string]any{
"value": "new-secret",
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if !preview.Before.Sensitive || !preview.After.Sensitive {
t.Fatalf("expected merged preview snapshots to preserve sensitive flag, got %#v", preview)
}
}
func TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults(t *testing.T) {
readOnly := false
@@ -224,3 +314,202 @@ func TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults(t *testing.
t.Fatalf("expected after snapshot defaults to be preserved, got %#v", preview.After)
}
}
func TestBuildChangePreviewAddsConfirmationTokenWhenRequired(t *testing.T) {
readOnly := false
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "invoke resize",
RiskLevel: "high",
RequiresConfirmation: true,
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-prod",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Environment: EnvPROD,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"args": []any{},
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if !preview.RequiresConfirmation {
t.Fatalf("expected confirmation requirement, got %#v", preview)
}
if strings.TrimSpace(preview.ConfirmationToken) == "" {
t.Fatalf("expected confirmation token, got %#v", preview)
}
}
func TestBuildChangePreviewUsesNormalizedProviderModeForConfirmationToken(t *testing.T) {
readOnly := false
cfg := connection.ConnectionConfig{
Type: "jvm",
ID: "conn-prod",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Environment: EnvPROD,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}
previewWithoutRequestedMode, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "invoke resize",
RiskLevel: "high",
RequiresConfirmation: true,
},
}, cfg, ChangeRequest{
ProviderMode: "",
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"args": []any{},
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error for empty provider mode: %v", err)
}
if strings.TrimSpace(previewWithoutRequestedMode.ConfirmationToken) == "" {
t.Fatalf("expected confirmation token for empty requested provider mode, got %#v", previewWithoutRequestedMode)
}
previewWithRequestedMode, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "invoke resize",
RiskLevel: "high",
RequiresConfirmation: true,
},
}, cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"args": []any{},
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error for explicit provider mode: %v", err)
}
if strings.TrimSpace(previewWithRequestedMode.ConfirmationToken) == "" {
t.Fatalf("expected confirmation token for explicit requested provider mode, got %#v", previewWithRequestedMode)
}
if previewWithoutRequestedMode.ConfirmationToken != previewWithRequestedMode.ConfirmationToken {
t.Fatalf("expected tokens to match when normalized mode is the same, got %q vs %q", previewWithoutRequestedMode.ConfirmationToken, previewWithRequestedMode.ConfirmationToken)
}
}
func TestBuildChangePreviewBlockedByProviderDoesNotGenerateConfirmationToken(t *testing.T) {
readOnly := false
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: false,
RequiresConfirmation: true,
BlockingReason: "provider denied write",
Summary: "blocked by provider",
RiskLevel: "high",
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-prod",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Environment: EnvPROD,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"args": []any{},
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if preview.Allowed {
t.Fatalf("expected provider-blocked preview, got %#v", preview)
}
if strings.TrimSpace(preview.ConfirmationToken) != "" {
t.Fatalf("expected blocked preview to not include confirmation token, got %#v", preview)
}
}
func TestBuildChangePreviewFailsClosedWhenTokenMarshalFails(t *testing.T) {
readOnly := false
_, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "invoke resize",
RiskLevel: "high",
RequiresConfirmation: true,
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-prod",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Environment: EnvPROD,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"invalid": func() {},
},
})
if err == nil {
t.Fatal("expected BuildChangePreview to fail when confirmation token marshal fails")
}
if !strings.Contains(err.Error(), "确认令牌") {
t.Fatalf("expected error to mention confirmation token, got %v", err)
}
}
func TestValidateChangeConfirmationRejectsMissingOrMismatchedToken(t *testing.T) {
preview := ChangePreview{
Allowed: true,
RequiresConfirmation: true,
ConfirmationToken: "token-a",
}
if err := ValidateChangeConfirmation(preview, ChangeRequest{}); err == nil {
t.Fatal("expected missing confirmation token to be rejected")
}
if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-b"}); err == nil {
t.Fatal("expected mismatched confirmation token to be rejected")
}
if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-a"}); err != nil {
t.Fatalf("expected matching confirmation token to pass, got %v", err)
}
}

View File

@@ -58,18 +58,20 @@ type ValueSnapshot struct {
}
type ChangeRequest struct {
ProviderMode string `json:"providerMode"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source,omitempty"`
ExpectedVersion string `json:"expectedVersion,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
ProviderMode string `json:"providerMode"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source,omitempty"`
ExpectedVersion string `json:"expectedVersion,omitempty"`
ConfirmationToken string `json:"confirmationToken,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
type ChangePreview struct {
Allowed bool `json:"allowed"`
RequiresConfirmation bool `json:"requiresConfirmation,omitempty"`
ConfirmationToken string `json:"confirmationToken,omitempty"`
Summary string `json:"summary"`
RiskLevel string `json:"riskLevel"`
BlockingReason string `json:"blockingReason,omitempty"`