feat: enforce signature-scoped drainage authorization before SMS submission
This commit is contained in:
@@ -44,7 +44,7 @@ func main() {
|
||||
protocolLogPublisher.SuccessSampleRate = positiveEnvInt("GATEWAY_PROTOCOL_LOG_SUCCESS_SAMPLE_PERCENT", 10)
|
||||
protocolLogPublisher.MaxLen = int64(positiveEnvInt("GATEWAY_PROTOCOL_LOG_STREAM_MAX_LEN", 200000))
|
||||
}
|
||||
upstreamManager := &upstream.Manager{APIBaseURL: apiBaseURL, EventAPIBaseURL: callbackBaseURL, ProtocolLogPublisher: protocolLogPublisher, GatewayInstanceID: gatewayInstanceID}
|
||||
upstreamManager := &upstream.Manager{DrainageGuardEnabled: true, APIBaseURL: apiBaseURL, EventAPIBaseURL: callbackBaseURL, ProtocolLogPublisher: protocolLogPublisher, GatewayInstanceID: gatewayInstanceID}
|
||||
var worker *submitworker.Worker
|
||||
var resultOutbox *resultoutbox.Outbox
|
||||
channelLimiter, err := ratelimit.New(os.Getenv("REDIS_URL"))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
DrainageGuardEnabled bool
|
||||
APIBaseURL string
|
||||
EventAPIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user