feat: implement T-019 reliable event ingress
Harness governance / validate (pull_request) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// Package ingress authenticates Brain event candidates and delegates their
|
||||
// atomic persistence to Bell's repository.
|
||||
package ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yovision/bell/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
Path = "/internal/v1/event-candidates"
|
||||
MaxBodyBytes = 1 << 20
|
||||
HeaderKeyID = "X-YoVision-Key-Id"
|
||||
HeaderTimestamp = "X-YoVision-Timestamp"
|
||||
HeaderNonce = "X-YoVision-Nonce"
|
||||
HeaderSignature = "X-YoVision-Signature"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReplayConflict = errors.New("event ingress replay conflict")
|
||||
ErrSourceConflict = errors.New("event ingress source event conflict")
|
||||
ErrIdentityDenied = errors.New("event ingress identity denied")
|
||||
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
producerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
EventID string `json:"event_id"`
|
||||
Status string `json:"status"`
|
||||
HTTPStatus int `json:"-"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Replay(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
[sha256.Size]byte,
|
||||
string,
|
||||
string,
|
||||
[sha256.Size]byte,
|
||||
) (Result, bool, error)
|
||||
ProcessEvent(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
[sha256.Size]byte,
|
||||
string,
|
||||
[sha256.Size]byte,
|
||||
event.Event,
|
||||
) (Result, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
repository Repository
|
||||
keys map[string]Key
|
||||
factory *event.Factory
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewHandler(repository Repository, keys map[string]Key, factory *event.Factory) (*Handler, error) {
|
||||
if repository == nil || factory == nil || len(keys) == 0 {
|
||||
return nil, errors.New("event ingress handler dependencies are required")
|
||||
}
|
||||
copyKeys := make(map[string]Key, len(keys))
|
||||
for id, key := range keys {
|
||||
if !keyIDPattern.MatchString(id) || !producerIDPattern.MatchString(key.ProducerID) || len(key.Secret) < 32 {
|
||||
return nil, errors.New("invalid event ingress handler key")
|
||||
}
|
||||
copyKeys[id] = Key{ProducerID: key.ProducerID, Secret: append([]byte(nil), key.Secret...)}
|
||||
}
|
||||
return &Handler{repository: repository, keys: copyKeys, factory: factory, now: time.Now}, nil
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
Candidate json.RawMessage `json:"candidate"`
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != Path {
|
||||
writeError(writer, http.StatusNotFound, "not_found", "")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(request.Body, MaxBodyBytes+1))
|
||||
if err != nil || len(body) > MaxBodyBytes {
|
||||
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large", "")
|
||||
return
|
||||
}
|
||||
keyID := request.Header.Get(HeaderKeyID)
|
||||
timestamp := request.Header.Get(HeaderTimestamp)
|
||||
nonce := request.Header.Get(HeaderNonce)
|
||||
provided := request.Header.Get(HeaderSignature)
|
||||
key, ok := h.keys[keyID]
|
||||
seconds, timestampErr := strconv.ParseInt(timestamp, 10, 64)
|
||||
nonceBytes, nonceErr := base64.RawURLEncoding.DecodeString(nonce)
|
||||
signatureBytes, signatureErr := base64.RawURLEncoding.DecodeString(provided)
|
||||
if !ok || timestampErr != nil || len(timestamp) < 10 || nonceErr != nil || len(nonceBytes) < 16 || len(nonceBytes) > 48 ||
|
||||
signatureErr != nil || len(signatureBytes) != sha256.Size || absDuration(h.now().UTC().Sub(time.Unix(seconds, 0).UTC())) > 300*time.Second {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
expected := signature(key.Secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
|
||||
if !hmac.Equal(signatureBytes, expected) {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
|
||||
var value envelope
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil || value.SchemaVersion != 1 || !producerIDPattern.MatchString(value.ProducerID) || len(value.Candidate) == 0 {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
|
||||
return
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
|
||||
return
|
||||
}
|
||||
if value.ProducerID != key.ProducerID {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
canonicalCandidate, err := canonicalJSON(value.Candidate)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
|
||||
return
|
||||
}
|
||||
sourceEventID, err := candidateSourceEventID(canonicalCandidate)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", "schema_invalid")
|
||||
return
|
||||
}
|
||||
requestHash := sha256.Sum256(body)
|
||||
candidateHash := sha256.Sum256(canonicalCandidate)
|
||||
replay, found, err := h.repository.Replay(
|
||||
request.Context(), keyID, nonce, requestHash, value.ProducerID, sourceEventID, candidateHash,
|
||||
)
|
||||
switch {
|
||||
case errors.Is(err, ErrReplayConflict):
|
||||
writeError(writer, http.StatusConflict, "replay_conflict", "")
|
||||
return
|
||||
case errors.Is(err, ErrSourceConflict):
|
||||
writeError(writer, http.StatusConflict, "source_event_conflict", "")
|
||||
return
|
||||
case err != nil:
|
||||
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
|
||||
return
|
||||
case found:
|
||||
writeJSON(writer, replay.HTTPStatus, replay)
|
||||
return
|
||||
}
|
||||
created, err := h.factory.Create(request.Context(), canonicalCandidate)
|
||||
if err != nil {
|
||||
handleFactoryError(writer, err)
|
||||
return
|
||||
}
|
||||
result, err := h.repository.ProcessEvent(request.Context(), keyID, nonce, requestHash, value.ProducerID, candidateHash, created)
|
||||
switch {
|
||||
case errors.Is(err, ErrReplayConflict):
|
||||
writeError(writer, http.StatusConflict, "replay_conflict", "")
|
||||
case errors.Is(err, ErrSourceConflict):
|
||||
writeError(writer, http.StatusConflict, "source_event_conflict", "")
|
||||
case errors.Is(err, ErrIdentityDenied):
|
||||
writeError(writer, http.StatusForbidden, "identity_denied", "")
|
||||
case err != nil:
|
||||
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
|
||||
default:
|
||||
writeJSON(writer, result.HTTPStatus, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleFactoryError(writer http.ResponseWriter, err error) {
|
||||
var validation *event.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
|
||||
return
|
||||
}
|
||||
switch validation.Code {
|
||||
case event.CodePayloadTooLarge:
|
||||
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large", "")
|
||||
case event.CodePrivacyDenied:
|
||||
writeError(writer, http.StatusForbidden, "privacy_denied", "")
|
||||
case event.CodePrivacyUnavailable:
|
||||
writeError(writer, http.StatusServiceUnavailable, "privacy_unavailable", "")
|
||||
default:
|
||||
writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", string(validation.Code))
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil || value == nil {
|
||||
return nil, errors.New("invalid JSON")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("multiple JSON values")
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
var sourceEventIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
||||
|
||||
func candidateSourceEventID(raw []byte) (string, error) {
|
||||
var object map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
return "", errors.New("candidate is not an object")
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(object["source_event_id"], &value); err != nil || !sourceEventIDPattern.MatchString(value) {
|
||||
return "", errors.New("candidate source event ID is invalid")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func canonicalString(method, path, timestamp, nonce string, body []byte) string {
|
||||
digest := sha256.Sum256(body)
|
||||
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
|
||||
}
|
||||
|
||||
func signature(secret []byte, canonical string) []byte {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func absDuration(value time.Duration) time.Duration {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func writeError(writer http.ResponseWriter, status int, code, detail string) {
|
||||
value := map[string]string{"error": code}
|
||||
if detail != "" {
|
||||
value["detail_code"] = detail
|
||||
}
|
||||
writeJSON(writer, status, value)
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(value)
|
||||
}
|
||||
Reference in New Issue
Block a user