feat: implement T-019 reliable event ingress
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-11 15:41:07 +08:00
parent b7fe44eeb0
commit bd964e8831
33 changed files with 2762 additions and 57 deletions
+270
View File
@@ -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)
}
+176
View File
@@ -0,0 +1,176 @@
package ingress
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"yovision/bell/contracts"
"yovision/bell/internal/event"
)
type ingressIDs struct{}
func (ingressIDs) NewEventID() (string, error) {
return "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", nil
}
type ingressPrivacy struct{}
func (ingressPrivacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
return true, nil
}
type fakeRepository struct {
replayResult Result
replayFound bool
replayErr error
processErr error
processed int
}
func (f *fakeRepository) Replay(context.Context, string, string, [sha256.Size]byte, string, string, [sha256.Size]byte) (Result, bool, error) {
return f.replayResult, f.replayFound, f.replayErr
}
func (f *fakeRepository) ProcessEvent(_ context.Context, _, _ string, _ [sha256.Size]byte, producer string, _ [sha256.Size]byte, value event.Event) (Result, error) {
f.processed++
if f.processErr != nil {
return Result{}, f.processErr
}
return Result{SchemaVersion: 1, ProducerID: producer, SourceEventID: value.SourceEventID(), EventID: value.ID(), Status: "accepted", HTTPStatus: 201}, nil
}
func ingressCandidate(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json"))
if err != nil {
t.Fatal(err)
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
delete(object, "id")
object["kind"] = "zone_entry"
object["severity"] = "medium"
object["evidence"] = map[string]any{"snapshot_uris": []any{}, "clip_uri": nil, "clip_range": nil}
encoded, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
return encoded
}
func newIngressHandler(t *testing.T, repository Repository) (*Handler, []byte) {
t.Helper()
guard, err := event.NewEvidenceGuard("private-customer")
if err != nil {
t.Fatal(err)
}
factory, err := event.NewFactory(contracts.EventV01Schema, ingressIDs{}, ingressPrivacy{}, guard)
if err != nil {
t.Fatal(err)
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
t.Fatal(err)
}
handler, err := NewHandler(repository, map[string]Key{"brain-a": {ProducerID: "brain-main", Secret: secret}}, factory)
if err != nil {
t.Fatal(err)
}
handler.now = func() time.Time { return time.Unix(1_800_000_000, 0).UTC() }
return handler, secret
}
func signedRequest(t *testing.T, secret []byte, producer string, candidate []byte) *http.Request {
t.Helper()
body, err := json.Marshal(map[string]any{"schema_version": 1, "producer_id": producer, "candidate": json.RawMessage(candidate)})
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, Path, bytes.NewReader(body))
timestamp := strconv.FormatInt(1_800_000_000, 10)
nonce := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef"))
request.Header.Set(HeaderKeyID, "brain-a")
request.Header.Set(HeaderTimestamp, timestamp)
request.Header.Set(HeaderNonce, nonce)
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, Path, timestamp, nonce, body))))
return request
}
func TestHandlerAcceptsSignedCandidateAndRejectsProducerSpoofing(t *testing.T) {
repository := &fakeRepository{}
handler, secret := newIngressHandler(t, repository)
response := httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusCreated || repository.processed != 1 {
t.Fatalf("signed candidate: status=%d body=%s processed=%d", response.Code, response.Body.String(), repository.processed)
}
var result Result
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || result.Status != "accepted" || result.EventID == "" {
t.Fatalf("invalid accepted response: %+v %v", result, err)
}
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-spoofed", ingressCandidate(t)))
if response.Code != http.StatusUnauthorized || repository.processed != 1 {
t.Fatalf("producer spoofing was not rejected: %d %s", response.Code, response.Body.String())
}
}
func TestHandlerReturnsDurableReplayAndStableConflicts(t *testing.T) {
repository := &fakeRepository{replayFound: true, replayResult: Result{
SchemaVersion: 1, ProducerID: "brain-main", SourceEventID: "source-1",
EventID: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", Status: "duplicate", HTTPStatus: 200,
}}
handler, secret := newIngressHandler(t, repository)
response := httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusOK || repository.processed != 0 {
t.Fatalf("durable replay did not bypass factory persistence: %d", response.Code)
}
repository.replayFound = false
repository.replayErr = ErrSourceConflict
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusConflict {
t.Fatalf("source conflict status=%d body=%s", response.Code, response.Body.String())
}
}
func TestHandlerRejectsInvalidSignatureAndUpstreamPlatformID(t *testing.T) {
repository := &fakeRepository{}
handler, secret := newIngressHandler(t, repository)
request := signedRequest(t, secret, "brain-main", ingressCandidate(t))
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(make([]byte, 32)))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("invalid signature status=%d", response.Code)
}
var object map[string]any
if err := json.Unmarshal(ingressCandidate(t), &object); err != nil {
t.Fatal(err)
}
object["id"] = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
withID, _ := json.Marshal(object)
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", withID))
if response.Code != http.StatusUnprocessableEntity || repository.processed != 0 {
t.Fatalf("upstream ID status=%d body=%s", response.Code, response.Body.String())
}
}
+53
View File
@@ -0,0 +1,53 @@
package ingress
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
)
type keyDocument struct {
Version int `json:"version"`
Keys []struct {
KeyID string `json:"key_id"`
ProducerID string `json:"producer_id"`
Secret string `json:"secret_base64url"`
} `json:"keys"`
}
type Key struct {
ProducerID string
Secret []byte
}
func LoadKeys(path string) (map[string]Key, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read Bell event ingress key file")
}
var document keyDocument
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
return nil, errors.New("invalid Bell event ingress key file")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid Bell event ingress key file")
}
values := make(map[string]Key, len(document.Keys))
for _, item := range document.Keys {
secret, err := base64.RawURLEncoding.DecodeString(item.Secret)
if err != nil || !keyIDPattern.MatchString(item.KeyID) || !producerIDPattern.MatchString(item.ProducerID) || len(secret) < 32 {
return nil, errors.New("invalid Bell event ingress key")
}
if _, exists := values[item.KeyID]; exists {
return nil, errors.New("duplicate Bell event ingress key ID")
}
values[item.KeyID] = Key{ProducerID: item.ProducerID, Secret: append([]byte(nil), secret...)}
}
return values, nil
}