mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
🐛 fix(jvm): 强化变更确认令牌校验
将 JVM 变更确认从可重算校验值升级为服务端发放的一次性令牌,避免未预览、重放或上下文变更后继续执行高风险变更。
This commit is contained in:
@@ -948,6 +948,7 @@ export namespace jvm {
|
||||
reason: string;
|
||||
source?: string;
|
||||
expectedVersion?: string;
|
||||
confirmationToken?: string;
|
||||
payload?: Record<string, any>;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -962,6 +963,7 @@ export namespace jvm {
|
||||
this.reason = source["reason"];
|
||||
this.source = source["source"];
|
||||
this.expectedVersion = source["expectedVersion"];
|
||||
this.confirmationToken = source["confirmationToken"];
|
||||
this.payload = source["payload"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,17 +55,20 @@ type queryContext struct {
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
startedAt time.Time
|
||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||
connectFailures map[string]cachedConnectFailure
|
||||
mu sync.RWMutex // Mutex for cache access
|
||||
updateMu sync.Mutex
|
||||
updateState updateState
|
||||
queryMu sync.RWMutex
|
||||
configDir string
|
||||
secretStore secretstore.SecretStore
|
||||
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
|
||||
ctx context.Context
|
||||
startedAt time.Time
|
||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||
connectFailures map[string]cachedConnectFailure
|
||||
mu sync.RWMutex // Mutex for cache access
|
||||
updateMu sync.Mutex
|
||||
updateState updateState
|
||||
queryMu sync.RWMutex
|
||||
configDir string
|
||||
secretStore secretstore.SecretStore
|
||||
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
|
||||
jvmPreviewTokenMu sync.Mutex
|
||||
jvmPreviewTokens map[string]jvmPreviewConfirmationToken
|
||||
jvmPreviewTokenTTL time.Duration
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
@@ -78,11 +81,13 @@ func NewAppWithSecretStore(store secretstore.SecretStore) *App {
|
||||
store = secretstore.NewUnavailableStore("secret store unavailable")
|
||||
}
|
||||
return &App{
|
||||
dbCache: make(map[string]cachedDatabase),
|
||||
connectFailures: make(map[string]cachedConnectFailure),
|
||||
runningQueries: make(map[string]queryContext),
|
||||
configDir: resolveAppConfigDir(),
|
||||
secretStore: store,
|
||||
dbCache: make(map[string]cachedDatabase),
|
||||
connectFailures: make(map[string]cachedConnectFailure),
|
||||
runningQueries: make(map[string]queryContext),
|
||||
configDir: resolveAppConfigDir(),
|
||||
secretStore: store,
|
||||
jvmPreviewTokens: make(map[string]jvmPreviewConfirmationToken),
|
||||
jvmPreviewTokenTTL: defaultJVMPreviewConfirmationTokenTTL,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/jvm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var newJVMProvider = jvm.NewProvider
|
||||
|
||||
const defaultJVMPreviewConfirmationTokenTTL = 10 * time.Minute
|
||||
|
||||
type jvmPreviewConfirmationToken struct {
|
||||
contextHash string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type jvmPreviewConfirmationContext struct {
|
||||
ConfigHash string `json:"configHash"`
|
||||
ProviderMode string `json:"providerMode"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
Action string `json:"action"`
|
||||
Reason string `json:"reason"`
|
||||
Source string `json:"source"`
|
||||
ExpectedVersion string `json:"expectedVersion"`
|
||||
PayloadHash string `json:"payloadHash"`
|
||||
PreviewChecksum string `json:"previewChecksum"`
|
||||
RiskLevel string `json:"riskLevel"`
|
||||
BeforeVersion string `json:"beforeVersion"`
|
||||
AfterVersion string `json:"afterVersion"`
|
||||
}
|
||||
|
||||
func buildJVMCapabilityError(mode string, cfg connection.ConnectionConfig, err error) jvm.Capability {
|
||||
probeCfg := cfg
|
||||
probeCfg.JVM.PreferredMode = mode
|
||||
@@ -40,6 +69,119 @@ func resolveJVMProviderForMode(cfg connection.ConnectionConfig, mode string) (co
|
||||
return normalized, provider, nil
|
||||
}
|
||||
|
||||
func (a *App) issueJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) {
|
||||
contextHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := uuid.NewString()
|
||||
now := time.Now()
|
||||
ttl := a.jvmPreviewTokenTTL
|
||||
if ttl <= 0 {
|
||||
ttl = defaultJVMPreviewConfirmationTokenTTL
|
||||
}
|
||||
|
||||
a.jvmPreviewTokenMu.Lock()
|
||||
defer a.jvmPreviewTokenMu.Unlock()
|
||||
if a.jvmPreviewTokens == nil {
|
||||
a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken)
|
||||
}
|
||||
a.pruneExpiredJVMPreviewConfirmationTokensLocked(now)
|
||||
a.jvmPreviewTokens[token] = jvmPreviewConfirmationToken{
|
||||
contextHash: contextHash,
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (a *App) consumeJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) error {
|
||||
if !preview.RequiresConfirmation {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(preview.ConfirmationToken) == "" {
|
||||
return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交")
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(req.ConfirmationToken)
|
||||
if token == "" {
|
||||
return fmt.Errorf("缺少确认令牌,请先完成预览确认")
|
||||
}
|
||||
|
||||
expectedHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
a.jvmPreviewTokenMu.Lock()
|
||||
if a.jvmPreviewTokens == nil {
|
||||
a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken)
|
||||
}
|
||||
a.pruneExpiredJVMPreviewConfirmationTokensLocked(now)
|
||||
entry, ok := a.jvmPreviewTokens[token]
|
||||
if ok {
|
||||
delete(a.jvmPreviewTokens, token)
|
||||
}
|
||||
a.jvmPreviewTokenMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("确认令牌已失效,请重新预览并确认")
|
||||
}
|
||||
if !entry.expiresAt.After(now) {
|
||||
return fmt.Errorf("确认令牌已过期,请重新预览并确认")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(entry.contextHash), []byte(expectedHash)) != 1 {
|
||||
return fmt.Errorf("确认令牌不匹配,请重新预览并确认")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) pruneExpiredJVMPreviewConfirmationTokensLocked(now time.Time) {
|
||||
for token, entry := range a.jvmPreviewTokens {
|
||||
if !entry.expiresAt.After(now) {
|
||||
delete(a.jvmPreviewTokens, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildJVMPreviewConfirmationContextHash(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) {
|
||||
configHash, err := hashJSONValue(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成 JVM 预览上下文失败: %w", err)
|
||||
}
|
||||
payloadHash, err := hashJSONValue(req.Payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成 JVM 预览载荷摘要失败: %w", err)
|
||||
}
|
||||
|
||||
input := jvmPreviewConfirmationContext{
|
||||
ConfigHash: configHash,
|
||||
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),
|
||||
PayloadHash: payloadHash,
|
||||
PreviewChecksum: strings.TrimSpace(preview.ConfirmationToken),
|
||||
RiskLevel: strings.TrimSpace(preview.RiskLevel),
|
||||
BeforeVersion: strings.TrimSpace(preview.Before.Version),
|
||||
AfterVersion: strings.TrimSpace(preview.After.Version),
|
||||
}
|
||||
return hashJSONValue(input)
|
||||
}
|
||||
|
||||
func hashJSONValue(value any) (string, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult {
|
||||
normalized, provider, err := resolveJVMProvider(cfg)
|
||||
if err != nil {
|
||||
@@ -97,6 +239,13 @@ func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRe
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if preview.Allowed && preview.RequiresConfirmation {
|
||||
token, err := a.issueJVMPreviewConfirmationToken(normalized, req, preview)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
preview.ConfirmationToken = token
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Data: preview}
|
||||
}
|
||||
@@ -124,26 +273,66 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
|
||||
}
|
||||
return connection.QueryResult{Success: false, Message: message}
|
||||
}
|
||||
|
||||
result, err := provider.ApplyChange(a.ctx, normalized, req)
|
||||
if err != nil {
|
||||
if err := a.consumeJVMPreviewConfirmationToken(normalized, req, preview); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
if err := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")).Append(jvm.AuditRecord{
|
||||
ConnectionID: normalized.ID,
|
||||
ProviderMode: normalized.JVM.PreferredMode,
|
||||
ResourceID: req.ResourceID,
|
||||
Action: req.Action,
|
||||
Reason: req.Reason,
|
||||
Source: req.Source,
|
||||
Result: result.Status,
|
||||
}); err != nil {
|
||||
if strings.TrimSpace(result.Message) == "" {
|
||||
result.Message = "变更已执行,但审计记录写入失败: " + err.Error()
|
||||
} else {
|
||||
result.Message += ";审计记录写入失败: " + err.Error()
|
||||
auditStore := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl"))
|
||||
appendAuditRecord := func(record jvm.AuditRecord) error {
|
||||
return auditStore.Append(record)
|
||||
}
|
||||
appendAudit := func(result string, timestamp int64) error {
|
||||
return appendAuditRecord(jvm.AuditRecord{
|
||||
Timestamp: timestamp,
|
||||
ConnectionID: normalized.ID,
|
||||
ProviderMode: normalized.JVM.PreferredMode,
|
||||
ResourceID: req.ResourceID,
|
||||
Action: req.Action,
|
||||
Reason: req.Reason,
|
||||
Source: req.Source,
|
||||
Result: result,
|
||||
})
|
||||
}
|
||||
appendWarning := func(message string, warning string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
warning = strings.TrimSpace(warning)
|
||||
if warning == "" {
|
||||
return message
|
||||
}
|
||||
if message == "" {
|
||||
return warning
|
||||
}
|
||||
return message + ";" + warning
|
||||
}
|
||||
|
||||
pendingTimestamp := time.Now().UnixMilli()
|
||||
terminalAuditTimestamp := func() int64 {
|
||||
ts := time.Now().UnixMilli()
|
||||
if ts <= pendingTimestamp {
|
||||
return pendingTimestamp + 1
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
if err := appendAudit("pending", pendingTimestamp); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: "审计记录写入失败,已阻止 JVM 变更: " + err.Error()}
|
||||
}
|
||||
|
||||
result, err := provider.ApplyChange(a.ctx, normalized, req)
|
||||
if err != nil {
|
||||
if auditErr := appendAudit("failed", terminalAuditTimestamp()); auditErr != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error() + ";失败审计写入失败: " + auditErr.Error()}
|
||||
}
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
terminalResult := strings.TrimSpace(result.Status)
|
||||
if terminalResult == "" {
|
||||
terminalResult = "applied"
|
||||
}
|
||||
if err := appendAudit(terminalResult, terminalAuditTimestamp()); err != nil {
|
||||
result.Message = appendWarning(result.Message, "终态审计写入失败: "+err.Error())
|
||||
return connection.QueryResult{Success: true, Message: result.Message, Data: result}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Data: result}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user