fix: harden terminal-scoped analytics contracts
Keep JSON and bounded CSV exports on one canonical predicate, replace replayable offsets with encrypted tenant/filter-bound keyset cursors, and suppress merchant-grain peers under terminal scope. Validate period filters and align OpenAPI and capability specs with runtime behavior.
This commit is contained in:
108
internal/analytics/cursor.go
Normal file
108
internal/analytics/cursor.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidCursor = errors.New("analytics: invalid cursor")
|
||||
|
||||
type detailCursorPosition struct {
|
||||
CreatedAt time.Time
|
||||
SessionID int64
|
||||
}
|
||||
|
||||
type detailCursorPayload struct {
|
||||
Version int `json:"v"`
|
||||
Binding string `json:"b"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
SessionID int64 `json:"sessionId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type detailCursorBinding struct {
|
||||
MerchantID int64 `json:"merchantId"`
|
||||
PeriodID string `json:"periodId"`
|
||||
View string `json:"view"`
|
||||
FailureStage string `json:"failureStage"`
|
||||
TerminalID string `json:"terminalId"`
|
||||
Status string `json:"status"`
|
||||
Minimum *int64 `json:"minimum"`
|
||||
Maximum *int64 `json:"maximum"`
|
||||
}
|
||||
|
||||
func (s *Service) encodeDetailCursor(merchantID int64, period Period, filter DetailFilter, createdAt time.Time, sessionID int64) (string, error) {
|
||||
payload := detailCursorPayload{
|
||||
Version: 1,
|
||||
Binding: detailCursorBindingDigest(merchantID, period, filter),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
SessionID: sessionID,
|
||||
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
aead, err := s.cursorAEAD()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := aead.Seal(nonce, nonce, encoded, nil)
|
||||
return base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func (s *Service) decodeDetailCursor(merchantID int64, period Period, filter DetailFilter, cursor string) (detailCursorPosition, error) {
|
||||
sealed, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
aead, err := s.cursorAEAD()
|
||||
if err != nil || len(sealed) < aead.NonceSize() {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
nonce, ciphertext := sealed[:aead.NonceSize()], sealed[aead.NonceSize():]
|
||||
payloadBytes, err := aead.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
var payload detailCursorPayload
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil || payload.Version != 1 || payload.SessionID <= 0 || payload.CreatedAt.IsZero() || time.Now().UTC().After(payload.ExpiresAt) {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
wantBinding := detailCursorBindingDigest(merchantID, period, filter)
|
||||
if payload.Binding != wantBinding {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
return detailCursorPosition{CreatedAt: payload.CreatedAt.UTC(), SessionID: payload.SessionID}, nil
|
||||
}
|
||||
|
||||
func (s *Service) cursorAEAD() (cipher.AEAD, error) {
|
||||
key := sha256.Sum256(s.cursorSecret)
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
func detailCursorBindingDigest(merchantID int64, period Period, filter DetailFilter) string {
|
||||
binding := detailCursorBinding{
|
||||
MerchantID: merchantID, PeriodID: period.PeriodID, View: filter.View,
|
||||
FailureStage: filter.FailureStage, TerminalID: filter.TerminalID, Status: filter.Status,
|
||||
Minimum: filter.MinAmountRial, Maximum: filter.MaxAmountRial,
|
||||
}
|
||||
encoded, _ := json.Marshal(binding)
|
||||
digest := sha256.Sum256(encoded)
|
||||
return base64.RawURLEncoding.EncodeToString(digest[:])
|
||||
}
|
||||
Reference in New Issue
Block a user