feat: enforce signature-scoped drainage authorization before SMS submission

This commit is contained in:
hectorzhao
2026-09-10 13:29:04 +08:00
parent 5bcdbb2a03
commit 0c3f820cc9
35 changed files with 2769 additions and 791 deletions
@@ -0,0 +1,70 @@
package upstream
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"cmpp-platform/gateway/internal/queue"
)
type drainageBusinessRejection struct{ reason string }
func (e *drainageBusinessRejection) Error() string { return "引流资格拒绝: " + e.reason }
// Recheck current DB authorization after queue/connection waits. Neither a stale
// command nor a disabled detection flag on a command can grant permission.
func (m *Manager) authorizeDrainage(ctx context.Context, cmd queue.SubmitCommand) error {
if !m.DrainageGuardEnabled {
return nil
}
digest := sha256.Sum256([]byte(cmd.Content))
body, err := json.Marshal(map[string]string{"submitId": cmd.SubmitID, "channelId": cmd.ChannelID, "contentHash": hex.EncodeToString(digest[:])})
if err != nil {
return err
}
base := m.EventAPIBaseURL
if base == "" {
base = m.APIBaseURL
}
if base == "" {
return fmt.Errorf("引流资格校验服务未配置")
}
checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(checkCtx, http.MethodPost, strings.TrimRight(base, "/")+"/gateway/events/authorize-drainage", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := m.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("引流资格校验暂不可用: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("引流资格校验失败: HTTP %d", resp.StatusCode)
}
var result struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 16384)).Decode(&result); err != nil {
return fmt.Errorf("引流资格响应无效: %w", err)
}
if !result.Allowed {
return &drainageBusinessRejection{reason: result.Reason}
}
return nil
}
@@ -0,0 +1,43 @@
package upstream
import (
"cmpp-platform/gateway/internal/queue"
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestDrainageGuardFailClosed(t *testing.T) {
for _, body := range []string{`{"allowed":false,"reason":"未报备"}`, `{}`, `not json`} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) }))
m := &Manager{DrainageGuardEnabled: true, EventAPIBaseURL: server.URL}
if err := m.authorizeDrainage(context.Background(), queue.SubmitCommand{SubmitID: "s", Content: "原文"}); err == nil {
t.Fatalf("unexpected authorization: %s", body)
}
server.Close()
}
}
func TestDrainageGuardRequiresFreshResponse(t *testing.T) {
allowed := true
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gateway/events/authorize-drainage" {
t.Error(r.URL.Path)
}
if allowed {
w.Write([]byte(`{"allowed":true}`))
} else {
w.WriteHeader(503)
}
}))
defer server.Close()
m := &Manager{DrainageGuardEnabled: true, EventAPIBaseURL: server.URL}
cmd := queue.SubmitCommand{SubmitID: "s", Content: "unchanged"}
if err := m.authorizeDrainage(context.Background(), cmd); err != nil {
t.Fatal(err)
}
allowed = false
if err := m.authorizeDrainage(context.Background(), cmd); err == nil {
t.Fatal("reused stale permission")
}
}
+1
View File
@@ -25,6 +25,7 @@ const (
)
type Manager struct {
DrainageGuardEnabled bool
APIBaseURL string
EventAPIBaseURL string
HTTPClient *http.Client
+19 -1
View File
@@ -32,7 +32,7 @@ func (m *Manager) Submit(ctx context.Context, cmd queue.SubmitCommand) (queue.Su
// This cannot make the supplier/Redis boundary globally atomic, but it avoids
// holding the supplier slot for an API round trip and minimizes untracked sends.
return m.SubmitSegmentPublisher.PublishSubmitSegment(ctx, cmd, segment)
})
}, func() error { return m.authorizeDrainage(ctx, cmd) })
return result, err
}
@@ -40,6 +40,7 @@ func (p *connectionPool) submit(
ctx context.Context,
cmd queue.SubmitCommand,
onSegment func(queue.SubmitSegmentResult) error,
authorize ...func() error,
) (final queue.SubmitResult, finalErr error) {
defer func() {
for _, segment := range final.Segments {
@@ -67,6 +68,23 @@ func (p *connectionPool) submit(
result.Segments = segments
return result, err
}
for _, check := range authorize {
if err := check(); err != nil {
release()
code := "DRNCHK"
status := "rejected"
if _, business := err.(*drainageBusinessRejection); business {
code = "DRN"
} else if len(segments) == 0 {
// No bytes were submitted. Let the existing durable worker retry
// a technical outage with its bounded backoff/dead-letter policy.
status = ""
}
result := submitResult(cmd, 0, "", status, code, err.Error())
result.Segments = segments
return result, err
}
}
supplierStartedAt := time.Now()
seq, gatewayMessageID, result, err := conn.submitPart(ctx, cmd, part)
metrics.ObserveSubmitStage("supplier_rtt", err == nil, time.Since(supplierStartedAt))