108 lines
3.5 KiB
Go
108 lines
3.5 KiB
Go
package event_ingress
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
|
|
"go-admin/app/bell/integration/machine_identity"
|
|
)
|
|
|
|
const MaxRequestBytes = 64 * 1024
|
|
|
|
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
|
|
|
type Handler struct {
|
|
DB *gorm.DB
|
|
Verifier machine_identity.Verifier
|
|
Enabled bool
|
|
Resolver EvidenceRefresher
|
|
}
|
|
|
|
func (h Handler) Post(c *gin.Context) {
|
|
if !h.Enabled {
|
|
writeProblem(c, http.StatusServiceUnavailable, "connector_disabled", "event connector is disabled", "")
|
|
return
|
|
}
|
|
requestID := c.GetHeader("X-Request-ID")
|
|
if requestID == "" {
|
|
requestID = uuid.NewString()
|
|
} else if !requestIDPattern.MatchString(requestID) {
|
|
writeProblem(c, http.StatusBadRequest, "invalid_request_id", "X-Request-ID must be an opaque 16-128 character value", "")
|
|
return
|
|
}
|
|
c.Header("X-Request-ID", requestID)
|
|
if c.Request.URL.RawQuery != "" || c.Request.URL.Fragment != "" || c.Request.URL.EscapedPath() != "/v1/events" {
|
|
writeProblem(c, http.StatusBadRequest, "invalid_request_target", "event request target must be the normalized /v1/events path", "")
|
|
return
|
|
}
|
|
if relayHeader := c.GetHeader("X-YoVision-Relay-ID"); relayHeader != "" {
|
|
relayID := strings.TrimSpace(relayHeader)
|
|
if relayID != relayHeader || !validID(relayID) {
|
|
writeProblem(c, http.StatusBadRequest, "invalid_event", "relay identity header is invalid", "")
|
|
return
|
|
}
|
|
}
|
|
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, MaxRequestBytes))
|
|
if err != nil {
|
|
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload is invalid or too large", "")
|
|
return
|
|
}
|
|
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
|
|
if err != nil {
|
|
writeProblem(c, http.StatusUnauthorized, machineErrorCode(err), "machine identity was rejected", "")
|
|
return
|
|
}
|
|
if _, err = h.Verifier.Verify(token, "yovision-bell", "events:ingest", c.Request.Method, c.Request.URL.EscapedPath(), body); err != nil {
|
|
status := http.StatusUnauthorized
|
|
code := machineErrorCode(err)
|
|
if code == "machine_scope_denied" || code == "machine_audience_denied" {
|
|
status = http.StatusForbidden
|
|
}
|
|
writeProblem(c, status, code, "machine identity was rejected", "")
|
|
return
|
|
}
|
|
parsed, err := ParseEvent(body)
|
|
if err != nil {
|
|
if errors.Is(err, ErrUnsupportedSchema) {
|
|
writeProblem(c, http.StatusUnprocessableEntity, "unsupported_schema_version", "event schema version is unsupported", "")
|
|
return
|
|
}
|
|
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload failed validation", "")
|
|
return
|
|
}
|
|
result, err := (Service{DB: h.DB, Resolver: h.Resolver}).Ingest(c.Request.Context(), parsed)
|
|
if errors.Is(err, ErrIdempotencyConflict) {
|
|
writeProblem(c, http.StatusConflict, "idempotency_conflict", "idempotency key is already bound to another payload", result.EventID)
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeProblem(c, http.StatusServiceUnavailable, "ingest_unavailable", "event ingest is temporarily unavailable", "")
|
|
return
|
|
}
|
|
status := http.StatusCreated
|
|
if result.Disposition == "duplicate" {
|
|
status = http.StatusOK
|
|
}
|
|
c.JSON(status, result)
|
|
}
|
|
|
|
func writeProblem(c *gin.Context, status int, code, message, existing string) {
|
|
c.Header("Content-Type", "application/problem+json")
|
|
c.JSON(status, Problem{Code: code, Message: message, ExistingEventID: existing})
|
|
}
|
|
|
|
func machineErrorCode(err error) string {
|
|
var machineErr *machine_identity.Error
|
|
if errors.As(err, &machineErr) {
|
|
return machineErr.Code
|
|
}
|
|
return "machine_token_invalid"
|
|
}
|