Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c5deecfb | ||
|
|
cf00d73436 | ||
|
|
ae9bd015c2 | ||
|
|
06e0790f00 | ||
|
|
009dc3cca0 | ||
|
|
573113eb3b | ||
|
|
b548b05874 | ||
|
|
23a85278cb | ||
|
|
96777a948f | ||
|
|
c2b023c9fe | ||
|
|
4c35da9ef6 | ||
|
|
30c43aa8d7 | ||
|
|
359c553452 | ||
|
|
2e61167500 | ||
|
|
67391acb16 | ||
|
|
2a395aa126 |
@@ -0,0 +1,117 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
type EvidenceClient struct {
|
||||
Endpoint string
|
||||
Signer machine_identity.Signer
|
||||
HTTP interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
}
|
||||
|
||||
func NewEvidenceClient(endpoint string, signer machine_identity.Signer) (*EvidenceClient, error) {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("Sense evidence endpoint must be an HTTPS origin without userinfo")
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second}
|
||||
return &EvidenceClient{Endpoint: strings.TrimRight(endpoint, "/"), Signer: signer, HTTP: &http.Client{Transport: transport, Timeout: 8 * time.Second}}, nil
|
||||
}
|
||||
|
||||
func (c EvidenceClient) Refresh(ctx context.Context, db *gorm.DB, status EvidenceStatus) error {
|
||||
path := "/v1/evidence/" + status.EvidenceID
|
||||
token, err := c.Signer.Mint("yovision-sense", []string{"evidence:read"}, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "machine_identity_error")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Endpoint+path, nil)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_request")
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Request-ID", newCorrelationID())
|
||||
response, err := c.HTTP.Do(request)
|
||||
if err != nil {
|
||||
code := "evidence_unavailable"
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
code = "evidence_timeout"
|
||||
}
|
||||
return c.degrade(db, status, "unavailable", code)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
|
||||
if readErr != nil || len(body) > 64*1024 {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return c.degrade(db, status, "unavailable", "evidence_not_found")
|
||||
}
|
||||
if response.StatusCode == http.StatusGone {
|
||||
return c.degrade(db, status, "expired", "evidence_expired")
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return c.degrade(db, status, "unavailable", "evidence_unavailable")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
var evidence Evidence
|
||||
if err = decoder.Decode(&evidence); err != nil || evidence.EvidenceID != status.EvidenceID || evidence.OwnerID != status.OwnerID || validateEvidence(evidence) != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
canonical, err := canonicalJSON(body)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var expiresAt *time.Time
|
||||
if evidence.ExpiresAt != "" {
|
||||
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
parsedExpiry = parsedExpiry.UTC()
|
||||
expiresAt = &parsedExpiry
|
||||
}
|
||||
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"status": evidence.Status, "resolution": "current", "current_payload": canonical, "last_error": "", "expires_at": expiresAt, "checked_at": now, "updated_at": now}).Error
|
||||
}
|
||||
|
||||
func (c EvidenceClient) degrade(db *gorm.DB, status EvidenceStatus, resolution, code string) error {
|
||||
now := time.Now().UTC()
|
||||
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"resolution": resolution, "last_error": code, "checked_at": now, "updated_at": now}).Error
|
||||
}
|
||||
|
||||
func newCorrelationID() string {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "request-id-fallback"
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func LoadEvidenceClient(getenv func(string) string) (*EvidenceClient, error) {
|
||||
key, err := machine_identity.LoadPrivateKey(getenv("BELL_SENSE_PRIVATE_KEY_PATH"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Bell evidence key: %w", err)
|
||||
}
|
||||
signer := machine_identity.Signer{Principal: strings.TrimSpace(getenv("BELL_SENSE_PRINCIPAL_ID")), KeyID: strings.TrimSpace(getenv("BELL_SENSE_KEY_ID")), PrivateKey: key}
|
||||
return NewEvidenceClient(strings.TrimSpace(getenv("BELL_SENSE_EVIDENCE_ENDPOINT")), signer)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EventSchemaVersion = "yovision.event/v1"
|
||||
EvidenceSchemaVersion = "yovision.evidence-reference/v1"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
SiteRef string `json:"site_ref"`
|
||||
DeviceRef string `json:"device_ref"`
|
||||
ProfileRef string `json:"profile_ref"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Severity string `json:"severity"`
|
||||
Rule Rule `json:"rule"`
|
||||
Model Model `json:"model"`
|
||||
Observation Observation `json:"observation"`
|
||||
Region Region `json:"region"`
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Observation struct {
|
||||
TrackID string `json:"track_id"`
|
||||
Category string `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BBoxNormalized []float64 `json:"bbox_normalized,omitempty"`
|
||||
}
|
||||
|
||||
type Region struct {
|
||||
RegionID string `json:"region_id"`
|
||||
Kind string `json:"kind"`
|
||||
CrossingDirection string `json:"crossing_direction,omitempty"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
EvidenceID string `json:"evidence_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
CapturedAt string `json:"captured_at"`
|
||||
StatusUpdatedAt string `json:"status_updated_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Integrity *EvidenceIntegrity `json:"integrity,omitempty"`
|
||||
Failure *EvidenceFailure `json:"failure,omitempty"`
|
||||
}
|
||||
|
||||
type EvidenceIntegrity struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Digest string `json:"digest"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type EvidenceFailure struct {
|
||||
Code string `json:"code"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
type IngestResult struct {
|
||||
EventID string `json:"event_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
Disposition string `json:"disposition"`
|
||||
PayloadSHA256 string `json:"payload_sha256"`
|
||||
}
|
||||
|
||||
type Problem struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Field string `json:"field,omitempty"`
|
||||
ExistingEventID string `json:"existing_event_id,omitempty"`
|
||||
}
|
||||
|
||||
type ParsedEvent struct {
|
||||
Event Event
|
||||
Canonical json.RawMessage
|
||||
Digest string
|
||||
Occurred time.Time
|
||||
}
|
||||
|
||||
// EvidenceStatus is mutable Bell-owned resolution metadata kept separately
|
||||
// from the immutable Event and from Alert acknowledgement/close facts.
|
||||
type EvidenceStatus struct {
|
||||
EventID string `gorm:"type:uuid;primaryKey"`
|
||||
EvidenceID string `gorm:"size:128;primaryKey"`
|
||||
OwnerID string `gorm:"size:128;not null;index"`
|
||||
Status string `gorm:"size:16;not null"`
|
||||
Resolution string `gorm:"size:16;not null;index"`
|
||||
CurrentPayload json.RawMessage `gorm:"column:current_payload;type:jsonb;not null"`
|
||||
LastError string `gorm:"size:64;not null;default:''"`
|
||||
ExpiresAt *time.Time `gorm:"index"`
|
||||
CheckedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (EvidenceStatus) TableName() string { return "bell_evidence_status" }
|
||||
@@ -0,0 +1,42 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ReplayToken is Bell-owned security state. It is intentionally separate from
|
||||
// business Receipt idempotency and remains effective across process restarts.
|
||||
type ReplayToken struct {
|
||||
Principal string `gorm:"size:128;primaryKey"`
|
||||
TokenID string `gorm:"size:64;primaryKey"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ReplayToken) TableName() string { return "bell_machine_token_replays" }
|
||||
|
||||
type PersistentReplayStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s PersistentReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
if s.DB == nil {
|
||||
return false
|
||||
}
|
||||
accepted := false
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("expires_at <= ?", now.UTC()).Delete(&ReplayToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{
|
||||
Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt.UTC(), CreatedAt: now.UTC(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
accepted = result.RowsAffected == 1
|
||||
return nil
|
||||
})
|
||||
return err == nil && accepted
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
func RegisterRuntime(engine *gin.Engine) error {
|
||||
enabled := strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVENT_INGRESS_ENABLED")), "true")
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
db := sdk.Runtime.GetDbByKey("")
|
||||
if db == nil {
|
||||
return fmt.Errorf("Bell event ingress database is unavailable")
|
||||
}
|
||||
if !db.Migrator().HasTable(&ReplayToken{}) || !db.Migrator().HasTable(&EvidenceStatus{}) {
|
||||
return fmt.Errorf("Bell event ingress migration is required")
|
||||
}
|
||||
registry, err := machine_identity.LoadRegistry(os.Getenv("BELL_MACHINE_PRINCIPAL_REGISTRY"), "yovision-bell")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Bell machine identity registry: %w", err)
|
||||
}
|
||||
var resolver EvidenceRefresher
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVIDENCE_RESOLVER_ENABLED")), "true") {
|
||||
resolver, err = LoadEvidenceClient(os.Getenv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Bell evidence resolver: %w", err)
|
||||
}
|
||||
}
|
||||
handler := Handler{DB: db, Enabled: true, Resolver: resolver, Verifier: machine_identity.Verifier{Registry: registry, Replay: PersistentReplayStore{DB: db}}}
|
||||
engine.POST("/v1/events", handler.Post)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
var ErrIdempotencyConflict = errors.New("idempotency_conflict")
|
||||
|
||||
type EvidenceRefresher interface {
|
||||
Refresh(context.Context, *gorm.DB, EvidenceStatus) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Resolver EvidenceRefresher
|
||||
}
|
||||
|
||||
func (s Service) Ingest(ctx context.Context, parsed ParsedEvent) (IngestResult, error) {
|
||||
if s.DB == nil {
|
||||
return IngestResult{}, errors.New("event database is unavailable")
|
||||
}
|
||||
var output IngestResult
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
key := fmt.Sprintf("%d:%s:%s", len(parsed.Event.ProducerID), parsed.Event.ProducerID, parsed.Event.SourceEventID)
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var existing struct {
|
||||
EventID string
|
||||
PayloadSHA256 string
|
||||
}
|
||||
err := tx.Model(&receipt.Receipt{}).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("event_id", "payload_sha256").Where("producer_id = ? AND source_event_id = ?", parsed.Event.ProducerID, parsed.Event.SourceEventID).First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.PayloadSHA256 != parsed.Digest {
|
||||
output.EventID = existing.EventID
|
||||
return ErrIdempotencyConflict
|
||||
}
|
||||
output = IngestResult{EventID: existing.EventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "duplicate", PayloadSHA256: parsed.Digest}
|
||||
return tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeReplay, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
eventID := uuid.NewString()
|
||||
var evidenceRef *string
|
||||
if len(parsed.Event.Evidence) > 0 {
|
||||
value := parsed.Event.Evidence[0].EvidenceID
|
||||
evidenceRef = &value
|
||||
}
|
||||
item := event.Event{ID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID,
|
||||
EventType: parsed.Event.EventType, OccurredAt: parsed.Occurred, Location: parsed.Event.SiteRef + "/" + parsed.Event.DeviceRef,
|
||||
Severity: parsed.Event.Severity, EvidenceRef: evidenceRef, NormalizedPayload: parsed.Canonical, PayloadSHA256: parsed.Digest, ReceivedAt: now}
|
||||
receiptItem := receipt.Receipt{ID: uuid.NewString(), EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, AcceptedAt: now}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&receiptItem).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, evidence := range parsed.Event.Evidence {
|
||||
payload, marshalErr := json.Marshal(evidence)
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
canonical, canonicalErr := canonicalJSON(payload)
|
||||
if canonicalErr != nil {
|
||||
return canonicalErr
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if evidence.ExpiresAt != "" {
|
||||
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
parsedExpiry = parsedExpiry.UTC()
|
||||
expiresAt = &parsedExpiry
|
||||
}
|
||||
status := EvidenceStatus{EventID: eventID, EvidenceID: evidence.EvidenceID, OwnerID: evidence.OwnerID,
|
||||
Status: evidence.Status, Resolution: "snapshot", CurrentPayload: canonical, ExpiresAt: expiresAt, CreatedAt: now, UpdatedAt: now}
|
||||
if err := tx.Create(&status).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeAccepted, ActorID: 0, CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
output = IngestResult{EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "created", PayloadSHA256: parsed.Digest}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, ErrIdempotencyConflict) {
|
||||
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeConflict, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
|
||||
if auditErr != nil {
|
||||
return IngestResult{}, fmt.Errorf("record conflict audit: %w", auditErr)
|
||||
}
|
||||
return output, ErrIdempotencyConflict
|
||||
}
|
||||
if err != nil || s.Resolver == nil {
|
||||
return output, err
|
||||
}
|
||||
var statuses []EvidenceStatus
|
||||
if err = s.DB.WithContext(ctx).Where("event_id = ?", output.EventID).Find(&statuses).Error; err != nil {
|
||||
return IngestResult{}, err
|
||||
}
|
||||
for _, status := range statuses {
|
||||
// Evidence lookup is supplementary. The immutable Event/Receipt boundary
|
||||
// remains accepted even when Sense is unavailable.
|
||||
_ = s.Resolver.Refresh(ctx, s.DB.WithContext(ctx), status)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidEvent = errors.New("invalid_event")
|
||||
ErrUnsupportedSchema = errors.New("unsupported_schema_version")
|
||||
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||
hexDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
)
|
||||
|
||||
func ParseEvent(raw []byte) (ParsedEvent, error) {
|
||||
var event Event
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: malformed or unknown member", ErrInvalidEvent)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: trailing JSON value", ErrInvalidEvent)
|
||||
}
|
||||
if event.SchemaVersion == "" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.SchemaVersion != EventSchemaVersion {
|
||||
return ParsedEvent{}, ErrUnsupportedSchema
|
||||
}
|
||||
occurred, err := time.Parse("2006-01-02T15:04:05.000Z", event.OccurredAt)
|
||||
if err != nil || !validID(event.ProducerID) || !validID(event.SourceEventID) || !validID(event.SiteRef) ||
|
||||
!validID(event.DeviceRef) || !validID(event.ProfileRef) || !validID(event.Rule.RuleID) ||
|
||||
!validID(event.Observation.TrackID) || !validID(event.Region.RegionID) || event.Rule.Version == "" ||
|
||||
len(event.Rule.Version) > 64 || event.Model.Name == "" || len(event.Model.Name) > 128 ||
|
||||
event.Model.Version == "" || len(event.Model.Version) > 64 || event.Observation.Confidence < 0 ||
|
||||
event.Observation.Confidence > 1 || math.IsNaN(event.Observation.Confidence) || math.IsInf(event.Observation.Confidence, 0) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.EventType != "dangerous_area_entered" && event.EventType != "directional_line_crossed" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Severity != "low" && event.Severity != "medium" && event.Severity != "high" && event.Severity != "critical" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Observation.Category != "person" && event.Observation.Category != "vehicle" && event.Observation.Category != "other" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if len(event.Observation.BBoxNormalized) != 0 && len(event.Observation.BBoxNormalized) != 4 {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
for _, value := range event.Observation.BBoxNormalized {
|
||||
if value < 0 || value > 1 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
}
|
||||
if (event.EventType == "dangerous_area_entered" && (event.Region.Kind != "area" || event.Region.CrossingDirection != "")) ||
|
||||
(event.EventType == "directional_line_crossed" && (event.Region.Kind != "line" || (event.Region.CrossingDirection != "a_to_b" && event.Region.CrossingDirection != "b_to_a"))) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Evidence == nil || len(event.Evidence) > 8 {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
seenEvidence := map[string]bool{}
|
||||
for _, evidence := range event.Evidence {
|
||||
evidenceJSON, marshalErr := json.Marshal(evidence)
|
||||
canonicalEvidence, canonicalErr := canonicalJSON(evidenceJSON)
|
||||
if err := validateEvidence(evidence); err != nil || marshalErr != nil || canonicalErr != nil || seenEvidence[string(canonicalEvidence)] {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
seenEvidence[string(canonicalEvidence)] = true
|
||||
}
|
||||
canonical, err := canonicalJSON(raw)
|
||||
if err != nil {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if containsExplicitNull(raw) {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: optional members must be omitted", ErrInvalidEvent)
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
return ParsedEvent{Event: event, Canonical: canonical, Digest: hex.EncodeToString(digest[:]), Occurred: occurred}, nil
|
||||
}
|
||||
|
||||
func validateEvidence(value Evidence) error {
|
||||
if value.SchemaVersion != EvidenceSchemaVersion || !validID(value.EvidenceID) || !validID(value.OwnerID) ||
|
||||
(value.Type != "snapshot" && value.Type != "clip") {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.CapturedAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.StatusUpdatedAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if value.ExpiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.ExpiresAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
}
|
||||
switch value.Status {
|
||||
case "pending", "processing":
|
||||
if value.ContentType != "" || value.Integrity != nil || value.Failure != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
case "success":
|
||||
if value.Integrity == nil || value.Failure != nil || (value.ContentType != "image/jpeg" && value.ContentType != "image/png" && value.ContentType != "video/mp4") ||
|
||||
value.Integrity.Algorithm != "sha256" || !hexDigestPattern.MatchString(value.Integrity.Digest) || value.Integrity.SizeBytes < 0 {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
case "failed":
|
||||
if value.Failure == nil || value.ContentType != "" || value.Integrity != nil ||
|
||||
(value.Failure.Code != "capture_failed" && value.Failure.Code != "processing_failed" && value.Failure.Code != "expired" && value.Failure.Code != "unavailable") {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
default:
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := normalizeJCSNumbers(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func normalizeJCSNumbers(value any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
number, err := strconv.ParseFloat(string(typed), 64)
|
||||
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
return nil, errors.New("JSON number is outside the RFC 8785 domain")
|
||||
}
|
||||
if number == 0 {
|
||||
return float64(0), nil
|
||||
}
|
||||
return number, nil
|
||||
case []any:
|
||||
for index, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[index] = normalized
|
||||
}
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[key] = normalized
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func containsExplicitNull(raw []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if decoder.Decode(&value) != nil {
|
||||
return true
|
||||
}
|
||||
return hasNull(value)
|
||||
}
|
||||
|
||||
func hasNull(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if hasNull(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
for _, item := range typed {
|
||||
if hasNull(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validID(value string) bool {
|
||||
return identifierPattern.MatchString(value) && !strings.ContainsAny(strings.ToLower(value), "\\/@")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type registryDocument struct {
|
||||
Version string `json:"version"`
|
||||
Audience string `json:"audience"`
|
||||
Principals []registryPrincipal `json:"principals"`
|
||||
}
|
||||
|
||||
type registryPrincipal struct {
|
||||
PrincipalID string `json:"principal_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Keys []registryKey `json:"keys"`
|
||||
}
|
||||
|
||||
type registryKey struct {
|
||||
KeyID string `json:"kid"`
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
|
||||
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
|
||||
return nil, errors.New("machine principal registry path and audience are required")
|
||||
}
|
||||
raw, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine principal registry")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
var document registryDocument
|
||||
if err = decoder.Decode(&document); err != nil {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records := make([]KeyRecord, 0)
|
||||
for _, principal := range document.Principals {
|
||||
if len(principal.Keys) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
for _, key := range principal.Keys {
|
||||
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
|
||||
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
|
||||
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
|
||||
}
|
||||
}
|
||||
return NewRegistry(records...)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
|
||||
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
|
||||
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
|
||||
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
|
||||
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
|
||||
)
|
||||
|
||||
const (
|
||||
Version = "yovision.machine-identity/v1"
|
||||
TokenType = "YOVISION-MACHINE+JWT"
|
||||
MaxLifetime = 5 * time.Minute
|
||||
AllowedSkew = 30 * time.Second
|
||||
MaxKeyOverlap = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Error struct{ Code string }
|
||||
|
||||
func (e *Error) Error() string { return e.Code }
|
||||
|
||||
func codeError(code string) error { return &Error{Code: code} }
|
||||
|
||||
// BearerToken deliberately has no cookie or query fallback.
|
||||
func BearerToken(authorization string) (string, error) {
|
||||
parts := strings.Split(authorization, " ")
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
|
||||
return "", codeError("machine_token_missing")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Version string `json:"ver"`
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience string `json:"aud"`
|
||||
Scopes []string `json:"scope"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
NotBefore int64 `json:"nbf"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
TokenID string `json:"jti"`
|
||||
Method string `json:"htm"`
|
||||
Path string `json:"htu"`
|
||||
BodySHA256 string `json:"body_sha256"`
|
||||
}
|
||||
|
||||
type protectedHeader struct {
|
||||
Algorithm string `json:"alg"`
|
||||
Type string `json:"typ"`
|
||||
KeyID string `json:"kid"`
|
||||
Version string `json:"ver"`
|
||||
}
|
||||
|
||||
type KeyRecord struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PublicKey ed25519.PublicKey
|
||||
Audience string
|
||||
Scopes []string
|
||||
Enabled bool
|
||||
Revoked bool
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]KeyRecord
|
||||
}
|
||||
|
||||
func NewRegistry(records ...KeyRecord) (*Registry, error) {
|
||||
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
|
||||
for _, record := range records {
|
||||
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
|
||||
return nil, errors.New("invalid machine key record")
|
||||
}
|
||||
if _, exists := r.keys[record.KeyID]; exists {
|
||||
return nil, errors.New("duplicate machine key id")
|
||||
}
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
r.keys[record.KeyID] = record
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
record, ok := r.keys[keyID]
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
return record, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Revoke(keyID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
record, ok := r.keys[keyID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
record.Revoked = true
|
||||
r.keys[keyID] = record
|
||||
return true
|
||||
}
|
||||
|
||||
type ReplayStore struct {
|
||||
mu sync.Mutex
|
||||
used map[string]time.Time
|
||||
}
|
||||
|
||||
// ReplayCache must atomically persist accepted (principal, jti) pairs until
|
||||
// expiry. ReplayStore is process-local and intended for tests or a single
|
||||
// uninterrupted process; connector implementations inject a durable store.
|
||||
type ReplayCache interface {
|
||||
Consume(principal, tokenID string, expiresAt, now time.Time) bool
|
||||
}
|
||||
|
||||
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
|
||||
|
||||
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for key, expiry := range s.used {
|
||||
if !expiry.After(now) {
|
||||
delete(s.used, key)
|
||||
}
|
||||
}
|
||||
key := principal + "\x00" + tokenID
|
||||
if _, exists := s.used[key]; exists {
|
||||
return false
|
||||
}
|
||||
s.used[key] = expiresAt
|
||||
return true
|
||||
}
|
||||
|
||||
type Signer struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PrivateKey ed25519.PrivateKey
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, errors.New("machine private key path is required")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine private key")
|
||||
}
|
||||
block, rest := pem.Decode(raw)
|
||||
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
|
||||
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("parse machine private key")
|
||||
}
|
||||
key, ok := parsed.(ed25519.PrivateKey)
|
||||
if !ok || len(key) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("machine private key is not Ed25519")
|
||||
}
|
||||
return slices.Clone(key), nil
|
||||
}
|
||||
|
||||
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
|
||||
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
|
||||
return "", errors.New("incomplete machine signer configuration")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method = strings.ToUpper(method)
|
||||
if !allowedMethod(method) {
|
||||
return "", errors.New("unsupported machine request method")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
tokenID, err := randomTokenID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
|
||||
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
|
||||
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
|
||||
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
|
||||
headerJSON, _ := json.Marshal(header)
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
|
||||
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
|
||||
return signingInput + "." + rawBase64(signature), nil
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
Registry *Registry
|
||||
Replay ReplayCache
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
|
||||
if v.Registry == nil || v.Replay == nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 || strings.Contains(token, "=") {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
headerBytes, err := decodeRaw(parts[0])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var header protectedHeader
|
||||
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
record, ok := v.Registry.Lookup(header.KeyID)
|
||||
if !ok {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
signature, err := decodeRaw(parts[2])
|
||||
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !record.Enabled || record.Revoked {
|
||||
return Claims{}, codeError("machine_identity_revoked")
|
||||
}
|
||||
claimsBytes, err := decodeRaw(parts[1])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var claims Claims
|
||||
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if v.Now != nil {
|
||||
now = v.Now().UTC()
|
||||
}
|
||||
nowUnix := now.Unix()
|
||||
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
|
||||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_expired")
|
||||
}
|
||||
if claims.Audience != audience || record.Audience != audience {
|
||||
return Claims{}, codeError("machine_audience_denied")
|
||||
}
|
||||
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
|
||||
return Claims{}, codeError("machine_scope_denied")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
digest := sha256.Sum256(body)
|
||||
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
|
||||
return Claims{}, codeError("machine_token_replayed")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func decodeClosed(raw []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validClaimsShape(claims Claims) bool {
|
||||
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
|
||||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
|
||||
return false
|
||||
}
|
||||
if !validScopeList(claims.Scopes) {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(claims.BodySHA256)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validScopeList(scopes []string) bool {
|
||||
if len(scopes) == 0 || len(scopes) > 4 {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, scope := range scopes {
|
||||
if !validScopes[scope] || seen[scope] {
|
||||
return false
|
||||
}
|
||||
seen[scope] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePath(value string) (string, error) {
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
|
||||
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
|
||||
}
|
||||
return parsed.EscapedPath(), nil
|
||||
}
|
||||
|
||||
func allowedMethod(method string) bool {
|
||||
switch method {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func randomTokenID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate machine token id: %w", err)
|
||||
}
|
||||
return rawBase64(raw), nil
|
||||
}
|
||||
|
||||
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
|
||||
|
||||
func decodeRaw(value string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.Strict().DecodeString(value)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type crossLanguageVector struct {
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Token string `json:"token"`
|
||||
Now int64 `json:"now"`
|
||||
Audience string `json:"audience"`
|
||||
Scope string `json:"required_scope"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body string `json:"body_base64"`
|
||||
}
|
||||
|
||||
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
|
||||
t.Helper()
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
|
||||
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
|
||||
}
|
||||
|
||||
func errorCode(t *testing.T, err error) string {
|
||||
t.Helper()
|
||||
var coded *Error
|
||||
if !errors.As(err, &coded) {
|
||||
t.Fatalf("expected coded error, got %v", err)
|
||||
}
|
||||
return coded.Code
|
||||
}
|
||||
|
||||
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
|
||||
t.Fatalf("unexpected claims: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
|
||||
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
|
||||
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
|
||||
t.Fatalf("accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
mint := func() string {
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
token := mint()
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryRevocationAndRotation(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte("{}")
|
||||
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
|
||||
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
rotation, err := NewRegistry(
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
|
||||
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
|
||||
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rotation.Revoke("brain-old-0001") {
|
||||
t.Fatal("old key was not revoked")
|
||||
}
|
||||
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
|
||||
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
|
||||
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
|
||||
if err := safe.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unsafe := safe
|
||||
unsafe.VerifyHostname = false
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("unsafe hostname policy accepted")
|
||||
}
|
||||
unsafe = safe
|
||||
unsafe.TLSMinVersion = tls.VersionTLS11
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("TLS 1.1 accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifiesCrossLanguageVector(t *testing.T) {
|
||||
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
|
||||
raw, err := os.ReadFile(vectorPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var vector crossLanguageVector
|
||||
if err = json.Unmarshal(raw, &vector); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := base64.StdEncoding.DecodeString(vector.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
|
||||
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != "yv:brain:vector" {
|
||||
t.Fatalf("unexpected issuer: %s", claims.Issuer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
|
||||
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
|
||||
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
|
||||
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
|
||||
}}}},
|
||||
}
|
||||
raw, _ := json.Marshal(document)
|
||||
file := filepath.Join(t.TempDir(), "principals.json")
|
||||
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := LoadRegistry(file, "yovision-bell")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
|
||||
t.Fatal("registry record missing")
|
||||
}
|
||||
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
|
||||
t.Fatal("wrong registry audience accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TransportPolicy struct {
|
||||
TLSMinVersion uint16
|
||||
VerifyCertificate bool
|
||||
VerifyHostname bool
|
||||
ConnectTimeout time.Duration
|
||||
ResponseHeaderTimeout time.Duration
|
||||
RequestTimeout time.Duration
|
||||
MaxRequestBytes int64
|
||||
}
|
||||
|
||||
func (p TransportPolicy) Validate() error {
|
||||
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
|
||||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
|
||||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
|
||||
return errors.New("machine transport policy is unsafe")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
|
||||
TLSHandshakeTimeout: p.ConnectTimeout,
|
||||
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/app/bell/synthetic"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
@@ -32,6 +33,9 @@ func InitRouter() {
|
||||
for _, register := range registrars {
|
||||
register(v1, authMiddleware)
|
||||
}
|
||||
if err := event_ingress.RegisterRuntime(engine); err != nil {
|
||||
log.Errorf("Bell event ingress init error: %v", err)
|
||||
}
|
||||
if synthetic.Enabled(config.ApplicationConfig.Mode, os.Getenv) {
|
||||
registerSyntheticRouter(v1, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellEventIngress)
|
||||
}
|
||||
|
||||
func migrateBellEventIngress(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(
|
||||
&event_ingress.ReplayToken{},
|
||||
&event_ingress.EvidenceStatus{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBellEventIngressMigrationIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const version = "2026083112000"
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err = migrateBellEventIngress(db, version); err != nil {
|
||||
t.Fatalf("migration attempt %d: %v", attempt+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
for name, model := range map[string]any{
|
||||
"replay tokens": &event_ingress.ReplayToken{},
|
||||
"evidence statuses": &event_ingress.EvidenceStatus{},
|
||||
} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
t.Fatalf("%s table missing", name)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(model).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count %s: %v", name, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("migration inserted %d %s fixtures", count, name)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasIndex(&event_ingress.ReplayToken{}, "ExpiresAt") {
|
||||
t.Fatal("replay expiry index missing")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
token := event_ingress.ReplayToken{Principal: "brain", TokenID: "token-1", ExpiresAt: now.Add(time.Minute), CreatedAt: now}
|
||||
if err = db.Create(&token).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&token).Error; err == nil {
|
||||
t.Fatal("duplicate replay token accepted")
|
||||
}
|
||||
|
||||
var applied int64
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("migration records=%d, want 1", applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package event_ingress_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
func TestPostgresConcurrentBusinessAndSecurityIdempotency(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_EVENT_INGRESS_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set BELL_EVENT_INGRESS_TEST_DATABASE_URL to run PostgreSQL concurrency verification")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := event_ingress.ParseEvent(fixture(t, "dangerous-area.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const workers = 12
|
||||
var created, duplicate, failures atomic.Int32
|
||||
var wait sync.WaitGroup
|
||||
for range workers {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
result, ingestErr := (event_ingress.Service{DB: db}).Ingest(context.Background(), parsed)
|
||||
if ingestErr != nil {
|
||||
failures.Add(1)
|
||||
return
|
||||
}
|
||||
if result.Disposition == "created" {
|
||||
created.Add(1)
|
||||
} else if result.Disposition == "duplicate" {
|
||||
duplicate.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if created.Load() != 1 || duplicate.Load() != workers-1 || failures.Load() != 0 {
|
||||
t.Fatalf("concurrent ingest created=%d duplicate=%d failures=%d", created.Load(), duplicate.Load(), failures.Load())
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var consumed atomic.Int32
|
||||
for range workers {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
if (event_ingress.PersistentReplayStore{DB: db}).Consume("yv:sense:school-a", "concurrent-token-id-0001", now.Add(time.Minute), now) {
|
||||
consumed.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if consumed.Load() != 1 {
|
||||
t.Fatalf("concurrent replay consume accepted %d requests", consumed.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRegistrationIsOptionalAndMigrationGated(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "")
|
||||
disabled := gin.New()
|
||||
if err := event_ingress.RegisterRuntime(disabled); err != nil || len(disabled.Routes()) != 0 {
|
||||
t.Fatalf("disabled runtime err=%v routes=%#v", err, disabled.Routes())
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file:bell-runtime?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sdk.Runtime.SetDb("", db)
|
||||
t.Cleanup(func() { sdk.Runtime.SetDb("", nil) })
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "true")
|
||||
if err = event_ingress.RegisterRuntime(gin.New()); err == nil {
|
||||
t.Fatal("enabled runtime started without formal migration")
|
||||
}
|
||||
if err = db.AutoMigrate(&event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registryPath := writeRegistry(t, "yovision-bell", "yv:sense:school-a", "sense-key-0001")
|
||||
t.Setenv("BELL_MACHINE_PRINCIPAL_REGISTRY", registryPath)
|
||||
registered := gin.New()
|
||||
if err = event_ingress.RegisterRuntime(registered); err != nil {
|
||||
t.Fatalf("enabled runtime did not register after migration: %v", err)
|
||||
}
|
||||
routes := registered.Routes()
|
||||
if len(routes) != 1 || routes[0].Method != http.MethodPost || routes[0].Path != "/v1/events" {
|
||||
t.Fatalf("unexpected ingress routes: %#v", routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractFixtureIdempotencyConflictAndReplayPersistence(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
databasePath := filepath.Join(t.TempDir(), "bell-ingress.sqlite")
|
||||
db := openDatabasePath(t, databasePath)
|
||||
body := fixture(t, "dangerous-area.json")
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
newHandler := func() event_ingress.Handler {
|
||||
return event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
|
||||
}
|
||||
|
||||
firstToken := mint(t, signer, body)
|
||||
first := request(t, newHandler(), body, firstToken)
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first ingest status=%d body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
var created event_ingress.IngestResult
|
||||
decode(t, first, &created)
|
||||
if created.Disposition != "created" || created.PayloadSHA256 != "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1" {
|
||||
t.Fatalf("unexpected created result: %+v", created)
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = sqlDatabase.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db = openDatabasePath(t, databasePath)
|
||||
|
||||
// A new process-local Handler and replay store still reject the old token,
|
||||
// proving that security replay state is durable rather than in-memory.
|
||||
replayedToken := request(t, newHandler(), body, firstToken)
|
||||
if replayedToken.Code != http.StatusUnauthorized || !strings.Contains(replayedToken.Body.String(), "machine_token_replayed") {
|
||||
t.Fatalf("token replay status=%d body=%s", replayedToken.Code, replayedToken.Body.String())
|
||||
}
|
||||
|
||||
duplicate := request(t, newHandler(), body, mint(t, signer, body))
|
||||
if duplicate.Code != http.StatusOK {
|
||||
t.Fatalf("business duplicate status=%d body=%s", duplicate.Code, duplicate.Body.String())
|
||||
}
|
||||
var duplicateResult event_ingress.IngestResult
|
||||
decode(t, duplicate, &duplicateResult)
|
||||
if duplicateResult.Disposition != "duplicate" || duplicateResult.EventID != created.EventID {
|
||||
t.Fatalf("duplicate did not retain event identity: %+v", duplicateResult)
|
||||
}
|
||||
numericVariant := bytes.Replace(body, []byte(`0.93`), []byte(`0.930`), 1)
|
||||
numericDuplicate := request(t, newHandler(), numericVariant, mint(t, signer, numericVariant))
|
||||
if numericDuplicate.Code != http.StatusOK || !strings.Contains(numericDuplicate.Body.String(), created.PayloadSHA256) {
|
||||
t.Fatalf("JCS-equivalent numeric payload was not a duplicate: %d %s", numericDuplicate.Code, numericDuplicate.Body.String())
|
||||
}
|
||||
|
||||
var changed map[string]any
|
||||
if err = json.Unmarshal(body, &changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed["severity"] = "critical"
|
||||
conflicting, _ := json.Marshal(changed)
|
||||
conflict := request(t, newHandler(), conflicting, mint(t, signer, conflicting))
|
||||
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "idempotency_conflict") || !strings.Contains(conflict.Body.String(), created.EventID) {
|
||||
t.Fatalf("conflict status=%d body=%s", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
assertCount(t, db, &event.Event{}, 1)
|
||||
assertCount(t, db, &receipt.Receipt{}, 1)
|
||||
assertCount(t, db, &receipt.IngestAudit{}, 4)
|
||||
}
|
||||
|
||||
func TestEvidenceDegradationIdentityErrorsAndDisabledConnector(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
registry, _ := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
signer := machine_identity.Signer{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
handler := event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
|
||||
|
||||
pending := fixture(t, "dangerous-area.json")
|
||||
if response := requestWithID(t, handler, pending, mint(t, signer, pending), "short"); response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request_id") {
|
||||
t.Fatalf("invalid request id status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
missingRequestID := requestWithID(t, handler, pending, mint(t, signer, pending), "")
|
||||
if missingRequestID.Code != http.StatusCreated || !requestIDPatternForTest(missingRequestID.Header().Get("X-Request-ID")) {
|
||||
t.Fatalf("trusted hop did not create a request id: %d %s", missingRequestID.Code, missingRequestID.Body.String())
|
||||
}
|
||||
queryResponse := requestTarget(t, handler, pending, mint(t, signer, pending), "/v1/events?debug=true")
|
||||
if queryResponse.Code != http.StatusBadRequest || !strings.Contains(queryResponse.Body.String(), "invalid_request_target") {
|
||||
t.Fatalf("query target was accepted: %d %s", queryResponse.Code, queryResponse.Body.String())
|
||||
}
|
||||
if response := request(t, handler, pending, mint(t, signer, pending)); response.Code != http.StatusOK {
|
||||
t.Fatalf("pending evidence rejected: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
failed := fixture(t, "directional-line-crossed.json")
|
||||
if response := request(t, handler, failed, mint(t, signer, failed)); response.Code != http.StatusCreated {
|
||||
t.Fatalf("failed evidence rejected: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
assertCount(t, db, &event.Event{}, 2)
|
||||
|
||||
wrongAudienceToken, err := signer.Mint("yovision-sense", []string{"events:ingest"}, http.MethodPost, "/v1/events", pending)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response := request(t, handler, pending, wrongAudienceToken); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("wrong audience was not forbidden: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if response := request(t, event_ingress.Handler{Enabled: false}, pending, "none"); response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("disabled connector status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceResolverCurrentMissingExpiredAndTimeout(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
status := event_ingress.EvidenceStatus{EventID: "event-1", EvidenceID: "ev-school-east-0001", OwnerID: "sense-school-a", Status: "pending", Resolution: "snapshot", CurrentPayload: json.RawMessage(`{"schema_version":"yovision.evidence-reference/v1","evidence_id":"ev-school-east-0001","owner_id":"sense-school-a","type":"snapshot","status":"pending","captured_at":"2026-08-31T00:00:01.125Z","status_updated_at":"2026-08-31T00:00:01.125Z"}`), CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&status).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
signer := machine_identity.Signer{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
response := func(code int, body string) *http.Response {
|
||||
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||
}
|
||||
client := event_ingress.EvidenceClient{Endpoint: "https://sense.example", Signer: signer, HTTP: doFunc(func(*http.Request) (*http.Response, error) {
|
||||
return response(http.StatusNotFound, `{}`), nil
|
||||
})}
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_not_found" {
|
||||
t.Fatalf("missing resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
|
||||
}
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusGone, `{}`), nil })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "expired" {
|
||||
t.Fatalf("expired resolution=%s db=%v", status.Resolution, err)
|
||||
}
|
||||
current := `{"schema_version":"yovision.evidence-reference/v1","evidence_id":"ev-school-east-0001","owner_id":"sense-school-a","type":"snapshot","status":"success","captured_at":"2026-08-31T00:00:01.125Z","status_updated_at":"2026-08-31T00:00:02.125Z","content_type":"image/jpeg","integrity":{"algorithm":"sha256","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size_bytes":1}}`
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusOK, current), nil })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "current" || status.Status != "success" || status.LastError != "" {
|
||||
t.Fatalf("current status=%s resolution=%s error=%s db=%v", status.Status, status.Resolution, status.LastError, err)
|
||||
}
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return nil, context.DeadlineExceeded })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_timeout" {
|
||||
t.Fatalf("timeout resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
|
||||
}
|
||||
}
|
||||
|
||||
func openDatabase(t *testing.T) *gorm.DB {
|
||||
return openDatabasePath(t, filepath.Join(t.TempDir(), "bell-ingress.sqlite"))
|
||||
}
|
||||
|
||||
func openDatabasePath(t *testing.T, databasePath string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDatabase.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func fixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "..", "..", "contracts", "events", "v1", "examples", name)
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func writeRegistry(t *testing.T, audience, principal, keyID string) string {
|
||||
t.Helper()
|
||||
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1", "audience": audience,
|
||||
"principals": []any{map[string]any{
|
||||
"principal_id": principal, "enabled": true,
|
||||
"keys": []any{map[string]any{
|
||||
"kid": keyID, "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey),
|
||||
"status": "active", "scopes": []string{"events:ingest"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
encoded, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "registry.json")
|
||||
if err = os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func mint(t *testing.T, signer machine_identity.Signer, body []byte) string {
|
||||
t.Helper()
|
||||
token, err := signer.Mint("yovision-bell", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func request(t *testing.T, handler event_ingress.Handler, body []byte, token string) *httptest.ResponseRecorder {
|
||||
return requestWithID(t, handler, body, token, "request-id-0000001")
|
||||
}
|
||||
|
||||
func requestWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, requestID string) *httptest.ResponseRecorder {
|
||||
return requestTargetWithID(t, handler, body, token, "/v1/events", requestID)
|
||||
}
|
||||
|
||||
func requestTarget(t *testing.T, handler event_ingress.Handler, body []byte, token, target string) *httptest.ResponseRecorder {
|
||||
return requestTargetWithID(t, handler, body, token, target, "request-id-0000001")
|
||||
}
|
||||
|
||||
func requestTargetWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, target, requestID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Request-ID", requestID)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
handler.Post(context)
|
||||
return response
|
||||
}
|
||||
|
||||
func requestIDPatternForTest(value string) bool {
|
||||
if len(value) < 16 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for index, r := range value {
|
||||
if !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || index > 0 && strings.ContainsRune("._:-", r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decode(t *testing.T, response *httptest.ResponseRecorder, target any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(response.Body.Bytes(), target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, db *gorm.DB, model any, expected int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Model(model).Count(&count).Error; err != nil || count != expected {
|
||||
t.Fatalf("count %T=%d expected=%d err=%v", model, count, expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
type doFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (function doFunc) Do(request *http.Request) (*http.Response, error) { return function(request) }
|
||||
@@ -8,7 +8,7 @@ version = "0.1.0"
|
||||
description = "Headless inference delivery unit for YoVision"
|
||||
readme = "README.md"
|
||||
requires-python = "==3.11.*"
|
||||
dependencies = []
|
||||
dependencies = ["cryptography==50.0.1"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# The wheel backend is selected by the official PyTorch index documented in
|
||||
|
||||
@@ -11,6 +11,7 @@ from yovision_brain.config import ConfigError
|
||||
from yovision_brain.decode import DecoderError
|
||||
from yovision_brain.events import JsonLinesSink
|
||||
from yovision_brain.input import InputError
|
||||
from yovision_brain.integration.event_export import build_event_export_sink
|
||||
from yovision_brain.rules import RuleConfigError
|
||||
|
||||
from .runner import run_pipeline
|
||||
@@ -38,14 +39,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
stream = sys.stdout
|
||||
owned_stream = None
|
||||
try:
|
||||
if args.output != "-":
|
||||
export_sink = build_event_export_sink(raw.get("event_export"), base_dir=config_path.parent)
|
||||
if export_sink is None and args.output != "-":
|
||||
try:
|
||||
owned_stream = Path(args.output).open("w", encoding="utf-8", newline="\n")
|
||||
except OSError:
|
||||
print(json.dumps({"status": "error", "message": "event output cannot be opened"}), file=sys.stderr)
|
||||
return 2
|
||||
stream = owned_stream
|
||||
summary = run_pipeline(raw, JsonLinesSink(stream), base_dir=config_path.parent)
|
||||
sink = export_sink if export_sink is not None else JsonLinesSink(stream)
|
||||
summary = run_pipeline(raw, sink, base_dir=config_path.parent)
|
||||
except (ConfigError, DecoderError, InputError, RuleConfigError, RuntimeError, ValueError) as exc:
|
||||
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 3
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Map Brain-internal candidates to the frozen anonymous event contract."""
|
||||
|
||||
from .mapper import (
|
||||
EVENT_SCHEMA_VERSION,
|
||||
EVIDENCE_SCHEMA_VERSION,
|
||||
EventExportError,
|
||||
canonical_json,
|
||||
canonical_json_bytes,
|
||||
export_event,
|
||||
payload_sha256,
|
||||
)
|
||||
from .replay import SQLiteReplayCache
|
||||
from .client import DeliveryResult, EventDeliveryError, HTTPSMachineIdentitySender
|
||||
from .runtime import EventExportSink, build_event_export_sink
|
||||
|
||||
__all__ = [
|
||||
"EVENT_SCHEMA_VERSION",
|
||||
"EVIDENCE_SCHEMA_VERSION",
|
||||
"EventExportError",
|
||||
"EventDeliveryError",
|
||||
"DeliveryResult",
|
||||
"EventExportSink",
|
||||
"HTTPSMachineIdentitySender",
|
||||
"SQLiteReplayCache",
|
||||
"canonical_json",
|
||||
"canonical_json_bytes",
|
||||
"export_event",
|
||||
"payload_sha256",
|
||||
"build_event_export_sink",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Synchronous, request-bound HTTPS delivery for Brain event exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from yovision_brain.integration.machine_identity import Signer, TransportPolicy
|
||||
|
||||
EVENT_PATH = "/v1/events"
|
||||
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")
|
||||
|
||||
|
||||
class EventDeliveryError(RuntimeError):
|
||||
"""An event was not accepted; callers must retain or reproduce the fact."""
|
||||
|
||||
def __init__(self, code: str, *, terminal: bool) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.terminal = terminal
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryResult:
|
||||
disposition: str
|
||||
request_id: str
|
||||
|
||||
|
||||
class _Headers(Protocol):
|
||||
def get(self, name: str, default: str | None = None) -> str | None: ...
|
||||
|
||||
|
||||
class _Response(Protocol):
|
||||
status: int
|
||||
headers: _Headers
|
||||
|
||||
def read(self, amount: int = -1) -> bytes: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class _Opener(Protocol):
|
||||
def open(self, request: urllib.request.Request, timeout: float) -> _Response: ...
|
||||
|
||||
|
||||
class HTTPSMachineIdentitySender:
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
signer: Signer,
|
||||
policy: TransportPolicy,
|
||||
*,
|
||||
opener: _Opener | None = None,
|
||||
) -> None:
|
||||
parsed = urlsplit(endpoint)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in {"", "/"}
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError("event export endpoint must be an HTTPS origin")
|
||||
policy.validate()
|
||||
self._endpoint = endpoint.rstrip("/")
|
||||
self._signer = signer
|
||||
self._policy = policy
|
||||
self._opener = opener or urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=policy.ssl_context())
|
||||
)
|
||||
|
||||
def send(self, body: bytes) -> DeliveryResult:
|
||||
if len(body) > self._policy.max_request_bytes:
|
||||
raise EventDeliveryError("event_request_too_large", terminal=True)
|
||||
request_id = "req-" + secrets.token_urlsafe(16)
|
||||
if not _REQUEST_ID.fullmatch(request_id): # pragma: no cover - defensive invariant
|
||||
raise RuntimeError("generated request id is invalid")
|
||||
token = self._signer.mint(
|
||||
"yovision-sense", ("events:ingest",), "POST", EVENT_PATH, body
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
self._endpoint + EVENT_PATH,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": request_id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
response = self._opener.open(
|
||||
request, timeout=self._policy.request_timeout_ms / 1000
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
response_body = exc.read(64 * 1024 + 1)
|
||||
code = _problem_code(response_body) or "event_delivery_rejected"
|
||||
raise EventDeliveryError(
|
||||
code,
|
||||
terminal=400 <= exc.code < 500 and exc.code != 429,
|
||||
) from None
|
||||
except (OSError, TimeoutError, urllib.error.URLError):
|
||||
raise EventDeliveryError("event_delivery_unavailable", terminal=False) from None
|
||||
|
||||
try:
|
||||
response_body = response.read(64 * 1024 + 1)
|
||||
if len(response_body) > 64 * 1024:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
if response.status not in {200, 201, 202}:
|
||||
raise EventDeliveryError(
|
||||
"event_delivery_rejected",
|
||||
terminal=400 <= response.status < 500 and response.status != 429,
|
||||
)
|
||||
response_request_id = response.headers.get("X-Request-ID")
|
||||
if response_request_id != request_id:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
disposition = _disposition(response_body, response.status, body)
|
||||
return DeliveryResult(disposition=disposition, request_id=request_id)
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
def _problem_code(body: bytes) -> str | None:
|
||||
try:
|
||||
value = json.loads(body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
code = value.get("code") if isinstance(value, dict) else None
|
||||
return code if isinstance(code, str) and re.fullmatch(r"[a-z][a-z0-9_]{0,63}", code) else None
|
||||
|
||||
|
||||
def _disposition(body: bytes, status: int, request_body: bytes) -> str:
|
||||
try:
|
||||
value = json.loads(body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False) from None
|
||||
disposition = value.get("disposition") if isinstance(value, dict) else None
|
||||
allowed = {"accepted", "created", "duplicate"}
|
||||
if disposition not in allowed:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
if status == 202 and disposition != "accepted":
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
try:
|
||||
sent = json.loads(request_body)
|
||||
response_identity = (
|
||||
value["producer_id"],
|
||||
value["source_event_id"],
|
||||
value["payload_sha256"],
|
||||
)
|
||||
expected_identity = (
|
||||
sent["producer_id"],
|
||||
sent["source_event_id"],
|
||||
hashlib.sha256(request_body).hexdigest(),
|
||||
)
|
||||
except (KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False) from None
|
||||
if response_identity != expected_identity:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
return disposition
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Safe, deterministic Brain producer mapping for ``yovision.event/v1``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from yovision_brain.events import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
|
||||
|
||||
EVENT_SCHEMA_VERSION = "yovision.event/v1"
|
||||
EVIDENCE_SCHEMA_VERSION = "yovision.evidence-reference/v1"
|
||||
|
||||
_REFERENCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
|
||||
_WINDOWS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
_SENSITIVE_NAMES = frozenset(
|
||||
{
|
||||
"path",
|
||||
"url",
|
||||
"uri",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"credential",
|
||||
"signed_url",
|
||||
"camera_url",
|
||||
"face",
|
||||
"face_id",
|
||||
"face_template",
|
||||
"alert",
|
||||
"ack",
|
||||
"close",
|
||||
"notification",
|
||||
}
|
||||
)
|
||||
_EVIDENCE_FIELDS = frozenset(
|
||||
{
|
||||
"schema_version",
|
||||
"evidence_id",
|
||||
"owner_id",
|
||||
"type",
|
||||
"status",
|
||||
"captured_at",
|
||||
"status_updated_at",
|
||||
"expires_at",
|
||||
"content_type",
|
||||
"integrity",
|
||||
"failure",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EventExportError(ValueError):
|
||||
"""The internal candidate cannot safely satisfy the frozen contract."""
|
||||
|
||||
|
||||
def export_event(
|
||||
candidate: InternalEventCandidate,
|
||||
*,
|
||||
producer_id: str,
|
||||
site_ref: str,
|
||||
device_ref: str | None = None,
|
||||
severity: str,
|
||||
evidence: Sequence[Mapping[str, object]] = (),
|
||||
region_ref: str | None = None,
|
||||
crossing_direction: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return a new closed v1 payload without mutating the internal candidate.
|
||||
|
||||
The candidate's already stable ``event_id`` is the source identity. Callers
|
||||
must persist and retry the returned payload unchanged; transport attempts do
|
||||
not participate in either identity field.
|
||||
"""
|
||||
|
||||
if candidate.schema != INTERNAL_EVENT_SCHEMA:
|
||||
raise EventExportError("unsupported internal event candidate schema")
|
||||
producer_id = _reference("producer_id", producer_id)
|
||||
source_event_id = _reference("source_event_id", candidate.event_id)
|
||||
site_ref = _reference("site_ref", site_ref)
|
||||
device_ref = _reference("device_ref", device_ref or candidate.logical_input_id)
|
||||
profile_ref = _reference("profile_ref", candidate.profile_id)
|
||||
rule_id = _reference("rule.rule_id", candidate.rule_id)
|
||||
region_id = _reference("region.region_id", region_ref or candidate.rule_id)
|
||||
track_id = _reference("observation.track_id", candidate.track_id)
|
||||
|
||||
event_type = {
|
||||
"danger_area_entered": "dangerous_area_entered",
|
||||
"dangerous_area_entered": "dangerous_area_entered",
|
||||
"directional_line_crossed": "directional_line_crossed",
|
||||
}.get(candidate.event_type)
|
||||
if event_type is None:
|
||||
raise EventExportError("unsupported event type")
|
||||
if severity not in {"low", "medium", "high", "critical"}:
|
||||
raise EventExportError("unsupported severity")
|
||||
|
||||
observation = _observation(candidate, track_id=track_id, category=category)
|
||||
region: dict[str, object] = {
|
||||
"region_id": region_id,
|
||||
"kind": "area" if event_type == "dangerous_area_entered" else "line",
|
||||
}
|
||||
if event_type == "directional_line_crossed":
|
||||
if crossing_direction not in {"a_to_b", "b_to_a"}:
|
||||
raise EventExportError("line events require a contract crossing_direction")
|
||||
region["crossing_direction"] = crossing_direction
|
||||
elif crossing_direction is not None:
|
||||
raise EventExportError("area events cannot carry crossing_direction")
|
||||
|
||||
if len(evidence) > 8:
|
||||
raise EventExportError("at most eight evidence references are allowed")
|
||||
mapped_evidence = [_evidence_reference(item) for item in evidence]
|
||||
if len({canonical_json_bytes(item) for item in mapped_evidence}) != len(mapped_evidence):
|
||||
raise EventExportError("duplicate evidence references are not allowed")
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"schema_version": EVENT_SCHEMA_VERSION,
|
||||
"producer_id": producer_id,
|
||||
"source_event_id": source_event_id,
|
||||
"site_ref": site_ref,
|
||||
"device_ref": device_ref,
|
||||
"profile_ref": profile_ref,
|
||||
"event_type": event_type,
|
||||
"occurred_at": _event_timestamp(candidate.occurred_at_ns),
|
||||
"severity": severity,
|
||||
"rule": {"rule_id": rule_id, "version": _bounded_text("rule.version", candidate.rule_version, 64)},
|
||||
"model": {
|
||||
"name": _bounded_text("model.name", candidate.model_name, 128),
|
||||
"version": _bounded_text("model.version", candidate.model_version, 64),
|
||||
},
|
||||
"observation": observation,
|
||||
"region": region,
|
||||
"evidence": mapped_evidence,
|
||||
}
|
||||
_reject_unsafe(payload)
|
||||
canonical_json_bytes(payload) # Reject non-finite or unsupported values now.
|
||||
return payload
|
||||
|
||||
|
||||
def canonical_json(value: object) -> str:
|
||||
"""Serialize the closed event-domain JCS subset used by frozen fixtures.
|
||||
|
||||
Contract values use JSON strings, containers, booleans, integers and finite
|
||||
ordinary decimals. Integer-valued floats are normalized to their JSON number
|
||||
form; the checked-in RFC 8785 vector fixes cross-language digest behavior.
|
||||
"""
|
||||
|
||||
return _encode_jcs(_normalize_numbers(value))
|
||||
|
||||
|
||||
def canonical_json_bytes(value: object) -> bytes:
|
||||
return canonical_json(value).encode("utf-8")
|
||||
|
||||
|
||||
def payload_sha256(event: Mapping[str, object]) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(event)).hexdigest()
|
||||
|
||||
|
||||
def _observation(
|
||||
candidate: InternalEventCandidate, *, track_id: str, category: str | None
|
||||
) -> dict[str, object]:
|
||||
internal = candidate.observation
|
||||
confidence = internal.get("confidence")
|
||||
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
|
||||
raise EventExportError("observation confidence must be numeric")
|
||||
confidence = float(confidence)
|
||||
if not math.isfinite(confidence) or not 0 <= confidence <= 1:
|
||||
raise EventExportError("observation confidence must be finite and between zero and one")
|
||||
|
||||
internal_category = internal.get("category")
|
||||
exported_category = category or {
|
||||
"anonymous_target": "person",
|
||||
"person": "person",
|
||||
"vehicle": "vehicle",
|
||||
"other": "other",
|
||||
}.get(internal_category)
|
||||
if exported_category not in {"person", "vehicle", "other"}:
|
||||
raise EventExportError("observation category requires an explicit anonymous contract mapping")
|
||||
|
||||
result: dict[str, object] = {
|
||||
"track_id": track_id,
|
||||
"category": exported_category,
|
||||
"confidence": confidence,
|
||||
}
|
||||
box = internal.get("box")
|
||||
if box is not None:
|
||||
if not isinstance(box, Mapping) or set(box) != {"left", "top", "right", "bottom"}:
|
||||
raise EventExportError("internal observation box is malformed")
|
||||
if candidate.frame_width <= 0 or candidate.frame_height <= 0:
|
||||
raise EventExportError("frame dimensions must be positive")
|
||||
coordinates = (box["left"], box["top"], box["right"], box["bottom"])
|
||||
if any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in coordinates):
|
||||
raise EventExportError("box coordinates must be numeric")
|
||||
normalized = [
|
||||
float(coordinates[0]) / candidate.frame_width,
|
||||
float(coordinates[1]) / candidate.frame_height,
|
||||
float(coordinates[2]) / candidate.frame_width,
|
||||
float(coordinates[3]) / candidate.frame_height,
|
||||
]
|
||||
if any(not math.isfinite(value) or not 0 <= value <= 1 for value in normalized):
|
||||
raise EventExportError("normalized box coordinates must be finite and between zero and one")
|
||||
result["bbox_normalized"] = normalized
|
||||
return result
|
||||
|
||||
|
||||
def _evidence_reference(source: Mapping[str, object]) -> dict[str, object]:
|
||||
if not isinstance(source, Mapping):
|
||||
raise EventExportError("evidence reference must be an object")
|
||||
unknown = set(source) - _EVIDENCE_FIELDS
|
||||
if unknown:
|
||||
raise EventExportError(f"evidence reference contains forbidden fields: {sorted(unknown)!r}")
|
||||
required = {
|
||||
"schema_version",
|
||||
"evidence_id",
|
||||
"owner_id",
|
||||
"type",
|
||||
"status",
|
||||
"captured_at",
|
||||
"status_updated_at",
|
||||
}
|
||||
missing = required - set(source)
|
||||
if missing:
|
||||
raise EventExportError(f"evidence reference is missing fields: {sorted(missing)!r}")
|
||||
result = dict(source)
|
||||
if result["schema_version"] != EVIDENCE_SCHEMA_VERSION:
|
||||
raise EventExportError("unsupported evidence schema version")
|
||||
_reference("evidence.evidence_id", result["evidence_id"])
|
||||
_reference("evidence.owner_id", result["owner_id"])
|
||||
if result["type"] not in {"snapshot", "clip"}:
|
||||
raise EventExportError("unsupported evidence type")
|
||||
status = result["status"]
|
||||
if status not in {"pending", "processing", "success", "failed"}:
|
||||
raise EventExportError("unsupported evidence status")
|
||||
for field in ("captured_at", "status_updated_at", "expires_at"):
|
||||
if field in result:
|
||||
_date_time(field, result[field])
|
||||
|
||||
if status in {"pending", "processing"}:
|
||||
if any(field in result for field in ("content_type", "integrity", "failure")):
|
||||
raise EventExportError(f"{status} evidence cannot claim content or failure")
|
||||
elif status == "success":
|
||||
if "failure" in result or "content_type" not in result or "integrity" not in result:
|
||||
raise EventExportError("successful evidence requires content metadata and no failure")
|
||||
if result["content_type"] not in {"image/jpeg", "image/png", "video/mp4"}:
|
||||
raise EventExportError("unsupported evidence content type")
|
||||
integrity = result["integrity"]
|
||||
if not isinstance(integrity, Mapping) or set(integrity) != {"algorithm", "digest", "size_bytes"}:
|
||||
raise EventExportError("evidence integrity is malformed")
|
||||
if integrity["algorithm"] != "sha256" or not isinstance(integrity["digest"], str) or not _SHA256.fullmatch(integrity["digest"]):
|
||||
raise EventExportError("evidence integrity must contain a SHA-256 digest")
|
||||
if isinstance(integrity["size_bytes"], bool) or not isinstance(integrity["size_bytes"], int) or integrity["size_bytes"] < 0:
|
||||
raise EventExportError("evidence size must be a non-negative integer")
|
||||
else:
|
||||
if "content_type" in result or "integrity" in result or "failure" not in result:
|
||||
raise EventExportError("failed evidence requires only failure metadata")
|
||||
failure = result["failure"]
|
||||
if not isinstance(failure, Mapping) or set(failure) != {"code", "retryable"}:
|
||||
raise EventExportError("evidence failure is malformed")
|
||||
if failure["code"] not in {"capture_failed", "processing_failed", "expired", "unavailable"}:
|
||||
raise EventExportError("unsupported evidence failure code")
|
||||
if not isinstance(failure["retryable"], bool):
|
||||
raise EventExportError("evidence retryable must be boolean")
|
||||
_reject_unsafe(result)
|
||||
return result
|
||||
|
||||
|
||||
def _reference(name: str, value: object) -> str:
|
||||
if not isinstance(value, str) or not _REFERENCE.fullmatch(value):
|
||||
raise EventExportError(f"{name} is not a valid logical reference")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_text(name: str, value: object, maximum: int) -> str:
|
||||
if not isinstance(value, str) or not 1 <= len(value) <= maximum:
|
||||
raise EventExportError(f"{name} must be 1..{maximum} characters")
|
||||
return value
|
||||
|
||||
|
||||
def _event_timestamp(nanoseconds: int) -> str:
|
||||
if isinstance(nanoseconds, bool) or not isinstance(nanoseconds, int) or nanoseconds < 0:
|
||||
raise EventExportError("occurred_at_ns must be a non-negative integer")
|
||||
seconds, remainder = divmod(nanoseconds, 1_000_000_000)
|
||||
value = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(
|
||||
seconds=seconds, milliseconds=remainder // 1_000_000
|
||||
)
|
||||
return value.strftime("%Y-%m-%dT%H:%M:%S.") + f"{value.microsecond // 1000:03d}Z"
|
||||
|
||||
|
||||
def _date_time(name: str, value: object) -> None:
|
||||
if not isinstance(value, str):
|
||||
raise EventExportError(f"evidence {name} must be a date-time string")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise EventExportError(f"evidence {name} must be a valid date-time") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise EventExportError(f"evidence {name} must include a timezone")
|
||||
|
||||
|
||||
def _reject_unsafe(value: object, *, key: str = "") -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for child_key, child in value.items():
|
||||
lowered = str(child_key).lower()
|
||||
if lowered in _SENSITIVE_NAMES or lowered.endswith("_path") or lowered.endswith("_url"):
|
||||
raise EventExportError(f"sensitive field {child_key!r} is forbidden")
|
||||
_reject_unsafe(child, key=lowered)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for child in value:
|
||||
_reject_unsafe(child, key=key)
|
||||
elif isinstance(value, str):
|
||||
if _URL.match(value) or _WINDOWS_PATH.match(value) or value.startswith(("/", "\\\\")):
|
||||
raise EventExportError(f"path or URL value in {key or 'payload'} is forbidden")
|
||||
|
||||
|
||||
def _normalize_numbers(value: object) -> object:
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
raise EventExportError("canonical JSON rejects non-finite numbers")
|
||||
if value == 0:
|
||||
return 0
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise EventExportError("canonical JSON object keys must be strings")
|
||||
return {key: _normalize_numbers(child) for key, child in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_normalize_numbers(child) for child in value]
|
||||
raise EventExportError(f"canonical JSON does not support {type(value).__name__}")
|
||||
|
||||
|
||||
def _encode_jcs(value: object) -> str:
|
||||
if value is None:
|
||||
return "null"
|
||||
if value is True:
|
||||
return "true"
|
||||
if value is False:
|
||||
return "false"
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
return _jcs_float(value)
|
||||
if isinstance(value, list):
|
||||
return "[" + ",".join(_encode_jcs(item) for item in value) + "]"
|
||||
if isinstance(value, Mapping):
|
||||
# Frozen contract keys are ASCII. Sorting them is therefore identical
|
||||
# to RFC 8785's UTF-16 code-unit ordering without accepting extensions.
|
||||
return "{" + ",".join(
|
||||
_encode_jcs(key) + ":" + _encode_jcs(value[key]) for key in sorted(value)
|
||||
) + "}"
|
||||
raise EventExportError(f"canonical JSON does not support {type(value).__name__}")
|
||||
|
||||
|
||||
def _jcs_float(value: float) -> str:
|
||||
if not math.isfinite(value):
|
||||
raise EventExportError("canonical JSON rejects non-finite numbers")
|
||||
if value == 0:
|
||||
return "0"
|
||||
rendered = repr(value).lower()
|
||||
absolute = abs(value)
|
||||
if 1e-6 <= absolute < 1e21 and "e" in rendered:
|
||||
return format(Decimal(rendered), "f")
|
||||
if "e" in rendered:
|
||||
mantissa, exponent = rendered.split("e", 1)
|
||||
sign = ""
|
||||
if exponent.startswith(("+", "-")):
|
||||
sign, exponent = exponent[0], exponent[1:]
|
||||
exponent = exponent.lstrip("0") or "0"
|
||||
rendered = mantissa + "e" + sign + exponent
|
||||
return rendered[:-2] if rendered.endswith(".0") else rendered
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Durable atomic replay protection owned by the Brain connector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SQLiteReplayCache:
|
||||
"""SQLite implementation of the machine-identity ``ReplayCache`` protocol.
|
||||
|
||||
A primary key makes consumption atomic across threads and processes. Entries
|
||||
remain durable across connector restarts until their verifier expiry passes.
|
||||
"""
|
||||
|
||||
def __init__(self, database: str | Path, *, timeout_seconds: float = 5.0) -> None:
|
||||
self._database = str(Path(database))
|
||||
self._timeout_seconds = timeout_seconds
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("SQLite replay timeout must be positive")
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS machine_token_replay (
|
||||
principal TEXT NOT NULL,
|
||||
token_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (principal, token_id)
|
||||
) WITHOUT ROWID
|
||||
"""
|
||||
)
|
||||
|
||||
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
|
||||
if not principal or not token_id:
|
||||
raise ValueError("replay identity must be non-empty")
|
||||
if any(isinstance(value, bool) or not isinstance(value, int) for value in (expires_at, now)):
|
||||
raise ValueError("replay timestamps must be integers")
|
||||
if expires_at <= now:
|
||||
return False
|
||||
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute("DELETE FROM machine_token_replay WHERE expires_at <= ?", (now,))
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO machine_token_replay (principal, token_id, expires_at) VALUES (?, ?, ?)",
|
||||
(principal, token_id, expires_at),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
connection.rollback()
|
||||
return False
|
||||
connection.commit()
|
||||
return True
|
||||
except BaseException:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(
|
||||
self._database,
|
||||
timeout=self._timeout_seconds,
|
||||
isolation_level=None,
|
||||
)
|
||||
connection.execute(f"PRAGMA busy_timeout={int(self._timeout_seconds * 1000)}")
|
||||
return connection
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Closed runtime configuration and event sink for Brain-to-Sense export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from yovision_brain.events import InternalEventCandidate
|
||||
from yovision_brain.integration.machine_identity import (
|
||||
Signer,
|
||||
TransportPolicy,
|
||||
load_private_key,
|
||||
)
|
||||
|
||||
from .client import HTTPSMachineIdentitySender
|
||||
from .mapper import canonical_json_bytes, export_event
|
||||
|
||||
|
||||
class EventExportSink:
|
||||
def __init__(
|
||||
self,
|
||||
sender: HTTPSMachineIdentitySender,
|
||||
*,
|
||||
producer_id: str,
|
||||
site_ref: str,
|
||||
severity: str,
|
||||
region_refs: Mapping[str, str],
|
||||
crossing_directions: Mapping[str, str],
|
||||
) -> None:
|
||||
self._sender = sender
|
||||
self._producer_id = producer_id
|
||||
self._site_ref = site_ref
|
||||
self._severity = severity
|
||||
self._region_refs = dict(region_refs)
|
||||
self._crossing_directions = dict(crossing_directions)
|
||||
|
||||
def write(self, candidate: InternalEventCandidate) -> None:
|
||||
event = export_event(
|
||||
candidate,
|
||||
producer_id=self._producer_id,
|
||||
site_ref=self._site_ref,
|
||||
severity=self._severity,
|
||||
region_ref=self._region_refs.get(candidate.rule_id),
|
||||
crossing_direction=self._crossing_directions.get(candidate.rule_id),
|
||||
)
|
||||
# Mapping and serialization happen before minting, so the exact bytes are
|
||||
# bound to the machine token and remain unchanged for this delivery.
|
||||
self._sender.send(canonical_json_bytes(event))
|
||||
|
||||
|
||||
def build_event_export_sink(raw: object, *, base_dir: Path) -> EventExportSink | None:
|
||||
if raw is None:
|
||||
return None
|
||||
config = _object("event_export", raw)
|
||||
_closed(
|
||||
"event_export",
|
||||
config,
|
||||
{
|
||||
"enabled", "endpoint", "producer_id", "site_ref", "severity",
|
||||
"region_refs", "crossing_directions", "machine_identity", "transport",
|
||||
},
|
||||
)
|
||||
enabled = config.get("enabled", False)
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError("event_export.enabled must be a boolean")
|
||||
if not enabled:
|
||||
if set(config) != {"enabled"}:
|
||||
raise ValueError("disabled event_export may only contain enabled")
|
||||
return None
|
||||
|
||||
identity = _object("event_export.machine_identity", config.get("machine_identity"))
|
||||
_closed(
|
||||
"event_export.machine_identity",
|
||||
identity,
|
||||
{"principal", "key_id", "private_key_path"},
|
||||
)
|
||||
transport_raw = _object("event_export.transport", config.get("transport"))
|
||||
_closed(
|
||||
"event_export.transport",
|
||||
transport_raw,
|
||||
{
|
||||
"tls_min_version", "verify_certificate", "verify_hostname",
|
||||
"connect_timeout_ms", "response_header_timeout_ms", "request_timeout_ms",
|
||||
"max_request_bytes",
|
||||
},
|
||||
)
|
||||
policy = TransportPolicy(**transport_raw) # type: ignore[arg-type]
|
||||
policy.validate()
|
||||
key_path = _string("private_key_path", identity.get("private_key_path"))
|
||||
resolved_key_path = Path(key_path)
|
||||
if not resolved_key_path.is_absolute():
|
||||
resolved_key_path = base_dir / resolved_key_path
|
||||
signer = Signer(
|
||||
_string("principal", identity.get("principal")),
|
||||
_string("key_id", identity.get("key_id")),
|
||||
load_private_key(resolved_key_path),
|
||||
)
|
||||
return EventExportSink(
|
||||
HTTPSMachineIdentitySender(
|
||||
_string("endpoint", config.get("endpoint")), signer, policy
|
||||
),
|
||||
producer_id=_string("producer_id", config.get("producer_id")),
|
||||
site_ref=_string("site_ref", config.get("site_ref")),
|
||||
severity=_string("severity", config.get("severity")),
|
||||
region_refs=_string_map("region_refs", config.get("region_refs", {})),
|
||||
crossing_directions=_string_map(
|
||||
"crossing_directions", config.get("crossing_directions", {})
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _object(name: str, value: object) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
|
||||
raise ValueError(f"{name} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _closed(name: str, value: Mapping[str, object], allowed: set[str]) -> None:
|
||||
unknown = set(value) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{name} contains unsupported fields")
|
||||
|
||||
|
||||
def _string(name: str, value: object) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"event_export.{name} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _string_map(name: str, value: object) -> Mapping[str, str]:
|
||||
mapping = _object(f"event_export.{name}", value)
|
||||
if any(not isinstance(item, str) or not item for item in mapping.values()):
|
||||
raise ValueError(f"event_export.{name} values must be non-empty strings")
|
||||
return mapping # type: ignore[return-value]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Independent service-to-service machine identity for Brain connectors."""
|
||||
|
||||
from .token import (
|
||||
ALLOWED_SKEW_SECONDS,
|
||||
MAX_KEY_OVERLAP_SECONDS,
|
||||
MAX_LIFETIME_SECONDS,
|
||||
VERSION,
|
||||
Claims,
|
||||
KeyRecord,
|
||||
MachineIdentityError,
|
||||
Registry,
|
||||
ReplayStore,
|
||||
Signer,
|
||||
Verifier,
|
||||
load_private_key,
|
||||
load_registry,
|
||||
bearer_token,
|
||||
)
|
||||
from .transport import TransportPolicy
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_SKEW_SECONDS",
|
||||
"MAX_KEY_OVERLAP_SECONDS",
|
||||
"MAX_LIFETIME_SECONDS",
|
||||
"VERSION",
|
||||
"Claims",
|
||||
"KeyRecord",
|
||||
"MachineIdentityError",
|
||||
"Registry",
|
||||
"ReplayStore",
|
||||
"Signer",
|
||||
"TransportPolicy",
|
||||
"Verifier",
|
||||
"load_private_key",
|
||||
"load_registry",
|
||||
"bearer_token",
|
||||
]
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Ed25519 request-bound machine tokens.
|
||||
|
||||
This module never accepts browser cookies, GoAdmin JWTs, query tokens, or
|
||||
shared secrets. HTTP adapters must obtain the compact token exclusively from
|
||||
the Authorization bearer header and pass the request body unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import posixpath
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Protocol
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
|
||||
VERSION = "yovision.machine-identity/v1"
|
||||
TOKEN_TYPE = "YOVISION-MACHINE+JWT"
|
||||
MAX_LIFETIME_SECONDS = 300
|
||||
ALLOWED_SKEW_SECONDS = 30
|
||||
MAX_KEY_OVERLAP_SECONDS = 24 * 60 * 60
|
||||
_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
|
||||
_AUDIENCES = frozenset({"yovision-sense", "yovision-brain", "yovision-bell"})
|
||||
_SCOPES = frozenset({"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"})
|
||||
_PRINCIPAL = re.compile(r"^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$")
|
||||
_KEY_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
||||
_TOKEN_ID = re.compile(r"^[A-Za-z0-9_-]{22,64}$")
|
||||
|
||||
|
||||
class MachineIdentityError(ValueError):
|
||||
"""A stable, non-secret authentication failure."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def bearer_token(authorization: str) -> str:
|
||||
"""Extract only an Authorization bearer token; there is no cookie/query fallback."""
|
||||
parts = authorization.split(" ")
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1] or any(character in parts[1] for character in " \t\r\n,"):
|
||||
raise MachineIdentityError("machine_token_missing")
|
||||
return parts[1]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Claims:
|
||||
ver: str
|
||||
iss: str
|
||||
sub: str
|
||||
aud: str
|
||||
scope: tuple[str, ...]
|
||||
iat: int
|
||||
nbf: int
|
||||
exp: int
|
||||
jti: str
|
||||
htm: str
|
||||
htu: str
|
||||
body_sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KeyRecord:
|
||||
principal: str
|
||||
key_id: str
|
||||
public_key: Ed25519PublicKey
|
||||
audience: str
|
||||
scopes: frozenset[str]
|
||||
enabled: bool = True
|
||||
revoked: bool = False
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(self, records: Iterable[KeyRecord]) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._records: dict[str, KeyRecord] = {}
|
||||
for record in records:
|
||||
if not _KEY_ID.fullmatch(record.key_id) or not _PRINCIPAL.fullmatch(record.principal) or record.audience not in _AUDIENCES or not _valid_scopes(record.scopes):
|
||||
raise ValueError("invalid machine key record")
|
||||
if record.key_id in self._records:
|
||||
raise ValueError("duplicate machine key id")
|
||||
self._records[record.key_id] = record
|
||||
|
||||
def lookup(self, key_id: str) -> KeyRecord | None:
|
||||
with self._lock:
|
||||
return self._records.get(key_id)
|
||||
|
||||
def revoke(self, key_id: str) -> bool:
|
||||
with self._lock:
|
||||
record = self._records.get(key_id)
|
||||
if record is None:
|
||||
return False
|
||||
self._records[key_id] = KeyRecord(
|
||||
principal=record.principal,
|
||||
key_id=record.key_id,
|
||||
public_key=record.public_key,
|
||||
audience=record.audience,
|
||||
scopes=record.scopes,
|
||||
enabled=record.enabled,
|
||||
revoked=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
class ReplayStore:
|
||||
"""Process-local replay cache for tests or one uninterrupted process."""
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._used: dict[tuple[str, str], int] = {}
|
||||
|
||||
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
|
||||
with self._lock:
|
||||
self._used = {key: expiry for key, expiry in self._used.items() if expiry > now}
|
||||
key = (principal, token_id)
|
||||
if key in self._used:
|
||||
return False
|
||||
self._used[key] = expires_at
|
||||
return True
|
||||
|
||||
|
||||
class ReplayCache(Protocol):
|
||||
"""Connector implementations provide an atomic durable implementation."""
|
||||
|
||||
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool: ...
|
||||
|
||||
|
||||
def load_private_key(path: str | Path) -> Ed25519PrivateKey:
|
||||
if not str(path).strip():
|
||||
raise ValueError("machine private key path is required")
|
||||
try:
|
||||
raw = Path(path).read_bytes()
|
||||
key = serialization.load_pem_private_key(raw, password=None)
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
raise ValueError("invalid machine private key file") from exc
|
||||
if not isinstance(key, Ed25519PrivateKey):
|
||||
raise ValueError("machine private key is not Ed25519")
|
||||
return key
|
||||
|
||||
|
||||
def load_registry(path: str | Path, expected_audience: str) -> Registry:
|
||||
if not str(path).strip() or expected_audience not in _AUDIENCES:
|
||||
raise ValueError("machine principal registry path and audience are required")
|
||||
try:
|
||||
document = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("invalid machine principal registry") from exc
|
||||
if not isinstance(document, dict) or set(document) != {"version", "audience", "principals"} or document["version"] != "yovision.machine-principal-registry/v1" or document["audience"] != expected_audience or not isinstance(document["principals"], list) or not document["principals"]:
|
||||
raise ValueError("invalid machine principal registry")
|
||||
records: list[KeyRecord] = []
|
||||
try:
|
||||
for principal in document["principals"]:
|
||||
if not isinstance(principal, dict) or set(principal) != {"principal_id", "enabled", "keys"} or not isinstance(principal["enabled"], bool) or not isinstance(principal["keys"], list) or not principal["keys"]:
|
||||
raise ValueError
|
||||
for key in principal["keys"]:
|
||||
if not isinstance(key, dict) or set(key) != {"kid", "public_key_base64url", "status", "scopes"} or key["status"] not in {"active", "revoked"} or not isinstance(key["scopes"], list):
|
||||
raise ValueError
|
||||
public_key = Ed25519PublicKey.from_public_bytes(_b64decode(key["public_key_base64url"]))
|
||||
records.append(KeyRecord(principal["principal_id"], key["kid"], public_key, expected_audience, frozenset(key["scopes"]), principal["enabled"], key["status"] == "revoked"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise ValueError("invalid machine principal registry") from None
|
||||
return Registry(records)
|
||||
|
||||
|
||||
class Signer:
|
||||
def __init__(
|
||||
self,
|
||||
principal: str,
|
||||
key_id: str,
|
||||
private_key: Ed25519PrivateKey,
|
||||
*,
|
||||
clock: Callable[[], int] | None = None,
|
||||
) -> None:
|
||||
if not _PRINCIPAL.fullmatch(principal) or not _KEY_ID.fullmatch(key_id) or not isinstance(private_key, Ed25519PrivateKey):
|
||||
raise ValueError("incomplete machine signer configuration")
|
||||
self._principal = principal
|
||||
self._key_id = key_id
|
||||
self._private_key = private_key
|
||||
self._clock = clock or (lambda: int(time.time()))
|
||||
|
||||
def mint(self, audience: str, scopes: Iterable[str], method: str, request_path: str, body: bytes) -> str:
|
||||
normalized_path = _normalize_path(request_path)
|
||||
normalized_method = method.upper()
|
||||
scope_values = tuple(scopes)
|
||||
if audience not in _AUDIENCES or not _valid_scopes(scope_values) or normalized_method not in _METHODS:
|
||||
raise ValueError("invalid machine token request")
|
||||
now = int(self._clock())
|
||||
header = {"alg": "EdDSA", "typ": TOKEN_TYPE, "kid": self._key_id, "ver": VERSION}
|
||||
claims = {
|
||||
"ver": VERSION,
|
||||
"iss": self._principal,
|
||||
"sub": self._principal,
|
||||
"aud": audience,
|
||||
"scope": list(scope_values),
|
||||
"iat": now,
|
||||
"nbf": now,
|
||||
"exp": now + MAX_LIFETIME_SECONDS,
|
||||
"jti": secrets.token_urlsafe(16),
|
||||
"htm": normalized_method,
|
||||
"htu": normalized_path,
|
||||
"body_sha256": hashlib.sha256(body).hexdigest(),
|
||||
}
|
||||
encoded_header = _encode_json(header)
|
||||
encoded_claims = _encode_json(claims)
|
||||
signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii")
|
||||
signature = self._private_key.sign(signing_input)
|
||||
return f"{encoded_header}.{encoded_claims}.{_b64encode(signature)}"
|
||||
|
||||
|
||||
class Verifier:
|
||||
def __init__(
|
||||
self,
|
||||
registry: Registry,
|
||||
replay_store: ReplayCache,
|
||||
*,
|
||||
clock: Callable[[], int] | None = None,
|
||||
) -> None:
|
||||
self._registry = registry
|
||||
self._replay_store = replay_store
|
||||
self._clock = clock or (lambda: int(time.time()))
|
||||
|
||||
def verify(
|
||||
self,
|
||||
token: str,
|
||||
audience: str,
|
||||
required_scope: str,
|
||||
method: str,
|
||||
request_path: str,
|
||||
body: bytes,
|
||||
) -> Claims:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3 or "=" in token:
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
header = _decode_object(parts[0], {"alg", "typ", "kid", "ver"})
|
||||
if header.get("alg") != "EdDSA" or header.get("typ") != TOKEN_TYPE or header.get("ver") != VERSION or not isinstance(header.get("kid"), str) or not _KEY_ID.fullmatch(header["kid"]):
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
record = self._registry.lookup(header["kid"])
|
||||
if record is None:
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
try:
|
||||
record.public_key.verify(_b64decode(parts[2]), f"{parts[0]}.{parts[1]}".encode("ascii"))
|
||||
except (InvalidSignature, ValueError):
|
||||
raise MachineIdentityError("machine_token_invalid") from None
|
||||
if not record.enabled or record.revoked:
|
||||
raise MachineIdentityError("machine_identity_revoked")
|
||||
|
||||
raw = _decode_object(parts[1], {"ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"})
|
||||
claims = _claims_from_object(raw)
|
||||
if claims.iss != record.principal or claims.sub != record.principal:
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
now = int(self._clock())
|
||||
if claims.exp - claims.iat <= 0 or claims.exp - claims.iat > MAX_LIFETIME_SECONDS or claims.nbf < claims.iat or claims.nbf > claims.exp or claims.iat > now + ALLOWED_SKEW_SECONDS:
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
if claims.nbf > now + ALLOWED_SKEW_SECONDS or claims.exp < now - ALLOWED_SKEW_SECONDS:
|
||||
raise MachineIdentityError("machine_token_expired")
|
||||
if claims.aud != audience or record.audience != audience:
|
||||
raise MachineIdentityError("machine_audience_denied")
|
||||
if required_scope not in claims.scope or required_scope not in record.scopes:
|
||||
raise MachineIdentityError("machine_scope_denied")
|
||||
if claims.htm != method.upper() or claims.htu != _normalize_path(request_path) or claims.body_sha256 != hashlib.sha256(body).hexdigest():
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
if not self._replay_store.consume(claims.iss, claims.jti, claims.exp + ALLOWED_SKEW_SECONDS, now):
|
||||
raise MachineIdentityError("machine_token_replayed")
|
||||
return claims
|
||||
|
||||
|
||||
def _claims_from_object(value: dict[str, object]) -> Claims:
|
||||
try:
|
||||
scope = value["scope"]
|
||||
if not isinstance(scope, list) or not _valid_scopes(scope):
|
||||
raise ValueError
|
||||
integer_fields = ("iat", "nbf", "exp")
|
||||
if any(not isinstance(value[field], int) or isinstance(value[field], bool) for field in integer_fields):
|
||||
raise ValueError
|
||||
string_fields = ("ver", "iss", "sub", "aud", "jti", "htm", "htu", "body_sha256")
|
||||
if any(not isinstance(value[field], str) for field in string_fields):
|
||||
raise ValueError
|
||||
claims = Claims(scope=tuple(scope), **{key: value[key] for key in string_fields + integer_fields})
|
||||
if claims.ver != VERSION or not _PRINCIPAL.fullmatch(claims.iss) or claims.iss != claims.sub or claims.aud not in _AUDIENCES or not _TOKEN_ID.fullmatch(claims.jti) or claims.htm not in _METHODS or len(claims.body_sha256) != 64:
|
||||
raise ValueError
|
||||
bytes.fromhex(claims.body_sha256)
|
||||
_normalize_path(claims.htu)
|
||||
return claims
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise MachineIdentityError("machine_token_invalid") from None
|
||||
|
||||
|
||||
def _normalize_path(value: str) -> str:
|
||||
split = urlsplit(value)
|
||||
if not value.startswith("/") or split.scheme or split.netloc or split.query or split.fragment or "\\" in split.path or "//" in split.path or posixpath.normpath(split.path) != split.path:
|
||||
raise ValueError("machine request path must be normalized and contain no query or fragment")
|
||||
return split.path
|
||||
|
||||
|
||||
def _valid_scopes(scopes: Iterable[str]) -> bool:
|
||||
values = tuple(scopes)
|
||||
return 1 <= len(values) <= 4 and len(set(values)) == len(values) and all(scope in _SCOPES for scope in values)
|
||||
|
||||
|
||||
def _encode_json(value: dict[str, object]) -> str:
|
||||
return _b64encode(json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
||||
|
||||
|
||||
def _decode_object(value: str, expected_keys: set[str]) -> dict[str, object]:
|
||||
try:
|
||||
decoded = json.loads(_b64decode(value).decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
|
||||
raise MachineIdentityError("machine_token_invalid") from None
|
||||
if not isinstance(decoded, dict) or set(decoded) != expected_keys:
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
return decoded
|
||||
|
||||
|
||||
def _b64encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(value: str) -> bytes:
|
||||
if not value or "=" in value:
|
||||
raise ValueError("invalid base64url")
|
||||
decoded = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
||||
if _b64encode(decoded) != value:
|
||||
raise ValueError("non-canonical base64url")
|
||||
return decoded
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Fail-closed HTTPS transport policy for Brain connectors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransportPolicy:
|
||||
tls_min_version: str
|
||||
verify_certificate: bool
|
||||
verify_hostname: bool
|
||||
connect_timeout_ms: int
|
||||
response_header_timeout_ms: int
|
||||
request_timeout_ms: int
|
||||
max_request_bytes: int
|
||||
|
||||
def validate(self) -> None:
|
||||
if (
|
||||
self.tls_min_version not in {"1.2", "1.3"}
|
||||
or not self.verify_certificate
|
||||
or not self.verify_hostname
|
||||
or not 100 <= self.connect_timeout_ms <= 30_000
|
||||
or not 100 <= self.response_header_timeout_ms <= 30_000
|
||||
or not 100 <= self.request_timeout_ms <= 60_000
|
||||
or not 1 <= self.max_request_bytes <= 10 * 1024 * 1024
|
||||
):
|
||||
raise ValueError("machine transport policy is unsafe")
|
||||
|
||||
def ssl_context(self) -> ssl.SSLContext:
|
||||
self.validate()
|
||||
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_3 if self.tls_min_version == "1.3" else ssl.TLSVersion.TLSv1_2
|
||||
context.check_hostname = True
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
return context
|
||||
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
import yovision_brain.app.__main__ as cli
|
||||
import yovision_brain.integration.event_export.runtime as export_runtime
|
||||
from yovision_brain.integration.event_export import (
|
||||
EventDeliveryError,
|
||||
HTTPSMachineIdentitySender,
|
||||
)
|
||||
from yovision_brain.integration.machine_identity import (
|
||||
KeyRecord,
|
||||
Registry,
|
||||
ReplayStore,
|
||||
Signer,
|
||||
TransportPolicy,
|
||||
Verifier,
|
||||
bearer_token,
|
||||
)
|
||||
|
||||
FIXTURE = Path(__file__).parents[1] / "fixtures" / "events" / "area.json"
|
||||
|
||||
|
||||
def _policy() -> TransportPolicy:
|
||||
return TransportPolicy(
|
||||
tls_min_version="1.2",
|
||||
verify_certificate=True,
|
||||
verify_hostname=True,
|
||||
connect_timeout_ms=1_000,
|
||||
response_header_timeout_ms=1_000,
|
||||
request_timeout_ms=2_000,
|
||||
max_request_bytes=64 * 1024,
|
||||
)
|
||||
|
||||
|
||||
class _Response:
|
||||
status = 202
|
||||
|
||||
def __init__(self, body: bytes, request_id: str) -> None:
|
||||
self.body = body
|
||||
self.headers = {"X-Request-ID": request_id}
|
||||
self.closed = False
|
||||
|
||||
def read(self, amount: int = -1) -> bytes:
|
||||
return self.body[:amount] if amount >= 0 else self.body
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _VerifyingOpener:
|
||||
def __init__(self, verifier: Verifier) -> None:
|
||||
self.verifier = verifier
|
||||
self.requests = []
|
||||
|
||||
def open(self, request, timeout: float) -> _Response: # noqa: ANN001
|
||||
self.requests.append((request, timeout))
|
||||
body = request.data
|
||||
self.verifier.verify(
|
||||
bearer_token(request.get_header("Authorization")),
|
||||
"yovision-sense",
|
||||
"events:ingest",
|
||||
request.method,
|
||||
"/v1/events",
|
||||
body,
|
||||
)
|
||||
event = json.loads(body)
|
||||
return _Response(json.dumps({
|
||||
"producer_id": event["producer_id"],
|
||||
"source_event_id": event["source_event_id"],
|
||||
"payload_sha256": hashlib.sha256(body).hexdigest(),
|
||||
"disposition": "accepted",
|
||||
}).encode(), request.get_header("X-request-id"))
|
||||
|
||||
|
||||
def test_sender_binds_exact_path_body_and_safe_request_id() -> None:
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
signer = Signer("yv:brain:school-a", "brain-key-0001", private_key, clock=lambda: 100)
|
||||
registry = Registry(
|
||||
[
|
||||
KeyRecord(
|
||||
principal="yv:brain:school-a",
|
||||
key_id="brain-key-0001",
|
||||
public_key=private_key.public_key(),
|
||||
audience="yovision-sense",
|
||||
scopes=frozenset({"events:ingest"}),
|
||||
)
|
||||
]
|
||||
)
|
||||
opener = _VerifyingOpener(Verifier(registry, ReplayStore(), clock=lambda: 100))
|
||||
sender = HTTPSMachineIdentitySender(
|
||||
"https://sense.example:8443", signer, _policy(), opener=opener
|
||||
)
|
||||
|
||||
result = sender.send(
|
||||
b'{"producer_id":"brain-school-a","schema_version":"yovision.event/v1",'
|
||||
b'"source_event_id":"evt-1"}'
|
||||
)
|
||||
|
||||
request, timeout = opener.requests[0]
|
||||
assert request.full_url == "https://sense.example:8443/v1/events"
|
||||
assert request.method == "POST"
|
||||
assert request.get_header("Content-type") == "application/json"
|
||||
assert re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{15,127}", result.request_id)
|
||||
assert request.get_header("X-request-id") == result.request_id
|
||||
assert (result.disposition, timeout) == ("accepted", 2.0)
|
||||
|
||||
|
||||
def test_sender_rejects_plaintext_and_marks_conflict_terminal() -> None:
|
||||
key = Ed25519PrivateKey.generate()
|
||||
signer = Signer("yv:brain:school-a", "brain-key-0001", key)
|
||||
with pytest.raises(ValueError, match="HTTPS origin"):
|
||||
HTTPSMachineIdentitySender("http://sense.example", signer, _policy())
|
||||
|
||||
class ConflictOpener:
|
||||
def open(self, request, timeout: float): # noqa: ANN001, ARG002
|
||||
raise urllib.error.HTTPError(
|
||||
request.full_url,
|
||||
409,
|
||||
"Conflict",
|
||||
{},
|
||||
io.BytesIO(b'{"code":"event_identity_conflict"}'),
|
||||
)
|
||||
|
||||
sender = HTTPSMachineIdentitySender(
|
||||
"https://sense.example", signer, _policy(), opener=ConflictOpener()
|
||||
)
|
||||
with pytest.raises(EventDeliveryError) as caught:
|
||||
sender.send(b"{}")
|
||||
assert (caught.value.code, caught.value.terminal) == (
|
||||
"event_identity_conflict",
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_connector_keeps_existing_json_lines_output(tmp_path: Path) -> None:
|
||||
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
raw["event_export"] = {"enabled": False}
|
||||
config = tmp_path / "brain.json"
|
||||
config.write_text(json.dumps(raw), encoding="utf-8")
|
||||
output = tmp_path / "events.jsonl"
|
||||
|
||||
assert cli.main(["--config", str(config), "--output", str(output)]) == 0
|
||||
assert json.loads(output.read_text(encoding="utf-8"))["schema"] == (
|
||||
"brain.internal.event-candidate/v1"
|
||||
)
|
||||
|
||||
|
||||
def test_delivery_failure_is_nonzero_and_not_silently_reported_as_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
class FailingSink:
|
||||
def write(self, candidate) -> None: # noqa: ANN001, ARG002
|
||||
raise EventDeliveryError("event_delivery_unavailable", terminal=False)
|
||||
|
||||
monkeypatch.setattr(cli, "build_event_export_sink", lambda raw, base_dir: FailingSink())
|
||||
unused_output = tmp_path / "disabled-json-lines-target"
|
||||
unused_output.write_text("must remain unchanged", encoding="utf-8")
|
||||
result = cli.main(
|
||||
["--config", str(FIXTURE), "--output", str(unused_output)]
|
||||
)
|
||||
|
||||
assert result == 3
|
||||
error = json.loads(capsys.readouterr().err.splitlines()[0])
|
||||
assert error == {"status": "error", "message": "event_delivery_unavailable"}
|
||||
assert unused_output.read_text(encoding="utf-8") == "must remain unchanged"
|
||||
|
||||
|
||||
def test_inline_private_key_material_is_rejected_without_echo(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
marker = "INLINE-PRIVATE-MATERIAL-MUST-NOT-LEAK"
|
||||
raw["event_export"] = {
|
||||
"enabled": True,
|
||||
"endpoint": "https://sense.example",
|
||||
"producer_id": "brain-school-a",
|
||||
"site_ref": "site-school-a",
|
||||
"severity": "high",
|
||||
"region_refs": {},
|
||||
"crossing_directions": {},
|
||||
"machine_identity": {
|
||||
"principal": "yv:brain:school-a",
|
||||
"key_id": "brain-key-0001",
|
||||
"private_key_path": "external.pem",
|
||||
"private_key": marker,
|
||||
},
|
||||
"transport": {},
|
||||
}
|
||||
config = tmp_path / "brain.json"
|
||||
config.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
assert cli.main(["--config", str(config)]) == 3
|
||||
assert marker not in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_enabled_config_loads_machine_key_only_from_external_path(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
key_path = tmp_path / "brain-machine.pem"
|
||||
key_path.write_bytes(
|
||||
Ed25519PrivateKey.generate().private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
captured = {}
|
||||
|
||||
class Sender:
|
||||
def __init__(self, endpoint, signer, policy) -> None: # noqa: ANN001
|
||||
captured.update(endpoint=endpoint, signer=signer, policy=policy)
|
||||
|
||||
monkeypatch.setattr(export_runtime, "HTTPSMachineIdentitySender", Sender)
|
||||
sink = export_runtime.build_event_export_sink(
|
||||
{
|
||||
"enabled": True,
|
||||
"endpoint": "https://sense.example",
|
||||
"producer_id": "brain-school-a",
|
||||
"site_ref": "site-school-a",
|
||||
"severity": "high",
|
||||
"region_refs": {},
|
||||
"crossing_directions": {},
|
||||
"machine_identity": {
|
||||
"principal": "yv:brain:school-a",
|
||||
"key_id": "brain-key-0001",
|
||||
"private_key_path": key_path.name,
|
||||
},
|
||||
"transport": {
|
||||
"tls_min_version": "1.2",
|
||||
"verify_certificate": True,
|
||||
"verify_hostname": True,
|
||||
"connect_timeout_ms": 1_000,
|
||||
"response_header_timeout_ms": 1_000,
|
||||
"request_timeout_ms": 2_000,
|
||||
"max_request_bytes": 64 * 1024,
|
||||
},
|
||||
},
|
||||
base_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert sink is not None
|
||||
assert captured["endpoint"] == "https://sense.example"
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from yovision_brain.events import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
|
||||
from yovision_brain.integration.event_export import EventExportError, canonical_json, export_event, payload_sha256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _candidate(**changes: object) -> InternalEventCandidate:
|
||||
values: dict[str, object] = {
|
||||
"schema": INTERNAL_EVENT_SCHEMA,
|
||||
"event_id": "evt-area-20260831-0001",
|
||||
"logical_input_id": "camera-east-gate",
|
||||
"event_type": "danger_area_entered",
|
||||
"occurred_at_ns": 1_788_134_401_125_000_000,
|
||||
"rule_id": "rule-east-danger",
|
||||
"rule_version": "3",
|
||||
"model_name": "anonymous-detector",
|
||||
"model_version": "2026.08",
|
||||
"profile_id": "profile-main-stream",
|
||||
"frame_width": 100,
|
||||
"frame_height": 100,
|
||||
"track_id": "track-0042",
|
||||
"observation": {
|
||||
"category": "anonymous_target",
|
||||
"confidence": 0.93,
|
||||
"box": {"left": 12, "top": 20, "right": 31, "bottom": 74},
|
||||
"anchor": {"x": 0.21, "y": 0.74},
|
||||
},
|
||||
"reason": "entered polygon",
|
||||
}
|
||||
values.update(changes)
|
||||
return InternalEventCandidate(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _fixture(relative: str) -> dict[str, object]:
|
||||
return json.loads((ROOT / relative).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_brain_mapper_matches_frozen_producer_fixture_and_jcs_digest() -> None:
|
||||
pending = _fixture("contracts/evidence/v1/examples/pending.json")
|
||||
expected = _fixture("contracts/events/v1/examples/dangerous-area.json")
|
||||
event = export_event(
|
||||
_candidate(),
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="high",
|
||||
evidence=[pending],
|
||||
region_ref="region-east-danger",
|
||||
)
|
||||
|
||||
assert event == expected
|
||||
assert payload_sha256(event) == "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"
|
||||
|
||||
|
||||
def test_retry_mapping_preserves_original_identity_and_payload() -> None:
|
||||
candidate = _candidate()
|
||||
arguments = {
|
||||
"producer_id": "brain-school-a",
|
||||
"site_ref": "site-school-a",
|
||||
"severity": "high",
|
||||
"evidence": [_fixture("contracts/evidence/v1/examples/pending.json")],
|
||||
"region_ref": "region-east-danger",
|
||||
}
|
||||
first = export_event(candidate, **arguments)
|
||||
retry = export_event(candidate, **arguments)
|
||||
|
||||
assert (first["producer_id"], first["source_event_id"]) == (
|
||||
"brain-school-a",
|
||||
candidate.event_id,
|
||||
)
|
||||
assert retry == first
|
||||
assert payload_sha256(retry) == payload_sha256(first)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", ["pending.json", "failed.json"])
|
||||
def test_evidence_degradation_states_are_exported_unchanged(fixture: str) -> None:
|
||||
evidence = _fixture(f"contracts/evidence/v1/examples/{fixture}")
|
||||
candidate = _candidate()
|
||||
if fixture == "failed.json":
|
||||
candidate = _candidate(
|
||||
event_id="evt-line-20260831-0002",
|
||||
logical_input_id="camera-north-corridor",
|
||||
event_type="directional_line_crossed",
|
||||
occurred_at_ns=1_788_134_590_000_000_000,
|
||||
rule_id="rule-north-one-way",
|
||||
rule_version="1",
|
||||
track_id="track-0088",
|
||||
observation={"category": "anonymous_target", "confidence": 0.88},
|
||||
)
|
||||
event = export_event(
|
||||
candidate,
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="medium" if fixture == "failed.json" else "high",
|
||||
evidence=[evidence],
|
||||
region_ref="line-north-one-way" if fixture == "failed.json" else "region-east-danger",
|
||||
crossing_direction="b_to_a" if fixture == "failed.json" else None,
|
||||
)
|
||||
|
||||
assert event["evidence"] == [evidence]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe",
|
||||
[
|
||||
{"local_path": "D:/captures/frame.jpg"},
|
||||
{"url": "https://example.invalid/signed"},
|
||||
{"token": "not-a-real-token"},
|
||||
],
|
||||
)
|
||||
def test_evidence_rejects_paths_urls_and_sensitive_fields(unsafe: dict[str, object]) -> None:
|
||||
evidence = _fixture("contracts/evidence/v1/examples/pending.json")
|
||||
evidence.update(unsafe)
|
||||
with pytest.raises(EventExportError, match="forbidden"):
|
||||
export_event(
|
||||
_candidate(),
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="high",
|
||||
evidence=[evidence],
|
||||
region_ref="region-east-danger",
|
||||
)
|
||||
|
||||
|
||||
def test_failed_evidence_cannot_claim_success_content() -> None:
|
||||
evidence = _fixture("contracts/evidence/v1/examples/failed.json")
|
||||
evidence["content_type"] = "video/mp4"
|
||||
with pytest.raises(EventExportError, match="failed evidence"):
|
||||
export_event(
|
||||
_candidate(),
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="high",
|
||||
evidence=[evidence],
|
||||
region_ref="region-east-danger",
|
||||
)
|
||||
|
||||
|
||||
def test_jcs_normalizes_number_lexemes_and_negative_zero() -> None:
|
||||
assert canonical_json({"small": 1e-7, "fixed": 1e20, "zero": -0.0}) == (
|
||||
'{"fixed":100000000000000000000,"small":1e-7,"zero":0}'
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
import pytest
|
||||
|
||||
from yovision_brain.integration.event_export import SQLiteReplayCache
|
||||
from yovision_brain.integration.machine_identity import (
|
||||
KeyRecord,
|
||||
MachineIdentityError,
|
||||
Registry,
|
||||
Signer,
|
||||
Verifier,
|
||||
)
|
||||
|
||||
|
||||
def test_replay_cache_rejects_same_jti_after_connector_restart(tmp_path) -> None:
|
||||
now = 1_800_000_000
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
registry = Registry(
|
||||
[
|
||||
KeyRecord(
|
||||
principal="yv:brain:node-a",
|
||||
key_id="brain-key-0001",
|
||||
public_key=private_key.public_key(),
|
||||
audience="yovision-sense",
|
||||
scopes=frozenset({"events:ingest"}),
|
||||
)
|
||||
]
|
||||
)
|
||||
token = Signer(
|
||||
"yv:brain:node-a",
|
||||
"brain-key-0001",
|
||||
private_key,
|
||||
clock=lambda: now,
|
||||
).mint("yovision-sense", ["events:ingest"], "POST", "/v1/events", b"{}")
|
||||
database = tmp_path / "machine-replay.sqlite3"
|
||||
|
||||
first_process = Verifier(registry, SQLiteReplayCache(database), clock=lambda: now)
|
||||
claims = first_process.verify(
|
||||
token, "yovision-sense", "events:ingest", "POST", "/v1/events", b"{}"
|
||||
)
|
||||
assert claims.iss == "yv:brain:node-a"
|
||||
|
||||
restarted_process = Verifier(registry, SQLiteReplayCache(database), clock=lambda: now)
|
||||
with pytest.raises(MachineIdentityError, match="machine_token_replayed") as caught:
|
||||
restarted_process.verify(
|
||||
token, "yovision-sense", "events:ingest", "POST", "/v1/events", b"{}"
|
||||
)
|
||||
assert caught.value.code == "machine_token_replayed"
|
||||
|
||||
|
||||
def test_replay_cache_atomically_reuses_expired_identity(tmp_path) -> None:
|
||||
cache = SQLiteReplayCache(tmp_path / "machine-replay.sqlite3")
|
||||
assert cache.consume("yv:brain:node-a", "jti-one", expires_at=110, now=100)
|
||||
assert not cache.consume("yv:brain:node-a", "jti-one", expires_at=110, now=101)
|
||||
assert cache.consume("yv:brain:node-a", "jti-one", expires_at=130, now=110)
|
||||
@@ -0,0 +1,187 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
)
|
||||
|
||||
type HTTPDoer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type Client struct {
|
||||
Endpoint string
|
||||
RelayID string
|
||||
Signer machine_identity.Signer
|
||||
HTTP HTTPDoer
|
||||
Enabled bool
|
||||
MaxRequestBytes int64
|
||||
}
|
||||
|
||||
func NewClient(endpoint, relayID string, signer machine_identity.Signer, policy machine_identity.TransportPolicy) (*Client, error) {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("Bell connector endpoint must be an HTTPS origin")
|
||||
}
|
||||
httpClient, err := policy.HTTPClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{Endpoint: strings.TrimRight(endpoint, "/"), RelayID: relayID, Signer: signer, HTTP: httpClient, Enabled: true, MaxRequestBytes: policy.MaxRequestBytes}, nil
|
||||
}
|
||||
|
||||
func (c Client) Send(ctx context.Context, body []byte) (IngestResult, error) {
|
||||
if !c.Enabled {
|
||||
return IngestResult{}, &DeliveryError{Code: "connector_disabled", Detail: "Bell connector is disabled", Terminal: true}
|
||||
}
|
||||
if c.HTTP == nil || !json.Valid(body) || (strings.TrimSpace(c.RelayID) != "" && !safeIdentifier(c.RelayID)) {
|
||||
return IngestResult{}, errors.New("Bell connector is not configured")
|
||||
}
|
||||
maximum := c.MaxRequestBytes
|
||||
if maximum == 0 {
|
||||
maximum = MaxInboundBytes
|
||||
}
|
||||
if maximum < 1 || int64(len(body)) > maximum {
|
||||
return IngestResult{}, &DeliveryError{Code: "event_request_too_large", Detail: "event exceeds the configured request limit", Terminal: true}
|
||||
}
|
||||
requestID, err := newRequestID()
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("generate request correlation id: %w", err)
|
||||
}
|
||||
token, err := c.Signer.Mint("yovision-bell", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("mint Bell machine token: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.Endpoint, "/")+"/v1/events", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return IngestResult{}, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Request-ID", requestID)
|
||||
if strings.TrimSpace(c.RelayID) != "" {
|
||||
request.Header.Set("X-YoVision-Relay-ID", c.RelayID)
|
||||
}
|
||||
response, err := c.HTTP.Do(request)
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("deliver event to Bell: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
|
||||
if err != nil || len(responseBody) > 64*1024 {
|
||||
return IngestResult{}, errors.New("Bell response is invalid")
|
||||
}
|
||||
if response.StatusCode == http.StatusCreated || response.StatusCode == http.StatusOK {
|
||||
if response.Header.Get("X-Request-ID") != requestID {
|
||||
return IngestResult{}, errors.New("Bell response request id is invalid")
|
||||
}
|
||||
var result IngestResult
|
||||
if json.Unmarshal(responseBody, &result) != nil || result.EventID == "" || result.PayloadSHA256 == "" || (result.Disposition != "created" && result.Disposition != "duplicate") {
|
||||
return IngestResult{}, errors.New("Bell response is invalid")
|
||||
}
|
||||
identity, parseErr := parseEventIdentity(body)
|
||||
if parseErr != nil || result.ProducerID != identity.ProducerID || result.SourceEventID != identity.SourceEventID {
|
||||
return IngestResult{}, errors.New("Bell response changed event identity")
|
||||
}
|
||||
canonical, canonicalErr := canonicalPayload(body)
|
||||
if canonicalErr != nil {
|
||||
return IngestResult{}, errors.New("delivered event cannot be canonicalized")
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
if result.PayloadSHA256 != hex.EncodeToString(digest[:]) {
|
||||
return IngestResult{}, errors.New("Bell response payload digest does not match the delivered event")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
var problem Problem
|
||||
_ = json.Unmarshal(responseBody, &problem)
|
||||
terminal := response.StatusCode >= 400 && response.StatusCode < 500 && response.StatusCode != http.StatusTooManyRequests
|
||||
if problem.Code == "" {
|
||||
problem.Code = "bell_unavailable"
|
||||
}
|
||||
return IngestResult{}, &DeliveryError{Code: problem.Code, Detail: problem.Message, Terminal: terminal}
|
||||
}
|
||||
|
||||
func newRequestID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := base64.RawURLEncoding.EncodeToString(raw)
|
||||
if !requestIDPattern.MatchString(value) {
|
||||
return "", errors.New("generated request id is invalid")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func canonicalPayload(raw []byte) ([]byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := normalizeJCSNumbers(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func normalizeJCSNumbers(value any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
number, err := strconv.ParseFloat(string(typed), 64)
|
||||
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
return nil, errors.New("JSON number is outside the RFC 8785 domain")
|
||||
}
|
||||
if number == 0 {
|
||||
return float64(0), nil
|
||||
}
|
||||
return number, nil
|
||||
case []any:
|
||||
for index, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[index] = normalized
|
||||
}
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[key] = normalized
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
)
|
||||
|
||||
const MaxInboundBytes = 64 * 1024
|
||||
|
||||
var inboundRequestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type IngressHandler struct {
|
||||
DB *gorm.DB
|
||||
Verifier machine_identity.Verifier
|
||||
EvidenceOwnerID string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (h IngressHandler) Post(c *gin.Context) {
|
||||
if !prepareMachineRequest(c, "/v1/events") {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, MaxInboundBytes))
|
||||
if err != nil {
|
||||
problem(c, http.StatusBadRequest, "invalid_event", "event payload is invalid or too large")
|
||||
return
|
||||
}
|
||||
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
problem(c, http.StatusUnauthorized, machineCode(err), "machine identity was rejected")
|
||||
return
|
||||
}
|
||||
if _, err = h.Verifier.Verify(token, "yovision-sense", "events:ingest", c.Request.Method, c.Request.URL.EscapedPath(), body); err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
if code := machineCode(err); code == "machine_scope_denied" || code == "machine_audience_denied" {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
problem(c, status, machineCode(err), "machine identity was rejected")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if h.Now != nil {
|
||||
now = h.Now().UTC()
|
||||
}
|
||||
result, err := AcceptEvent(c.Request.Context(), h.DB, body, h.EvidenceOwnerID, now)
|
||||
if errors.Is(err, ErrInboundConflict) {
|
||||
problem(c, http.StatusConflict, "idempotency_conflict", "event identity is bound to another payload")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
problem(c, http.StatusBadRequest, "invalid_event", "event payload was rejected")
|
||||
return
|
||||
}
|
||||
status := http.StatusAccepted
|
||||
if result.Disposition == "duplicate" {
|
||||
status = http.StatusOK
|
||||
}
|
||||
c.JSON(status, result)
|
||||
}
|
||||
|
||||
type EvidenceHandler struct {
|
||||
DB *gorm.DB
|
||||
Verifier machine_identity.Verifier
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (h EvidenceHandler) Get(c *gin.Context) {
|
||||
path := c.Request.URL.EscapedPath()
|
||||
if !prepareMachineRequest(c, path) || !safeIdentifier(c.Param("evidence_id")) {
|
||||
if !c.Writer.Written() {
|
||||
problem(c, http.StatusBadRequest, "evidence_not_found", "evidence reference is invalid")
|
||||
}
|
||||
return
|
||||
}
|
||||
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
problem(c, http.StatusUnauthorized, machineCode(err), "machine identity was rejected")
|
||||
return
|
||||
}
|
||||
if _, err = h.Verifier.Verify(token, "yovision-sense", "evidence:read", c.Request.Method, path, nil); err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
if code := machineCode(err); code == "machine_scope_denied" || code == "machine_audience_denied" {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
problem(c, status, machineCode(err), "machine identity was rejected")
|
||||
return
|
||||
}
|
||||
var record EvidenceRecord
|
||||
if err = h.DB.WithContext(c.Request.Context()).First(&record, "evidence_id = ?", c.Param("evidence_id")).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
problem(c, http.StatusNotFound, "evidence_not_found", "evidence reference is unknown")
|
||||
return
|
||||
}
|
||||
problem(c, http.StatusServiceUnavailable, "evidence_unavailable", "evidence metadata is temporarily unavailable")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if h.Now != nil {
|
||||
now = h.Now().UTC()
|
||||
}
|
||||
if record.ExpiresAt != nil && !record.ExpiresAt.After(now) {
|
||||
problem(c, http.StatusGone, "evidence_expired", "evidence reference has expired")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", record.Payload)
|
||||
}
|
||||
|
||||
func prepareMachineRequest(c *gin.Context, expectedPath string) bool {
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
} else if !inboundRequestIDPattern.MatchString(requestID) {
|
||||
problem(c, http.StatusBadRequest, "invalid_request_id", "X-Request-ID is invalid")
|
||||
return false
|
||||
}
|
||||
c.Header("X-Request-ID", requestID)
|
||||
if c.Request.URL.RawQuery != "" || c.Request.URL.Fragment != "" || c.Request.URL.EscapedPath() != expectedPath {
|
||||
problem(c, http.StatusBadRequest, "invalid_request_target", "request target is invalid")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func machineCode(err error) string {
|
||||
var machineErr *machine_identity.Error
|
||||
if errors.As(err, &machineErr) {
|
||||
return machineErr.Code
|
||||
}
|
||||
return "machine_token_invalid"
|
||||
}
|
||||
|
||||
func problem(c *gin.Context, status int, code, message string) {
|
||||
c.Header("Content-Type", "application/problem+json")
|
||||
c.JSON(status, Problem{Code: code, Message: message})
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var ErrInboundConflict = errors.New("idempotency_conflict")
|
||||
|
||||
func AcceptEvent(ctx context.Context, db *gorm.DB, payload []byte, evidenceOwnerID string, now time.Time) (AcceptResult, error) {
|
||||
identity, err := parseEventIdentity(payload)
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
canonical, err := canonicalPayload(payload)
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
digestBytes := sha256.Sum256(canonical)
|
||||
digest := hex.EncodeToString(digestBytes[:])
|
||||
result := AcceptResult{ProducerID: identity.ProducerID, SourceEventID: identity.SourceEventID, PayloadSHA256: digest}
|
||||
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
key := fmt.Sprintf("%d:%s:%s", len(identity.ProducerID), identity.ProducerID, identity.SourceEventID)
|
||||
if lockErr := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", key).Error; lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
}
|
||||
var existing InboundEvent
|
||||
lookup := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("producer_id = ? AND source_event_id = ?", identity.ProducerID, identity.SourceEventID).First(&existing).Error
|
||||
if lookup == nil {
|
||||
if existing.PayloadSHA256 != digest {
|
||||
return ErrInboundConflict
|
||||
}
|
||||
result.Disposition = "duplicate"
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(lookup, gorm.ErrRecordNotFound) {
|
||||
return lookup
|
||||
}
|
||||
fact := InboundEvent{ID: uuid.NewString(), ProducerID: identity.ProducerID, SourceEventID: identity.SourceEventID, Payload: append([]byte(nil), canonical...), PayloadSHA256: digest, ReceivedAt: now.UTC()}
|
||||
if err := tx.Create(&fact).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := EnqueueEvent(tx, canonical, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := retainEvidence(tx, canonical, evidenceOwnerID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Disposition = "accepted"
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func retainEvidence(tx *gorm.DB, payload []byte, ownerID string, now time.Time) error {
|
||||
var envelope struct {
|
||||
Evidence []json.RawMessage `json:"evidence"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
if err := decoder.Decode(&envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, raw := range envelope.Evidence {
|
||||
var metadata struct {
|
||||
EvidenceID string `json:"evidence_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil || !safeIdentifier(metadata.EvidenceID) || !safeIdentifier(metadata.OwnerID) {
|
||||
return errors.New("invalid evidence metadata")
|
||||
}
|
||||
if ownerID != "" && metadata.OwnerID != ownerID {
|
||||
continue
|
||||
}
|
||||
canonical, err := canonicalPayload(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if metadata.ExpiresAt != "" {
|
||||
parsed, parseErr := time.Parse(time.RFC3339Nano, metadata.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
parsed = parsed.UTC()
|
||||
expiresAt = &parsed
|
||||
}
|
||||
record := EvidenceRecord{EvidenceID: metadata.EvidenceID, OwnerID: metadata.OwnerID, Status: metadata.Status, Payload: canonical, ExpiresAt: expiresAt, UpdatedAt: now.UTC()}
|
||||
var existing EvidenceRecord
|
||||
lookup := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&existing, "evidence_id = ?", metadata.EvidenceID).Error
|
||||
if lookup == nil {
|
||||
if existing.OwnerID != metadata.OwnerID || !validEvidenceTransition(existing.Status, metadata.Status) {
|
||||
return errors.New("evidence metadata transition is invalid")
|
||||
}
|
||||
} else if !errors.Is(lookup, gorm.ErrRecordNotFound) {
|
||||
return lookup
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "evidence_id"}}, DoUpdates: clause.AssignmentColumns([]string{"status", "payload", "expires_at", "updated_at"})}).Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validEvidenceTransition(from, to string) bool {
|
||||
if from == to {
|
||||
return true
|
||||
}
|
||||
switch from {
|
||||
case "pending":
|
||||
return to == "processing" || to == "success" || to == "failed"
|
||||
case "processing":
|
||||
return to == "success" || to == "failed"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const OutboxType = "bell_event_v1"
|
||||
|
||||
type eventIdentity struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
}
|
||||
|
||||
type IngestResult struct {
|
||||
EventID string `json:"event_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
Disposition string `json:"disposition"`
|
||||
PayloadSHA256 string `json:"payload_sha256"`
|
||||
}
|
||||
|
||||
type Problem struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
ExistingEventID string `json:"existing_event_id,omitempty"`
|
||||
}
|
||||
|
||||
type DeliveryError struct {
|
||||
Code string
|
||||
Detail string
|
||||
Terminal bool
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *DeliveryError) Error() string { return e.Code + ": " + e.Detail }
|
||||
|
||||
// ReplayToken is Sense-owned verification state for authenticated evidence
|
||||
// requests. It is never shared with Bell's replay table or business receipts.
|
||||
type ReplayToken struct {
|
||||
Principal string `gorm:"size:128;primaryKey"`
|
||||
TokenID string `gorm:"size:64;primaryKey"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ReplayToken) TableName() string { return "sense_machine_token_replays" }
|
||||
|
||||
// InboundEvent is the Sense-owned local fact for an event received from Brain.
|
||||
// The immutable payload and Bell Outbox row are created in one transaction.
|
||||
type InboundEvent struct {
|
||||
ID string `gorm:"size:36;primaryKey"`
|
||||
ProducerID string `gorm:"size:128;not null;uniqueIndex:sense_inbound_event_key"`
|
||||
SourceEventID string `gorm:"size:128;not null;uniqueIndex:sense_inbound_event_key"`
|
||||
Payload json.RawMessage `gorm:"column:payload;type:jsonb;not null"`
|
||||
PayloadSHA256 string `gorm:"type:char(64);not null"`
|
||||
ReceivedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (InboundEvent) TableName() string { return "sense_inbound_events" }
|
||||
|
||||
type EvidenceRecord struct {
|
||||
EvidenceID string `gorm:"size:128;primaryKey"`
|
||||
OwnerID string `gorm:"size:128;not null;index"`
|
||||
Status string `gorm:"size:16;not null;index"`
|
||||
Payload json.RawMessage `gorm:"column:payload;type:jsonb;not null"`
|
||||
ExpiresAt *time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (EvidenceRecord) TableName() string { return "sense_evidence_metadata" }
|
||||
|
||||
type AcceptResult struct {
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
Disposition string `json:"disposition"`
|
||||
PayloadSHA256 string `json:"payload_sha256"`
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
)
|
||||
|
||||
func EnqueueEvent(tx *gorm.DB, payload []byte, now time.Time) (outbox.Message, error) {
|
||||
identity, err := parseEventIdentity(payload)
|
||||
if err != nil {
|
||||
return outbox.Message{}, err
|
||||
}
|
||||
keyDigest := sha256.Sum256([]byte(identity.ProducerID + "\x00" + identity.SourceEventID))
|
||||
return outbox.Enqueue(tx, outbox.EnqueueInput{
|
||||
InternalType: OutboxType,
|
||||
BusinessRef: identity.SourceEventID,
|
||||
IdempotencyKey: "bell-event-v1:" + hex.EncodeToString(keyDigest[:]),
|
||||
PayloadJSON: append([]byte(nil), payload...),
|
||||
}, now)
|
||||
}
|
||||
|
||||
func parseEventIdentity(payload []byte) (eventIdentity, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
var identity eventIdentity
|
||||
if err := decoder.Decode(&identity); err != nil || !json.Valid(payload) || identity.SchemaVersion != "yovision.event/v1" ||
|
||||
!safeIdentifier(identity.ProducerID) || !safeIdentifier(identity.SourceEventID) {
|
||||
return eventIdentity{}, errors.New("invalid yovision.event/v1 payload")
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func safeIdentifier(value string) bool {
|
||||
if value == "" || len(value) > 128 || strings.ContainsAny(value, "\\/@\x00\r\n") {
|
||||
return false
|
||||
}
|
||||
for index, r := range value {
|
||||
allowed := r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || (index > 0 && strings.ContainsRune("._:-", r))
|
||||
if !allowed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type Relay struct {
|
||||
DB *gorm.DB
|
||||
Client *Client
|
||||
Now func() time.Time
|
||||
Backoff func(int) time.Duration
|
||||
}
|
||||
|
||||
func (r Relay) DeliverBatch(ctx context.Context, worker string, limit int) (int, error) {
|
||||
if r.DB == nil || r.Client == nil || strings.TrimSpace(worker) == "" || limit < 1 || limit > 100 {
|
||||
return 0, errors.New("invalid Bell relay configuration")
|
||||
}
|
||||
items, err := r.claim(worker, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
delivered := 0
|
||||
queueRelay := outbox.NewRelay(r.DB)
|
||||
queueRelay.Now = r.now
|
||||
if r.Backoff != nil {
|
||||
queueRelay.Backoff = r.Backoff
|
||||
}
|
||||
for _, item := range items {
|
||||
_, deliveryErr := r.Client.Send(ctx, []byte(item.PayloadJSON))
|
||||
if deliveryErr == nil {
|
||||
if err = queueRelay.MarkSuccess(item.ID, worker); err != nil {
|
||||
return delivered, err
|
||||
}
|
||||
delivered++
|
||||
continue
|
||||
}
|
||||
var classified *DeliveryError
|
||||
if errors.As(deliveryErr, &classified) && classified.Terminal {
|
||||
if err = r.markTerminal(item, worker, classified.Code); err != nil {
|
||||
return delivered, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err = queueRelay.MarkFailure(item.ID, worker, deliveryErr.Error()); err != nil {
|
||||
return delivered, err
|
||||
}
|
||||
}
|
||||
return delivered, nil
|
||||
}
|
||||
|
||||
func (r Relay) claim(worker string, limit int) ([]outbox.Message, error) {
|
||||
now := r.now()
|
||||
leaseUntil := now.Add(30 * time.Second)
|
||||
claimed := make([]outbox.Message, 0, limit)
|
||||
err := r.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var candidates []outbox.Message
|
||||
query := tx.Where("internal_type = ? AND (((state IN ?) AND available_at <= ?) OR (state = ? AND lease_until < ?))", OutboxType, []string{outbox.StatePending, outbox.StateRetry}, now, outbox.StateProcessing, now).Order("available_at, created_at").Limit(limit)
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"})
|
||||
}
|
||||
if err := query.Find(&candidates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range candidates {
|
||||
result := tx.Model(&outbox.Message{}).Where("id = ? AND version = ?", item.ID, item.Version).Updates(map[string]any{"state": outbox.StateProcessing, "lease_owner": worker, "lease_until": leaseUntil, "version": gorm.Expr("version + 1"), "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
item.State, item.LeaseOwner, item.LeaseUntil, item.Version = outbox.StateProcessing, worker, &leaseUntil, item.Version+1
|
||||
claimed = append(claimed, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return claimed, err
|
||||
}
|
||||
|
||||
func (r Relay) markTerminal(item outbox.Message, worker, detail string) error {
|
||||
now := r.now()
|
||||
return r.DB.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&outbox.Message{}).Where("id = ? AND state = ? AND lease_owner = ?", item.ID, outbox.StateProcessing, worker).Updates(map[string]any{
|
||||
"state": outbox.StateDead, "attempt_count": gorm.Expr("attempt_count + 1"), "last_error": detail,
|
||||
"lease_owner": "", "lease_until": nil, "version": gorm.Expr("version + 1"), "updated_at": now,
|
||||
})
|
||||
if result.Error != nil || result.RowsAffected != 1 {
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return errors.New("Bell outbox lease lost")
|
||||
}
|
||||
return tx.Create(&outbox.Attempt{MessageID: item.ID, Number: item.AttemptCount + 1, Outcome: outbox.StateDead, Detail: detail, Worker: worker, CreatedAt: now}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r Relay) now() time.Time {
|
||||
if r.Now != nil {
|
||||
return r.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func PreserveIdentity(before, after []byte) error {
|
||||
left, err := parseEventIdentity(before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
right, err := parseEventIdentity(after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if left.ProducerID != right.ProducerID || left.SourceEventID != right.SourceEventID {
|
||||
return fmt.Errorf("relay changed original event identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type PersistentReplayStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s PersistentReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
if s.DB == nil {
|
||||
return false
|
||||
}
|
||||
accepted := false
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("expires_at <= ?", now.UTC()).Delete(&ReplayToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt.UTC(), CreatedAt: now.UTC()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
accepted = result.RowsAffected == 1
|
||||
return nil
|
||||
})
|
||||
return err == nil && accepted
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
)
|
||||
|
||||
type Environment func(string) string
|
||||
|
||||
func StartRuntime(ctx context.Context, engine *gin.Engine, db *gorm.DB, getenv Environment) error {
|
||||
if getenv == nil {
|
||||
getenv = os.Getenv
|
||||
}
|
||||
ingressEnabled := enabled(getenv("SENSE_EVENT_INGRESS_ENABLED"))
|
||||
relayEnabled := enabled(getenv("SENSE_BELL_CONNECTOR_ENABLED"))
|
||||
if !ingressEnabled && !relayEnabled {
|
||||
return nil
|
||||
}
|
||||
if engine == nil || db == nil {
|
||||
return errors.New("Sense connector runtime requires engine and database")
|
||||
}
|
||||
for _, model := range []any{&InboundEvent{}, &EvidenceRecord{}, &ReplayToken{}, &outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{}} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
return fmt.Errorf("Sense connector migration is not applied for %T", model)
|
||||
}
|
||||
}
|
||||
if ingressEnabled {
|
||||
registry, err := machine_identity.LoadRegistry(getenv("SENSE_MACHINE_PRINCIPAL_REGISTRY"), "yovision-sense")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Sense machine identity registry: %w", err)
|
||||
}
|
||||
verifier := machine_identity.Verifier{Registry: registry, Replay: PersistentReplayStore{DB: db}}
|
||||
engine.POST("/v1/events", (IngressHandler{DB: db, Verifier: verifier, EvidenceOwnerID: strings.TrimSpace(getenv("SENSE_EVIDENCE_OWNER_ID"))}).Post)
|
||||
engine.GET("/v1/evidence/:evidence_id", (EvidenceHandler{DB: db, Verifier: verifier}).Get)
|
||||
}
|
||||
if !relayEnabled {
|
||||
return nil
|
||||
}
|
||||
privateKey, err := machine_identity.LoadPrivateKey(getenv("SENSE_BELL_PRIVATE_KEY_PATH"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signer := machine_identity.Signer{Principal: strings.TrimSpace(getenv("SENSE_BELL_PRINCIPAL_ID")), KeyID: strings.TrimSpace(getenv("SENSE_BELL_KEY_ID")), PrivateKey: privateKey}
|
||||
policy := machine_identity.TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true, ConnectTimeout: 5 * time.Second, ResponseHeaderTimeout: 10 * time.Second, RequestTimeout: 15 * time.Second, MaxRequestBytes: MaxInboundBytes}
|
||||
client, err := NewClient(strings.TrimSpace(getenv("SENSE_BELL_ENDPOINT")), strings.TrimSpace(getenv("SENSE_RELAY_ID")), signer, policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
interval := 2 * time.Second
|
||||
if raw := strings.TrimSpace(getenv("SENSE_BELL_RELAY_INTERVAL_MS")); raw != "" {
|
||||
milliseconds, parseErr := strconv.Atoi(raw)
|
||||
if parseErr != nil || milliseconds < 100 || milliseconds > 60000 {
|
||||
return errors.New("SENSE_BELL_RELAY_INTERVAL_MS must be between 100 and 60000")
|
||||
}
|
||||
interval = time.Duration(milliseconds) * time.Millisecond
|
||||
}
|
||||
go runRelay(ctx, Relay{DB: db, Client: client}, interval)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRelay(ctx context.Context, relay Relay, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := relay.DeliverBatch(ctx, "sense-bell-runtime", 50); err != nil && ctx.Err() == nil {
|
||||
log.Printf("Sense Bell connector delivery failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enabled(value string) bool { return strings.EqualFold(strings.TrimSpace(value), "true") }
|
||||
@@ -0,0 +1,68 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type registryDocument struct {
|
||||
Version string `json:"version"`
|
||||
Audience string `json:"audience"`
|
||||
Principals []registryPrincipal `json:"principals"`
|
||||
}
|
||||
|
||||
type registryPrincipal struct {
|
||||
PrincipalID string `json:"principal_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Keys []registryKey `json:"keys"`
|
||||
}
|
||||
|
||||
type registryKey struct {
|
||||
KeyID string `json:"kid"`
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
|
||||
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
|
||||
return nil, errors.New("machine principal registry path and audience are required")
|
||||
}
|
||||
raw, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine principal registry")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
var document registryDocument
|
||||
if err = decoder.Decode(&document); err != nil {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records := make([]KeyRecord, 0)
|
||||
for _, principal := range document.Principals {
|
||||
if len(principal.Keys) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
for _, key := range principal.Keys {
|
||||
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
|
||||
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
|
||||
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
|
||||
}
|
||||
}
|
||||
return NewRegistry(records...)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
|
||||
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
|
||||
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
|
||||
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
|
||||
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
|
||||
)
|
||||
|
||||
const (
|
||||
Version = "yovision.machine-identity/v1"
|
||||
TokenType = "YOVISION-MACHINE+JWT"
|
||||
MaxLifetime = 5 * time.Minute
|
||||
AllowedSkew = 30 * time.Second
|
||||
MaxKeyOverlap = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Error struct{ Code string }
|
||||
|
||||
func (e *Error) Error() string { return e.Code }
|
||||
|
||||
func codeError(code string) error { return &Error{Code: code} }
|
||||
|
||||
// BearerToken deliberately has no cookie or query fallback.
|
||||
func BearerToken(authorization string) (string, error) {
|
||||
parts := strings.Split(authorization, " ")
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
|
||||
return "", codeError("machine_token_missing")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Version string `json:"ver"`
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience string `json:"aud"`
|
||||
Scopes []string `json:"scope"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
NotBefore int64 `json:"nbf"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
TokenID string `json:"jti"`
|
||||
Method string `json:"htm"`
|
||||
Path string `json:"htu"`
|
||||
BodySHA256 string `json:"body_sha256"`
|
||||
}
|
||||
|
||||
type protectedHeader struct {
|
||||
Algorithm string `json:"alg"`
|
||||
Type string `json:"typ"`
|
||||
KeyID string `json:"kid"`
|
||||
Version string `json:"ver"`
|
||||
}
|
||||
|
||||
type KeyRecord struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PublicKey ed25519.PublicKey
|
||||
Audience string
|
||||
Scopes []string
|
||||
Enabled bool
|
||||
Revoked bool
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]KeyRecord
|
||||
}
|
||||
|
||||
func NewRegistry(records ...KeyRecord) (*Registry, error) {
|
||||
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
|
||||
for _, record := range records {
|
||||
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
|
||||
return nil, errors.New("invalid machine key record")
|
||||
}
|
||||
if _, exists := r.keys[record.KeyID]; exists {
|
||||
return nil, errors.New("duplicate machine key id")
|
||||
}
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
r.keys[record.KeyID] = record
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
record, ok := r.keys[keyID]
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
return record, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Revoke(keyID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
record, ok := r.keys[keyID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
record.Revoked = true
|
||||
r.keys[keyID] = record
|
||||
return true
|
||||
}
|
||||
|
||||
type ReplayStore struct {
|
||||
mu sync.Mutex
|
||||
used map[string]time.Time
|
||||
}
|
||||
|
||||
// ReplayCache must atomically persist accepted (principal, jti) pairs until
|
||||
// expiry. ReplayStore is process-local and intended for tests or a single
|
||||
// uninterrupted process; connector implementations inject a durable store.
|
||||
type ReplayCache interface {
|
||||
Consume(principal, tokenID string, expiresAt, now time.Time) bool
|
||||
}
|
||||
|
||||
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
|
||||
|
||||
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for key, expiry := range s.used {
|
||||
if !expiry.After(now) {
|
||||
delete(s.used, key)
|
||||
}
|
||||
}
|
||||
key := principal + "\x00" + tokenID
|
||||
if _, exists := s.used[key]; exists {
|
||||
return false
|
||||
}
|
||||
s.used[key] = expiresAt
|
||||
return true
|
||||
}
|
||||
|
||||
type Signer struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PrivateKey ed25519.PrivateKey
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, errors.New("machine private key path is required")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine private key")
|
||||
}
|
||||
block, rest := pem.Decode(raw)
|
||||
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
|
||||
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("parse machine private key")
|
||||
}
|
||||
key, ok := parsed.(ed25519.PrivateKey)
|
||||
if !ok || len(key) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("machine private key is not Ed25519")
|
||||
}
|
||||
return slices.Clone(key), nil
|
||||
}
|
||||
|
||||
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
|
||||
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
|
||||
return "", errors.New("incomplete machine signer configuration")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method = strings.ToUpper(method)
|
||||
if !allowedMethod(method) {
|
||||
return "", errors.New("unsupported machine request method")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
tokenID, err := randomTokenID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
|
||||
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
|
||||
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
|
||||
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
|
||||
headerJSON, _ := json.Marshal(header)
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
|
||||
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
|
||||
return signingInput + "." + rawBase64(signature), nil
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
Registry *Registry
|
||||
Replay ReplayCache
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
|
||||
if v.Registry == nil || v.Replay == nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 || strings.Contains(token, "=") {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
headerBytes, err := decodeRaw(parts[0])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var header protectedHeader
|
||||
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
record, ok := v.Registry.Lookup(header.KeyID)
|
||||
if !ok {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
signature, err := decodeRaw(parts[2])
|
||||
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !record.Enabled || record.Revoked {
|
||||
return Claims{}, codeError("machine_identity_revoked")
|
||||
}
|
||||
claimsBytes, err := decodeRaw(parts[1])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var claims Claims
|
||||
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if v.Now != nil {
|
||||
now = v.Now().UTC()
|
||||
}
|
||||
nowUnix := now.Unix()
|
||||
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
|
||||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_expired")
|
||||
}
|
||||
if claims.Audience != audience || record.Audience != audience {
|
||||
return Claims{}, codeError("machine_audience_denied")
|
||||
}
|
||||
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
|
||||
return Claims{}, codeError("machine_scope_denied")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
digest := sha256.Sum256(body)
|
||||
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
|
||||
return Claims{}, codeError("machine_token_replayed")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func decodeClosed(raw []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validClaimsShape(claims Claims) bool {
|
||||
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
|
||||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
|
||||
return false
|
||||
}
|
||||
if !validScopeList(claims.Scopes) {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(claims.BodySHA256)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validScopeList(scopes []string) bool {
|
||||
if len(scopes) == 0 || len(scopes) > 4 {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, scope := range scopes {
|
||||
if !validScopes[scope] || seen[scope] {
|
||||
return false
|
||||
}
|
||||
seen[scope] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePath(value string) (string, error) {
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
|
||||
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
|
||||
}
|
||||
return parsed.EscapedPath(), nil
|
||||
}
|
||||
|
||||
func allowedMethod(method string) bool {
|
||||
switch method {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func randomTokenID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate machine token id: %w", err)
|
||||
}
|
||||
return rawBase64(raw), nil
|
||||
}
|
||||
|
||||
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
|
||||
|
||||
func decodeRaw(value string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.Strict().DecodeString(value)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type crossLanguageVector struct {
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Token string `json:"token"`
|
||||
Now int64 `json:"now"`
|
||||
Audience string `json:"audience"`
|
||||
Scope string `json:"required_scope"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body string `json:"body_base64"`
|
||||
}
|
||||
|
||||
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
|
||||
t.Helper()
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
|
||||
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
|
||||
}
|
||||
|
||||
func errorCode(t *testing.T, err error) string {
|
||||
t.Helper()
|
||||
var coded *Error
|
||||
if !errors.As(err, &coded) {
|
||||
t.Fatalf("expected coded error, got %v", err)
|
||||
}
|
||||
return coded.Code
|
||||
}
|
||||
|
||||
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
|
||||
t.Fatalf("unexpected claims: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
|
||||
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
|
||||
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
|
||||
t.Fatalf("accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
mint := func() string {
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
token := mint()
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryRevocationAndRotation(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte("{}")
|
||||
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
|
||||
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
rotation, err := NewRegistry(
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
|
||||
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
|
||||
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rotation.Revoke("brain-old-0001") {
|
||||
t.Fatal("old key was not revoked")
|
||||
}
|
||||
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
|
||||
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
|
||||
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
|
||||
if err := safe.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unsafe := safe
|
||||
unsafe.VerifyHostname = false
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("unsafe hostname policy accepted")
|
||||
}
|
||||
unsafe = safe
|
||||
unsafe.TLSMinVersion = tls.VersionTLS11
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("TLS 1.1 accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifiesCrossLanguageVector(t *testing.T) {
|
||||
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
|
||||
raw, err := os.ReadFile(vectorPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var vector crossLanguageVector
|
||||
if err = json.Unmarshal(raw, &vector); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := base64.StdEncoding.DecodeString(vector.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
|
||||
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != "yv:brain:vector" {
|
||||
t.Fatalf("unexpected issuer: %s", claims.Issuer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
|
||||
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
|
||||
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
|
||||
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
|
||||
}}}},
|
||||
}
|
||||
raw, _ := json.Marshal(document)
|
||||
file := filepath.Join(t.TempDir(), "principals.json")
|
||||
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := LoadRegistry(file, "yovision-bell")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
|
||||
t.Fatal("registry record missing")
|
||||
}
|
||||
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
|
||||
t.Fatal("wrong registry audience accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TransportPolicy struct {
|
||||
TLSMinVersion uint16
|
||||
VerifyCertificate bool
|
||||
VerifyHostname bool
|
||||
ConnectTimeout time.Duration
|
||||
ResponseHeaderTimeout time.Duration
|
||||
RequestTimeout time.Duration
|
||||
MaxRequestBytes int64
|
||||
}
|
||||
|
||||
func (p TransportPolicy) Validate() error {
|
||||
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
|
||||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
|
||||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
|
||||
return errors.New("machine transport policy is unsafe")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
|
||||
TLSHandshakeTimeout: p.ConnectTimeout,
|
||||
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
|
||||
}
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
|
||||
)
|
||||
|
||||
// CreateWithOutbox commits the local candidate and its internal delivery
|
||||
// record atomically. The payload remains Sense-internal and is not a Bell or
|
||||
// Brain contract.
|
||||
// record atomically. The payload is the frozen anonymous event contract; it
|
||||
// never carries the candidate's internal evidence path or delivery attempts.
|
||||
func CreateWithOutbox(ctx context.Context, db *gorm.DB, candidate EventCandidate, payload map[string]interface{}, now time.Time) error {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -23,7 +23,7 @@ func CreateWithOutbox(ctx context.Context, db *gorm.DB, candidate EventCandidate
|
||||
if err := tx.Create(&candidate).Error; err != nil {
|
||||
return fmt.Errorf("create local event candidate: %w", err)
|
||||
}
|
||||
_, err = outbox.Enqueue(tx, outbox.EnqueueInput{InternalType: "local_event_candidate", BusinessRef: candidate.ID, IdempotencyKey: "local-event:" + candidate.ID + ":v1", PayloadJSON: encoded}, now)
|
||||
_, err = bell_connector.EnqueueEvent(tx, encoded, now)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package local_event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -9,6 +12,7 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
)
|
||||
|
||||
@@ -24,7 +28,18 @@ func TestCreateWithOutboxCommitsAndRollsBackAtomically(t *testing.T) {
|
||||
}
|
||||
now := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
|
||||
candidate := EventCandidate{ID: uuid.NewString(), OccurredAt: now, SourceRef: "SEN-CAM-01", RuleRef: "rule-1", RuleName: "区域闯入", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStatePending, RetainUntil: now.Add(24 * time.Hour)}
|
||||
if err = CreateWithOutbox(context.Background(), db, candidate, map[string]interface{}{"eventId": candidate.ID}, now); err != nil {
|
||||
fixturePath := filepath.Join("..", "..", "..", "..", "..", "contracts", "events", "v1", "examples", "dangerous-area.json")
|
||||
fixture, err := os.ReadFile(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err = json.Unmarshal(fixture, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload["producer_id"] = "sense-local"
|
||||
payload["source_event_id"] = candidate.ID
|
||||
if err = CreateWithOutbox(context.Background(), db, candidate, payload, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var candidates, messages int64
|
||||
@@ -33,9 +48,13 @@ func TestCreateWithOutboxCommitsAndRollsBackAtomically(t *testing.T) {
|
||||
if candidates != 1 || messages != 1 {
|
||||
t.Fatalf("candidates=%d messages=%d", candidates, messages)
|
||||
}
|
||||
var message outbox.Message
|
||||
if err = db.First(&message).Error; err != nil || message.InternalType != bell_connector.OutboxType {
|
||||
t.Fatalf("local event did not enqueue Bell contract delivery: type=%s err=%v", message.InternalType, err)
|
||||
}
|
||||
duplicate := candidate
|
||||
duplicate.ID = candidate.ID
|
||||
if err = CreateWithOutbox(context.Background(), db, duplicate, map[string]interface{}{"eventId": duplicate.ID}, now); err == nil {
|
||||
if err = CreateWithOutbox(context.Background(), db, duplicate, payload, now); err == nil {
|
||||
t.Fatal("expected duplicate transaction failure")
|
||||
}
|
||||
db.Model(&EventCandidate{}).Count(&candidates)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
)
|
||||
|
||||
func TestBellConnectorRuntimeWiringIsOptionalAndMigrationGated(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open("file:sense-api-bell-connector?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine := gin.New()
|
||||
disabled := func(string) string { return "" }
|
||||
if err = startBellConnectorRuntime(context.Background(), engine, db, disabled); err != nil {
|
||||
t.Fatalf("disabled connector prevented Sense startup: %v", err)
|
||||
}
|
||||
if len(engine.Routes()) != 0 {
|
||||
t.Fatalf("disabled connector registered routes: %#v", engine.Routes())
|
||||
}
|
||||
enabled := func(key string) string {
|
||||
if key == "SENSE_EVENT_INGRESS_ENABLED" {
|
||||
return "true"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if err = startBellConnectorRuntime(context.Background(), gin.New(), db, enabled); err == nil {
|
||||
t.Fatal("enabled connector started without its formal migration")
|
||||
}
|
||||
|
||||
if err = db.AutoMigrate(
|
||||
&bell_connector.InboundEvent{}, &bell_connector.EvidenceRecord{}, &bell_connector.ReplayToken{},
|
||||
&outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registryPath := writeSenseRegistry(t)
|
||||
runtimeEnvironment := func(key string) string {
|
||||
switch key {
|
||||
case "SENSE_EVENT_INGRESS_ENABLED":
|
||||
return "true"
|
||||
case "SENSE_MACHINE_PRINCIPAL_REGISTRY":
|
||||
return registryPath
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
registered := gin.New()
|
||||
if err = startBellConnectorRuntime(context.Background(), registered, db, runtimeEnvironment); err != nil {
|
||||
t.Fatalf("enabled connector did not register after migration: %v", err)
|
||||
}
|
||||
routes := registered.Routes()
|
||||
if len(routes) != 2 || routes[0].Path != "/v1/events" || routes[1].Path != "/v1/evidence/:evidence_id" {
|
||||
t.Fatalf("unexpected connector routes: %#v", routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorRuntimeUsesOnlyTheExplicitDefaultDatabase(t *testing.T) {
|
||||
defaultDB, err := gorm.Open(sqlite.Open("file:sense-default-runtime?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondaryDB, err := gorm.Open(sqlite.Open("file:sense-secondary-runtime?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sdk.Runtime.SetDb("", defaultDB)
|
||||
sdk.Runtime.SetDb("analytics", secondaryDB)
|
||||
t.Cleanup(func() {
|
||||
sdk.Runtime.SetDb("", nil)
|
||||
sdk.Runtime.SetDb("analytics", nil)
|
||||
})
|
||||
if selected := defaultRuntimeDatabase(); selected != defaultDB {
|
||||
t.Fatalf("runtime selected a non-default database: %p", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSenseRegistry(t *testing.T) string {
|
||||
t.Helper()
|
||||
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1",
|
||||
"audience": "yovision-sense",
|
||||
"principals": []any{map[string]any{
|
||||
"principal_id": "yv:brain:school-a", "enabled": true,
|
||||
"keys": []any{map[string]any{
|
||||
"kid": "brain-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey),
|
||||
"status": "active", "scopes": []string{"events:ingest"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
encoded, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "sense-registry.json")
|
||||
if err = os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -17,9 +17,11 @@ import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/router"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/database"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
|
||||
@@ -88,15 +90,19 @@ func run() error {
|
||||
}
|
||||
runtimeCtx, runtimeCancel := context.WithCancel(context.Background())
|
||||
defer runtimeCancel()
|
||||
var runtimeDBFound bool
|
||||
for _, db := range sdk.Runtime.GetDb() {
|
||||
runtimeDBFound = true
|
||||
db := defaultRuntimeDatabase()
|
||||
if db != nil {
|
||||
engine, engineOK := sdk.Runtime.GetEngine().(*gin.Engine)
|
||||
if !engineOK || engine == nil {
|
||||
return errors.New("Sense connector runtime requires Gin engine")
|
||||
}
|
||||
if err := startBellConnectorRuntime(runtimeCtx, engine, db, os.Getenv); err != nil {
|
||||
return fmt.Errorf("Bell connector runtime unavailable: %w", err)
|
||||
}
|
||||
if err := media.StartRuntime(runtimeCtx, db); err != nil {
|
||||
return fmt.Errorf("MediaMTX runtime unavailable: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
if !runtimeDBFound {
|
||||
} else {
|
||||
log.Error("MediaMTX runtime unavailable: Sense database is not initialized")
|
||||
}
|
||||
|
||||
@@ -166,6 +172,14 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func startBellConnectorRuntime(ctx context.Context, engine *gin.Engine, db *gorm.DB, getenv bell_connector.Environment) error {
|
||||
return bell_connector.StartRuntime(ctx, engine, db, getenv)
|
||||
}
|
||||
|
||||
func defaultRuntimeDatabase() *gorm.DB {
|
||||
return sdk.Runtime.GetDbByKey("")
|
||||
}
|
||||
|
||||
//var Router runtime.Router
|
||||
|
||||
func tip() {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseBellConnector)
|
||||
}
|
||||
|
||||
func migrateSenseBellConnector(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(
|
||||
&bell_connector.InboundEvent{},
|
||||
&bell_connector.EvidenceRecord{},
|
||||
&bell_connector.ReplayToken{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestSenseBellConnectorMigrationIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const version = "2026083112000"
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err = migrateSenseBellConnector(db, version); err != nil {
|
||||
t.Fatalf("migration attempt %d: %v", attempt+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
for name, model := range map[string]any{
|
||||
"inbound events": &bell_connector.InboundEvent{},
|
||||
"evidence records": &bell_connector.EvidenceRecord{},
|
||||
"replay tokens": &bell_connector.ReplayToken{},
|
||||
} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
t.Fatalf("%s table missing", name)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(model).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count %s: %v", name, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("migration inserted %d %s fixtures", count, name)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasIndex(&bell_connector.ReplayToken{}, "ExpiresAt") {
|
||||
t.Fatal("replay expiry index missing")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
token := bell_connector.ReplayToken{Principal: "sense", TokenID: "token-1", ExpiresAt: now.Add(time.Minute), CreatedAt: now}
|
||||
if err = db.Create(&token).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&token).Error; err == nil {
|
||||
t.Fatal("duplicate replay token accepted")
|
||||
}
|
||||
|
||||
var applied int64
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("migration records=%d, want 1", applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package bell_connector_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
)
|
||||
|
||||
func TestPersistentOutboxRetriesWithoutChangingOriginalIdentity(t *testing.T) {
|
||||
db, databasePath := openDatabase(t)
|
||||
body := fixture(t)
|
||||
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC) }}
|
||||
var attempts atomic.Int32
|
||||
var received [][]byte
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
payload := make([]byte, request.ContentLength)
|
||||
_, _ = request.Body.Read(payload)
|
||||
received = append(received, payload)
|
||||
requestID := request.Header.Get("X-Request-ID")
|
||||
writer.Header().Set("X-Request-ID", requestID)
|
||||
if request.Header.Get("X-YoVision-Relay-ID") != "sense-school-a" || !strings.HasPrefix(request.Header.Get("Authorization"), "Bearer ") || len(requestID) < 16 || len(requestID) > 128 {
|
||||
http.Error(writer, "missing relay identity", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if attempts.Add(1) == 1 {
|
||||
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = writer.Write([]byte(`{"code":"ingest_unavailable","message":"temporarily unavailable"}`))
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusCreated)
|
||||
_, _ = writer.Write([]byte(`{"event_id":"bell-event-1","producer_id":"brain-school-a","source_event_id":"evt-area-20260831-0001","disposition":"created","payload_sha256":"4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
if _, err := bell_connector.EnqueueEvent(db, body, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &bell_connector.Client{Endpoint: server.URL, RelayID: "sense-school-a", Signer: signer, HTTP: server.Client(), Enabled: true}
|
||||
relay := bell_connector.Relay{DB: db, Client: client, Now: func() time.Time { return now }, Backoff: func(int) time.Duration { return 0 }}
|
||||
if delivered, err := relay.DeliverBatch(context.Background(), "worker-1", 10); err != nil || delivered != 0 {
|
||||
t.Fatalf("unavailable delivery=%d err=%v", delivered, err)
|
||||
}
|
||||
var queued outbox.Message
|
||||
if err := db.First(&queued).Error; err != nil || queued.State != outbox.StateRetry {
|
||||
t.Fatalf("outbox was not retained for retry: state=%s err=%v", queued.State, err)
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = sqlDatabase.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db = openDatabasePath(t, databasePath)
|
||||
if err = db.First(&queued).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Constructing a new relay simulates restart; the durable row is claimed
|
||||
// and delivered with its original bytes and business identity.
|
||||
restarted := bell_connector.Relay{DB: db, Client: client, Now: func() time.Time { return now }, Backoff: func(int) time.Duration { return 0 }}
|
||||
if delivered, err := restarted.DeliverBatch(context.Background(), "worker-2", 10); err != nil || delivered != 1 {
|
||||
t.Fatalf("recovery delivery=%d err=%v", delivered, err)
|
||||
}
|
||||
if len(received) != 2 || string(received[0]) != string(body) || string(received[1]) != string(body) || bell_connector.PreserveIdentity(received[0], received[1]) != nil {
|
||||
t.Fatal("relay changed the frozen payload or original identity")
|
||||
}
|
||||
if err := db.First(&queued).Error; err != nil || queued.State != outbox.StateDelivered {
|
||||
t.Fatalf("outbox not delivered: state=%s err=%v", queued.State, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalConflictDisabledTimeoutAndReplayRestart(t *testing.T) {
|
||||
db, databasePath := openDatabase(t)
|
||||
body := fixture(t)
|
||||
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey}
|
||||
policy := machine_identity.TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true, ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: time.Second, MaxRequestBytes: 64 * 1024}
|
||||
if _, err := bell_connector.NewClient("https://user:pass@bell.example", "sense-school-a", signer, policy); err == nil || !strings.Contains(err.Error(), "HTTPS origin") {
|
||||
t.Fatalf("endpoint userinfo was not rejected: %v", err)
|
||||
}
|
||||
conflictServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusConflict)
|
||||
_, _ = writer.Write([]byte(`{"code":"idempotency_conflict","message":"conflict","existing_event_id":"bell-1"}`))
|
||||
}))
|
||||
defer conflictServer.Close()
|
||||
if _, err := bell_connector.EnqueueEvent(db, body, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &bell_connector.Client{Endpoint: conflictServer.URL, Signer: signer, HTTP: conflictServer.Client(), Enabled: true}
|
||||
if _, err := (bell_connector.Relay{DB: db, Client: client}).DeliverBatch(context.Background(), "worker", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var message outbox.Message
|
||||
if err := db.First(&message).Error; err != nil || message.State != outbox.StateDead {
|
||||
t.Fatalf("terminal conflict was retried: state=%s err=%v", message.State, err)
|
||||
}
|
||||
if _, err := (bell_connector.Client{Enabled: false}).Send(context.Background(), body); err == nil || !strings.Contains(err.Error(), "connector_disabled") {
|
||||
t.Fatalf("disabled connector error=%v", err)
|
||||
}
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := client.Send(cancelled, body); err == nil {
|
||||
t.Fatal("cancelled/timeout request unexpectedly succeeded")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
firstStore := bell_connector.PersistentReplayStore{DB: db}
|
||||
if !firstStore.Consume("yv:bell:school-a", "abcdefghijklmnopqrstuv", now.Add(time.Minute), now) {
|
||||
t.Fatal("first replay consume failed")
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = sqlDatabase.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db = openDatabasePath(t, databasePath)
|
||||
restartedStore := bell_connector.PersistentReplayStore{DB: db}
|
||||
if restartedStore.Consume("yv:bell:school-a", "abcdefghijklmnopqrstuv", now.Add(time.Minute), now) {
|
||||
t.Fatal("replay was accepted after store restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrainIngressAndBellEvidenceEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, _ := openDatabase(t)
|
||||
brainPublic, brainPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
bellPublic, bellPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
registry, err := machine_identity.NewRegistry(
|
||||
machine_identity.KeyRecord{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PublicKey: brainPublic, Audience: "yovision-sense", Scopes: []string{"events:ingest"}, Enabled: true},
|
||||
machine_identity.KeyRecord{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PublicKey: bellPublic, Audience: "yovision-sense", Scopes: []string{"evidence:read"}, Enabled: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
verifier := machine_identity.Verifier{Registry: registry, Replay: bell_connector.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
body := fixture(t)
|
||||
brainSigner := machine_identity.Signer{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PrivateKey: brainPrivate, Now: func() time.Time { return now }}
|
||||
token, err := brainSigner.Mint("yovision-sense", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/events", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Request-ID", "request-id-0000001")
|
||||
response := httptest.NewRecorder()
|
||||
ginContext, _ := gin.CreateTestContext(response)
|
||||
ginContext.Request = request
|
||||
(bell_connector.IngressHandler{DB: db, Verifier: verifier, EvidenceOwnerID: "sense-school-a", Now: func() time.Time { return now }}).Post(ginContext)
|
||||
if response.Code != http.StatusAccepted {
|
||||
t.Fatalf("Brain ingress status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var evidence bell_connector.EvidenceRecord
|
||||
if err = db.First(&evidence, "evidence_id = ?", "ev-school-east-0001").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bellSigner := machine_identity.Signer{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PrivateKey: bellPrivate, Now: func() time.Time { return now }}
|
||||
path := "/v1/evidence/ev-school-east-0001"
|
||||
token, err = bellSigner.Mint("yovision-sense", []string{"evidence:read"}, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
getRequest := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
getRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
getRequest.Header.Set("X-Request-ID", "request-id-0000002")
|
||||
getResponse := httptest.NewRecorder()
|
||||
getContext, _ := gin.CreateTestContext(getResponse)
|
||||
getContext.Request = getRequest
|
||||
getContext.Params = gin.Params{{Key: "evidence_id", Value: "ev-school-east-0001"}}
|
||||
(bell_connector.EvidenceHandler{DB: db, Verifier: verifier, Now: func() time.Time { return now }}).Get(getContext)
|
||||
if getResponse.Code != http.StatusOK || !bytes.Equal(getResponse.Body.Bytes(), evidence.Payload) {
|
||||
t.Fatalf("evidence lookup status=%d body=%s", getResponse.Code, getResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func openDatabase(t *testing.T) (*gorm.DB, string) {
|
||||
databasePath := filepath.Join(t.TempDir(), "sense-bell-connector.sqlite")
|
||||
return openDatabasePath(t, databasePath), databasePath
|
||||
}
|
||||
|
||||
func openDatabasePath(t *testing.T, databasePath string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{}, &bell_connector.ReplayToken{}, &bell_connector.InboundEvent{}, &bell_connector.EvidenceRecord{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDatabase.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func fixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "..", "contracts", "events", "v1", "examples", "dangerous-area.json")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var event map[string]any
|
||||
if json.Unmarshal(body, &event) != nil {
|
||||
t.Fatal("invalid fixture")
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
module git.ilapage.cn/ila/yovision/Sense/tests/integration/bell_connector
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
git.ilapage.cn/ila/yovision/Sense/server v0.0.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/andeya/ameda v1.5.3 // indirect
|
||||
github.com/andeya/goutil v1.1.2 // indirect
|
||||
github.com/bitly/go-simplejson v0.5.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/bytedance/go-tagexpr/v2 v2.9.11 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 // indirect
|
||||
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-admin-team/go-admin-core v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 // indirect
|
||||
github.com/go-admin-team/go-admin-core/plugins/logger/zap v1.5.2 // indirect
|
||||
github.com/go-admin-team/go-admin-core/sdk v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.49 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nyaruka/phonenumbers v1.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.61.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.2 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/arch v0.30.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
replace git.ilapage.cn/ila/yovision/Sense/server => ../../../server
|
||||
@@ -0,0 +1,204 @@
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/andeya/ameda v1.5.3 h1:SvqnhQPZwwabS8HQTRGfJwWPl2w9ZIPInHAw9aE1Wlk=
|
||||
github.com/andeya/ameda v1.5.3/go.mod h1:FQDHRe1I995v6GG+8aJ7UIUToEmbdTJn/U26NCPIgXQ=
|
||||
github.com/andeya/goutil v1.0.1/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
|
||||
github.com/andeya/goutil v1.1.2 h1:RiFWFkL/9yXh2SjQkNWOHqErU1x+RauHmeR23eNUzSg=
|
||||
github.com/andeya/goutil v1.1.2/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
|
||||
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bytedance/go-tagexpr/v2 v2.9.11 h1:jJgmoDKPKacGl0llPYbYL/+/2N+Ng0vV0ipbnVssXHY=
|
||||
github.com/bytedance/go-tagexpr/v2 v2.9.11/go.mod h1:UAyKh4ZRLBPGsyTRFZoPqTni1TlojMdOJXQnEIPCX84=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
|
||||
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 h1:K62Lb6bsgLOB++z/VAvRvtiEBdNCuMfmQGTGGWMdPpM=
|
||||
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99/go.mod h1:9+sJ9zvvkXC5sPjPEZM3Jpb9n2Q2VtcrGZly0UHYF5I=
|
||||
github.com/chanxuehong/util v0.0.0-20200304121633-ca8141845b13/go.mod h1:XEYt99iTxMqkv+gW85JX/DdUINHUe43Sbe5AtqSaDAQ=
|
||||
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd h1:v3JNsFZmplLO/Cmiyr/rGvR7lW1ld9lB+d5h4yR0MTI=
|
||||
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd/go.mod h1:mysjrtCs9MmN8hqDf4/mc4eQ26Rt9s1p5oO+fhJlLB4=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-admin-team/go-admin-core v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 h1:UI9ppj+iWl/0Y6kp9w06dwGUdLsdxWRC3p0J0gLHApI=
|
||||
github.com/go-admin-team/go-admin-core v1.5.3-rc.3.0.20250408121721-2763de5dcdf4/go.mod h1:uWX7fPisJ6DluUP9vR3m3818RkDpZb/4dnwbZdmZN6Q=
|
||||
github.com/go-admin-team/go-admin-core/plugins/logger/zap v1.5.2 h1:cPTLzpvvyh8kyB24jblB+2W0QZBugP+8VYN3R34Pb4s=
|
||||
github.com/go-admin-team/go-admin-core/plugins/logger/zap v1.5.2/go.mod h1:ejtJ3aohd6EznZ9Q+KZVA3NwPU/2qIm0gayIGM3tIXw=
|
||||
github.com/go-admin-team/go-admin-core/sdk v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 h1:gU2OBSCsfSrqWId2monoEQhPKXZqhZ8W9ol9f7A4DAE=
|
||||
github.com/go-admin-team/go-admin-core/sdk v1.5.3-rc.3.0.20250408121721-2763de5dcdf4/go.mod h1:va1lNEXHGnV161Avr0lzi5gnT8OazJ/wmN9xnsY9N/s=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U=
|
||||
github.com/nyaruka/phonenumbers v1.2.2 h1:OwVjf7Y4uHoK9VJUrA8ebR0ha2yc6sEYbfrwkq0asCY=
|
||||
github.com/nyaruka/phonenumbers v1.2.2/go.mod h1:wzk2qq7qwsaBKrfbkWKdgHYOOH+QFTesSpIq53ELw8M=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
|
||||
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
|
||||
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
|
||||
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
|
||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
|
||||
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
|
||||
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
|
||||
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -0,0 +1,58 @@
|
||||
# Standard event contract v1
|
||||
|
||||
`yovision.event/v1` is the only shared representation of an anonymous safety event. It is an immutable fact, not a Bell Alert. Bell owns all rule matching, Alert, acknowledgement, close, notification and user/audit state.
|
||||
|
||||
## Identity and idempotency
|
||||
|
||||
The permanent idempotency key is the exact UTF-8 pair `(producer_id, source_event_id)`. `producer_id` always names the original producer. A Sense gateway/relay sends its own authenticated transport identity and optional `X-YoVision-Relay-ID`, but it must forward both key fields and the business payload unchanged. A retry is not a new event.
|
||||
|
||||
After schema validation, calculate `payload_sha256` from the RFC 8785 JSON Canonicalization Scheme representation of the complete Event. The checked-in vector fixes the expected digest for supported implementations. Bell stores key, digest and Bell `event_id` permanently:
|
||||
|
||||
- absent key: atomically create Event/Receipt and return `201` with `disposition=created`;
|
||||
- same key and digest: return the original `event_id` and digest with `200`, `disposition=duplicate`;
|
||||
- same key but another digest: return `409 idempotency_conflict`, append an audit fact, and mutate neither Event nor Alert;
|
||||
- identity lookup and insert must share a transaction/unique constraint so concurrent duplicates have the same result.
|
||||
|
||||
Canonical timestamps in Event v1 are UTC RFC 3339 with exactly three fractional digits and `Z`. Optional members are omitted, never sent as `null`. Producers must reject non-finite numbers before canonicalization.
|
||||
|
||||
## Mapper responsibilities
|
||||
|
||||
| Role | Required responsibility | Must not do |
|
||||
|---|---|---|
|
||||
| Brain producer mapper | Convert `brain.internal.event-candidate/v1` into stable original identity, logical site/device/profile/rule/region refs, model version and anonymous observation; generate one `source_event_id` once and persist/reuse it across retries. | Expose internal candidate fields, face/person identity, camera credentials, file paths, Alert state, or regenerate identity during retry. |
|
||||
| Sense producer/evidence mapper | When Sense originates an event, apply the same original-identity rule; map its internal evidence record to a logical evidence reference and own later status resolution. | Put local path, RTSP URL, signed URL, credential or Outbox attempt ID into Event. |
|
||||
| Sense relay | Authenticate as a transport hop, preserve original `producer_id`, `source_event_id` and payload, retain retry/audit state outside the Event, and return Bell's response unchanged enough for deterministic retry handling. | Replace producer identity, create a new source ID, enrich/reorder semantics, or treat `409`/`422` as a transient retry. |
|
||||
| Bell consumer mapper | Validate before persistence; canonicalize; enforce permanent idempotency; map the immutable shared Event into Bell's private Event/Receipt and then independently evaluate rules to create an Alert. Unknown evidence becomes degraded evidence, not a rejected Event. | Persist arbitrary extension fields, import producer internals, or accept shared ack/close/notification/user state. |
|
||||
|
||||
Field ownership is deliberately narrow:
|
||||
|
||||
| Contract fields | Authoritative writer | Relay/Bell responsibility |
|
||||
|---|---|---|
|
||||
| `schema_version`, `producer_id`, `source_event_id` | Original Brain or Sense producer mapper | Relay preserves; Bell uses version gate and permanent idempotency key. |
|
||||
| `site_ref`, `device_ref`, `profile_ref` | Producer mapper from versioned logical configuration | Relay preserves; Bell treats as opaque external refs. |
|
||||
| `event_type`, `occurred_at`, `severity`, `rule`, `model`, `observation`, `region` | Brain/Sense mapper at the detection decision | Relay preserves; Bell validates and stores the immutable snapshot. |
|
||||
| `evidence[]` identity and initial status | Evidence-owning producer, normally Sense | Relay preserves; Bell stores the Event snapshot and resolves current metadata separately. |
|
||||
| `X-YoVision-Relay-ID` | Authenticated Sense transport hop | Bell audits transport metadata outside the immutable Event. |
|
||||
| `event_id`, `disposition`, `payload_sha256` | Bell ingest boundary | Producer/relay retain the receipt for deterministic retries. |
|
||||
|
||||
## Errors, compatibility and fallback
|
||||
|
||||
- `400 invalid_event`: schema, canonical form, or sensitive/unknown member violation. Terminal until the producer fixes the payload.
|
||||
- `409 idempotency_conflict`: same permanent key with a different payload. Terminal and audited; never overwrite the first Event.
|
||||
- `422 unsupported_schema_version`: unknown major/revision. Terminal for that payload.
|
||||
- Evidence `pending`, `processing`, `success` and `failed` are valid Event states. Bell keeps the Event and resolves/degrades evidence independently.
|
||||
|
||||
v1 is closed (`additionalProperties=false`). Producers may enable a compatible revision only after all relays and Bell validate it. Any removed/renamed required field, changed meaning, enum narrowing, identity/canonicalization change, or new required member publishes a new major path such as `/v2`. During the compatibility window Bell keeps the previous version endpoint. Rollback disables the new producer version and resumes the last accepted version; it does not delete Event, Receipt, Outbox or audit facts.
|
||||
|
||||
Unknown-version fallback is explicit: Bell returns `422`; relay records the terminal rejection without rewriting the payload; producer may remap the same internal candidate into a supported v1 payload only if it has not previously assigned that `(producer_id, source_event_id)` to a different canonical payload. Otherwise it must stop and require operator reconciliation.
|
||||
|
||||
## Reproducible verification
|
||||
|
||||
No third-party package is needed:
|
||||
|
||||
```powershell
|
||||
python contracts/tests/events-v1/test_contract.py
|
||||
python contracts/tests/evidence-v1/test_contract.py
|
||||
```
|
||||
|
||||
The tests validate Schema/OpenAPI references, mapper fixtures, RFC 8785-compatible canonical vectors used by v1 examples, duplicate/conflict behavior, unknown versions and sensitive-field rejection.
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/events/v1/event.schema.json",
|
||||
"title": "YoVision anonymous safety event v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version", "producer_id", "source_event_id", "site_ref", "device_ref",
|
||||
"profile_ref", "event_type", "occurred_at", "severity", "rule", "model",
|
||||
"observation", "region", "evidence"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {"const": "yovision.event/v1"},
|
||||
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"site_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"device_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"profile_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"event_type": {"enum": ["dangerous_area_entered", "directional_line_crossed"]},
|
||||
"occurred_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"},
|
||||
"severity": {"enum": ["low", "medium", "high", "critical"]},
|
||||
"rule": {
|
||||
"type": "object", "additionalProperties": false, "required": ["rule_id", "version"],
|
||||
"properties": {
|
||||
"rule_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"version": {"type": "string", "minLength": 1, "maxLength": 64}
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"type": "object", "additionalProperties": false, "required": ["name", "version"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"version": {"type": "string", "minLength": 1, "maxLength": 64}
|
||||
}
|
||||
},
|
||||
"observation": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["track_id", "category", "confidence"],
|
||||
"properties": {
|
||||
"track_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"category": {"enum": ["person", "vehicle", "other"]},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"bbox_normalized": {
|
||||
"type": "array", "minItems": 4, "maxItems": 4,
|
||||
"items": {"type": "number", "minimum": 0, "maximum": 1}
|
||||
}
|
||||
}
|
||||
},
|
||||
"region": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["region_id", "kind"],
|
||||
"properties": {
|
||||
"region_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"kind": {"enum": ["area", "line"]},
|
||||
"crossing_direction": {"enum": ["a_to_b", "b_to_a"]}
|
||||
},
|
||||
"allOf": [
|
||||
{"if": {"properties": {"kind": {"const": "line"}}, "required": ["kind"]}, "then": {"required": ["crossing_direction"]}},
|
||||
{"if": {"properties": {"kind": {"const": "area"}}, "required": ["kind"]}, "then": {"not": {"required": ["crossing_direction"]}}}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array", "maxItems": 8, "uniqueItems": true,
|
||||
"items": {"$ref": "../../evidence/v1/evidence-reference.schema.json"}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"properties": {"event_type": {"const": "dangerous_area_entered"}}, "required": ["event_type"]},
|
||||
"then": {"properties": {"region": {"properties": {"kind": {"const": "area"}}}}}
|
||||
},
|
||||
{
|
||||
"if": {"properties": {"event_type": {"const": "directional_line_crossed"}}, "required": ["event_type"]},
|
||||
"then": {"properties": {"region": {"properties": {"kind": {"const": "line"}}}}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schema_version": "yovision.event/v1",
|
||||
"producer_id": "brain-school-a",
|
||||
"source_event_id": "evt-area-20260831-0001",
|
||||
"site_ref": "site-school-a",
|
||||
"device_ref": "camera-east-gate",
|
||||
"profile_ref": "profile-main-stream",
|
||||
"event_type": "dangerous_area_entered",
|
||||
"occurred_at": "2026-08-31T00:00:01.125Z",
|
||||
"severity": "high",
|
||||
"rule": {"rule_id": "rule-east-danger", "version": "3"},
|
||||
"model": {"name": "anonymous-detector", "version": "2026.08"},
|
||||
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93, "bbox_normalized": [0.12, 0.2, 0.31, 0.74]},
|
||||
"region": {"region_id": "region-east-danger", "kind": "area"},
|
||||
"evidence": [
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-school-east-0001",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "snapshot",
|
||||
"status": "pending",
|
||||
"captured_at": "2026-08-31T00:00:01.125Z",
|
||||
"status_updated_at": "2026-08-31T00:00:01.125Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schema_version": "yovision.event/v1",
|
||||
"producer_id": "brain-school-a",
|
||||
"source_event_id": "evt-line-20260831-0002",
|
||||
"site_ref": "site-school-a",
|
||||
"device_ref": "camera-north-corridor",
|
||||
"profile_ref": "profile-main-stream",
|
||||
"event_type": "directional_line_crossed",
|
||||
"occurred_at": "2026-08-31T00:03:10.000Z",
|
||||
"severity": "medium",
|
||||
"rule": {"rule_id": "rule-north-one-way", "version": "1"},
|
||||
"model": {"name": "anonymous-detector", "version": "2026.08"},
|
||||
"observation": {"track_id": "track-0088", "category": "person", "confidence": 0.88},
|
||||
"region": {"region_id": "line-north-one-way", "kind": "line", "crossing_direction": "b_to_a"},
|
||||
"evidence": [
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-school-east-0002",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "clip",
|
||||
"status": "failed",
|
||||
"captured_at": "2026-08-31T00:03:10.000Z",
|
||||
"status_updated_at": "2026-08-31T00:03:13.100Z",
|
||||
"failure": {"code": "processing_failed", "retryable": true}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"event_id": "bell-event-00000042",
|
||||
"producer_id": "brain-school-a",
|
||||
"source_event_id": "evt-area-20260831-0001",
|
||||
"disposition": "duplicate",
|
||||
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"code": "idempotency_conflict",
|
||||
"message": "idempotency key already belongs to another canonical payload",
|
||||
"existing_event_id": "bell-event-00000042"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"code": "unsupported_schema_version",
|
||||
"message": "schema_version yovision.event/v2 is not accepted",
|
||||
"field": "schema_version"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/events/v1/ingest-result.schema.json",
|
||||
"title": "YoVision Bell event ingest result v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["event_id", "producer_id", "source_event_id", "disposition", "payload_sha256"],
|
||||
"properties": {
|
||||
"event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"disposition": {"enum": ["created", "duplicate"]},
|
||||
"payload_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "YoVision standard event ingest API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/v1/events": {
|
||||
"post": {
|
||||
"summary": "Ingest one immutable anonymous safety event",
|
||||
"parameters": [
|
||||
{"name": "X-YoVision-Relay-ID", "in": "header", "required": false, "description": "Audited transport hop. A relay must not change producer_id or source_event_id.", "schema": {"type": "string", "maxLength": 128}}
|
||||
],
|
||||
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "./event.schema.json"}}}},
|
||||
"responses": {
|
||||
"201": {"description": "Created", "content": {"application/json": {"schema": {"$ref": "./ingest-result.schema.json"}}}},
|
||||
"200": {"description": "Exact duplicate; returns the original Bell Event identity", "content": {"application/json": {"schema": {"$ref": "./ingest-result.schema.json"}}}},
|
||||
"400": {"description": "Invalid or sensitive payload", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}},
|
||||
"409": {"description": "Same idempotency key with a different canonical payload", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}},
|
||||
"422": {"description": "Unsupported schema major version", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/events/v1/problem.schema.json",
|
||||
"title": "YoVision contract problem v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": {"enum": ["invalid_event", "unsupported_schema_version", "idempotency_conflict", "evidence_not_found", "evidence_expired"]},
|
||||
"message": {"type": "string", "minLength": 1, "maxLength": 512},
|
||||
"field": {"type": "string", "pattern": "^[A-Za-z0-9_.\\[\\]-]{1,128}$"},
|
||||
"existing_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Evidence reference contract v1
|
||||
|
||||
This contract shares metadata about a logical evidence object. It never grants object access. `owner_id` identifies the service that owns resolution; `evidence_id` is opaque to every consumer. Neither field may be interpreted as a URL or local path.
|
||||
|
||||
## State and degradation
|
||||
|
||||
- `pending`: capture was accepted but no processing started.
|
||||
- `processing`: capture or encoding is in progress.
|
||||
- `success`: capture completed; `content_type` and SHA-256 `integrity` are required. Access authorization is negotiated outside this payload by the machine-identity/connector work.
|
||||
- `failed`: `failure.code` and `retryable` are required. Bell keeps the immutable Event and renders evidence unavailable; it must not reject or close the Alert because evidence failed.
|
||||
- HTTP `404` means an unknown logical reference. `410` means expired evidence. Both degrade evidence only, not the Event.
|
||||
|
||||
The payload forbids arbitrary properties, so filesystem paths, camera credentials, bearer/user tokens, signed URLs, face templates and notification/Alert state fail schema validation. Do not add access URLs to v1. A short-lived download grant, if later required, needs a separately reviewed endpoint and security contract.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Brain may request evidence but maps only logical metadata it actually knows.
|
||||
- Sense is the default evidence owner and advances the status monotonically for a given capture attempt: `pending -> processing -> success|failed`. It must retain the same `evidence_id` while status changes.
|
||||
- A relay transports the reference unchanged and must not resolve it into a path or URL.
|
||||
- Bell stores the latest evidence metadata separately from its immutable Event. Evidence failure/expiry never changes Alert ack/close state.
|
||||
|
||||
## Compatibility and rollback
|
||||
|
||||
v1 consumers ignore no unknown fields because the v1 schema is closed. Additive fields therefore require a new schema revision that producers enable only after consumers accept it. Changed meaning, removed fields, or new required fields require `/v2`. Rollback disables the new producer and continues resolving stored v1 references; it never deletes Event, Receipt, Outbox, or evidence audit facts.
|
||||
|
||||
Run the standalone contract check from the repository root:
|
||||
|
||||
```powershell
|
||||
python contracts/tests/evidence-v1/test_contract.py
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/evidence/v1/evidence-reference.schema.json",
|
||||
"title": "YoVision evidence logical reference v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"evidence_id",
|
||||
"owner_id",
|
||||
"type",
|
||||
"status",
|
||||
"captured_at",
|
||||
"status_updated_at"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {"const": "yovision.evidence-reference/v1"},
|
||||
"evidence_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"owner_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"type": {"enum": ["snapshot", "clip"]},
|
||||
"status": {"enum": ["pending", "processing", "success", "failed"]},
|
||||
"captured_at": {"type": "string", "format": "date-time"},
|
||||
"status_updated_at": {"type": "string", "format": "date-time"},
|
||||
"expires_at": {"type": "string", "format": "date-time"},
|
||||
"content_type": {"enum": ["image/jpeg", "image/png", "video/mp4"]},
|
||||
"integrity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["algorithm", "digest", "size_bytes"],
|
||||
"properties": {
|
||||
"algorithm": {"const": "sha256"},
|
||||
"digest": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
||||
"size_bytes": {"type": "integer", "minimum": 0}
|
||||
}
|
||||
},
|
||||
"failure": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["code", "retryable"],
|
||||
"properties": {
|
||||
"code": {"enum": ["capture_failed", "processing_failed", "expired", "unavailable"]},
|
||||
"retryable": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"properties": {"status": {"const": "success"}}, "required": ["status"]},
|
||||
"then": {"required": ["content_type", "integrity"], "not": {"required": ["failure"]}}
|
||||
},
|
||||
{
|
||||
"if": {"properties": {"status": {"const": "failed"}}, "required": ["status"]},
|
||||
"then": {"required": ["failure"], "not": {"anyOf": [{"required": ["content_type"]}, {"required": ["integrity"]}]}}
|
||||
},
|
||||
{
|
||||
"if": {"properties": {"status": {"enum": ["pending", "processing"]}}, "required": ["status"]},
|
||||
"then": {"not": {"anyOf": [{"required": ["content_type"]}, {"required": ["integrity"]}, {"required": ["failure"]}]}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-school-east-0002",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "clip",
|
||||
"status": "failed",
|
||||
"captured_at": "2026-08-31T00:03:10.000Z",
|
||||
"status_updated_at": "2026-08-31T00:03:13.100Z",
|
||||
"failure": {"code": "processing_failed", "retryable": true}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-school-east-0001",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "snapshot",
|
||||
"status": "pending",
|
||||
"captured_at": "2026-08-31T00:00:01.125Z",
|
||||
"status_updated_at": "2026-08-31T00:00:01.125Z"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-school-east-0001",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "snapshot",
|
||||
"status": "success",
|
||||
"captured_at": "2026-08-31T00:00:01.125Z",
|
||||
"status_updated_at": "2026-08-31T00:00:02.450Z",
|
||||
"expires_at": "2026-09-07T00:00:01.125Z",
|
||||
"content_type": "image/jpeg",
|
||||
"integrity": {
|
||||
"algorithm": "sha256",
|
||||
"digest": "2f77668a9dfbf8d5848b9e6d7da867800b7b6790625f16b45a101f1ca1f7da75",
|
||||
"size_bytes": 48215
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "YoVision evidence reference API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/v1/evidence/{evidence_id}": {
|
||||
"get": {
|
||||
"summary": "Resolve current metadata for a logical evidence reference",
|
||||
"parameters": [
|
||||
{"name": "evidence_id", "in": "path", "required": true, "schema": {"type": "string"}}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"description": "Current metadata, including pending, processing, success or failed states", "content": {"application/json": {"schema": {"$ref": "./evidence-reference.schema.json"}}}},
|
||||
"404": {"description": "Unknown logical reference", "content": {"application/problem+json": {"schema": {"$ref": "../../events/v1/problem.schema.json"}}}},
|
||||
"410": {"description": "Evidence expired; event remains valid", "content": {"application/problem+json": {"schema": {"$ref": "../../events/v1/problem.schema.json"}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Machine identity contract v1
|
||||
|
||||
`yovision.machine-identity/v1` defines service-to-service identity for YoVision connectors. It is deliberately separate from Sense and Bell users, GoAdmin JWT/Cookie state, database roles and operating-system accounts.
|
||||
|
||||
## Authentication mechanism
|
||||
|
||||
Every request uses HTTPS and one compact Ed25519 JWS in `Authorization: Bearer <token>`. The protected header is closed and contains `alg=EdDSA`, `typ=YOVISION-MACHINE+JWT`, `kid` and `ver=yovision.machine-identity/v1`. The closed claims object contains:
|
||||
|
||||
mTLS is not the primary v1 identity mechanism. A customer PKI may add mTLS later as transport hardening, but it cannot replace or weaken the v1 principal, audience, scope, request binding, replay and revocation checks.
|
||||
|
||||
- one instance-specific `iss`/`sub` principal;
|
||||
- one exact service `aud`;
|
||||
- the minimum required `scope` values;
|
||||
- `iat`, `nbf`, `exp` and a single-use random `jti`;
|
||||
- uppercase HTTP method `htm`, normalized absolute-path reference `htu`, and lowercase SHA-256 `body_sha256`.
|
||||
|
||||
Tokens live for at most 300 seconds. Consumers allow at most 30 seconds of clock skew, verify the signature and active key/principal before authorization, then atomically consume `jti` until `exp + skew`. Retrying transport creates a new token and `jti`; business idempotency keys remain unchanged.
|
||||
|
||||
Production consumers persist the replay key `(principal, jti)` in their own durable store so a process restart cannot reopen the replay window. The checked-in process-local replay stores are adapter test/default primitives only; connector tasks must inject an atomic durable implementation and test restart behavior without sharing a database across products.
|
||||
|
||||
The v1 scopes are:
|
||||
|
||||
| Caller | Audience | Scope |
|
||||
|---|---|---|
|
||||
| Sense | `yovision-brain` | `source-config:write` |
|
||||
| Brain | `yovision-sense` | `runtime-status:write` |
|
||||
| Brain or Sense | `yovision-bell` | `events:ingest` |
|
||||
| Bell | `yovision-sense` | `evidence:read` |
|
||||
|
||||
No wildcard audience or scope exists. A relay authenticates as its own transport principal and never replaces the original event producer identity.
|
||||
|
||||
## Key lifecycle
|
||||
|
||||
Private Ed25519 keys are generated per product instance and stored outside the repository in an OS-protected file or secret store. Runtime configuration contains only the private-key path. Public registries are local consumer configuration, not a shared database.
|
||||
|
||||
Rotation first registers a new `kid`, switches the caller, and removes the old key after an overlap no longer than 24 hours. A disabled principal or revoked `kid` is rejected on every request, including tokens that have not expired. Emergency rollback disables the connector; it never enables a shared password, browser token, query token, plaintext transport or signature bypass.
|
||||
|
||||
## Threat boundary
|
||||
|
||||
v1 protects against token modification, wrong audience/scope, expired or premature tokens, captured-token replay, key/principal revocation and accidental credential mixing. It does not protect a host after administrator/root compromise, a stolen usable private key before revocation, compromised TLS trust roots, endpoint implementation flaws or denial of service. Rate and body-size limits remain consumer responsibilities.
|
||||
|
||||
See `../transport/v1/README.md` for HTTPS and request policy. Stable failures are defined in `errors.md`; callers and logs must expose only the stable code, principal/kid when already authenticated, and correlation ID—never the token, signature, private/public key material or complete Authorization header.
|
||||
|
||||
## Compatibility
|
||||
|
||||
v1 is closed. New optional claims require all consumers to accept them before producers emit them. Any change to signing input, algorithm, claim meaning, replay semantics, maximum lifetime, audience or scope meaning publishes a new major version. Consumers keep the last accepted major during a controlled migration; rollback disables the new producer version without weakening verification.
|
||||
|
||||
## Reproducible verification
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File contracts/tests/machine-identity-v1/run.ps1
|
||||
|
||||
cd Sense/server
|
||||
go test -race ./app/sense/integration/machine_identity
|
||||
|
||||
cd ../../Bell/server
|
||||
go test -race ./app/bell/integration/machine_identity
|
||||
```
|
||||
|
||||
The isolated contract test validates both JSON Schemas, the fixed Go/Python Ed25519 vector, request binding, exact audience/scope, expiry, replay, rotation overlap, revocation, bearer-only extraction and verified TLS policy. Product connector tasks remain responsible for injecting a durable replay store and testing restart recovery.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Machine identity v1 stable errors
|
||||
|
||||
| Code | Meaning | Retry |
|
||||
|---|---|---|
|
||||
| `machine_token_missing` | Authorization bearer token is absent or malformed | No, fix request |
|
||||
| `machine_token_invalid` | Header, claims, signature, request binding or key is invalid | No |
|
||||
| `machine_token_expired` | Token is outside its accepted time window | Mint a new token |
|
||||
| `machine_audience_denied` | Exact audience does not match | No |
|
||||
| `machine_scope_denied` | Required scope is absent or not granted to the key | No |
|
||||
| `machine_identity_revoked` | Principal or key is disabled/revoked | No; operator action |
|
||||
| `machine_token_replayed` | The same principal/jti was already accepted | Retry with a new token and the same business idempotency key |
|
||||
| `machine_transport_required` | HTTPS policy is not satisfied | No; fix deployment |
|
||||
|
||||
Responses and audit facts never include the token, signature, key material or Authorization header.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/machine-identity/v1/machine-token.schema.json",
|
||||
"title": "YoVision machine token claims v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"],
|
||||
"properties": {
|
||||
"ver": {"const": "yovision.machine-identity/v1"},
|
||||
"iss": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
|
||||
"sub": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
|
||||
"aud": {"enum": ["yovision-sense", "yovision-brain", "yovision-bell"]},
|
||||
"scope": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 4,
|
||||
"uniqueItems": true,
|
||||
"items": {"enum": ["source-config:write", "runtime-status:write", "events:ingest", "evidence:read"]}
|
||||
},
|
||||
"iat": {"type": "integer", "minimum": 0},
|
||||
"nbf": {"type": "integer", "minimum": 0},
|
||||
"exp": {"type": "integer", "minimum": 0},
|
||||
"jti": {"type": "string", "pattern": "^[A-Za-z0-9_-]{22,64}$"},
|
||||
"htm": {"type": "string", "pattern": "^(GET|POST|PUT|PATCH|DELETE)$"},
|
||||
"htu": {"type": "string", "pattern": "^/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$"},
|
||||
"body_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/machine-identity/v1/principal-registry.schema.json",
|
||||
"title": "YoVision machine principal registry v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "audience", "principals"],
|
||||
"properties": {
|
||||
"version": {"const": "yovision.machine-principal-registry/v1"},
|
||||
"audience": {"enum": ["yovision-sense", "yovision-brain", "yovision-bell"]},
|
||||
"principals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["principal_id", "enabled", "keys"],
|
||||
"properties": {
|
||||
"principal_id": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kid", "public_key_base64url", "status", "scopes"],
|
||||
"properties": {
|
||||
"kid": {"type": "string", "pattern": "^[A-Za-z0-9._-]{8,64}$"},
|
||||
"public_key_base64url": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"},
|
||||
"status": {"enum": ["active", "revoked"]},
|
||||
"scopes": {"type": "array", "minItems": 1, "maxItems": 4, "uniqueItems": true, "items": {"enum": ["source-config:write", "runtime-status:write", "events:ingest", "evidence:read"]}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Sense → Brain 媒体源与区域规则配置契约 v1
|
||||
|
||||
`yovision.source-config/v1` 是 Sense 发布、Brain 消费的完整配置快照。它只携带稳定逻辑标识、无凭据媒体引用、Profile 规格、归一化规则及完整性摘要,不暴露 Sense 数据库模型或 Brain 内部配置模型。
|
||||
|
||||
本版本选择 JSON Schema,而不是 OpenAPI:快照可经文件、消息或后续 connector 传输,工单 #148 不定义 HTTP 端点。后续 connector 若提供 HTTP API,应引用本 Schema,不复制字段定义。
|
||||
|
||||
## 文件
|
||||
|
||||
- `source-config.schema.json`:Draft 2020-12 JSON Schema。
|
||||
- `examples/valid/`:可接受的 active 与待重校准快照。
|
||||
- `examples/invalid/`:必须安全拒绝的版本、秘密、路径、坐标和绑定错误。
|
||||
- `compatibility.md`:版本、兼容周期、迁移和回退规则。
|
||||
- `mapper-fields.md`:Sense 生产者与 Brain 消费者字段映射和测试责任。
|
||||
|
||||
## 消费规则
|
||||
|
||||
1. 先按 JSON Schema 校验,再执行跨字段语义校验。
|
||||
2. `schema_version` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。
|
||||
3. `rule_set.profile_binding` 必须与 `profile.id/width/height` 完全一致。
|
||||
4. `rule_set.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。
|
||||
5. `areas` 与 `directional_lines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。
|
||||
6. `effective_at` 不得早于 `published_at`。
|
||||
7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。
|
||||
|
||||
`media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。
|
||||
|
||||
## 可复制验证
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
& contracts\tests\source-config-v1\run.ps1
|
||||
```
|
||||
|
||||
脚本在系统临时目录创建隔离虚拟环境、安装固定版本的 Schema 校验器并运行测试,不修改产品目录。测试结束后会清理临时环境。
|
||||
@@ -0,0 +1,29 @@
|
||||
# v1 兼容、迁移与回退
|
||||
|
||||
## 兼容规则
|
||||
|
||||
- v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。
|
||||
- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema_version` 值。
|
||||
- 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。
|
||||
- v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。
|
||||
|
||||
## revision 与生效
|
||||
|
||||
- `(config_id, revision)` 唯一标识一个不可变快照;同一 `config_id` 的新发布必须使用严格递增的 `revision`。
|
||||
- 消费者仅在 Schema、语义和完整性均通过后,按 `effective_at` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。
|
||||
- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `rule_set.state = recalibration_required`;消费者不得启用其中规则。
|
||||
- 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。
|
||||
|
||||
## 支持周期
|
||||
|
||||
- 发布新主版本后,Sense 生产者与 Brain 消费者至少并行支持上一主版本一个正式发布周期,且不少于 90 天;具体停止日期必须在新版本协调工单中冻结。
|
||||
- 并行期内生产者按目标消费者能力选择版本,不得把两个主版本字段混在同一快照。
|
||||
|
||||
## 回退
|
||||
|
||||
1. 停止分发有问题的新主版本或新 revision。
|
||||
2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `config_id`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。
|
||||
3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。
|
||||
4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。
|
||||
|
||||
样例 `examples/valid/recalibration-required.json` 展示 Profile 变化后的安全停用状态。回退不修改已发布 v1 字段语义,也不要求读取 Sense 数据库。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"coordinate-out-of-range.json": "schema",
|
||||
"credential-field.json": "secret",
|
||||
"internal-path.json": "internal path",
|
||||
"profile-binding-mismatch.json": "profile binding",
|
||||
"query-token.json": "secret",
|
||||
"unknown-major-version.json": "unknown schema"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1280, "height": 720}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v2",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {
|
||||
"id": "site-east"
|
||||
},
|
||||
"logical_device": {
|
||||
"id": "entry-camera-01"
|
||||
},
|
||||
"profile": {
|
||||
"id": "main-stream",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"encoding": "H264",
|
||||
"frame_rate": 25
|
||||
},
|
||||
"media": {
|
||||
"ref": "media:site-east/entry-01/main",
|
||||
"transport": "rtsp"
|
||||
},
|
||||
"rule_set": {
|
||||
"version": "entry-rules-7",
|
||||
"state": "active",
|
||||
"profile_binding": {
|
||||
"profile_id": "main-stream",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"areas": [
|
||||
{
|
||||
"id": "danger-yard",
|
||||
"version": 3,
|
||||
"kind": "danger_area",
|
||||
"enabled": true,
|
||||
"points": [
|
||||
{"x": 0.12, "y": 0.18},
|
||||
{"x": 0.82, "y": 0.18},
|
||||
{"x": 0.76, "y": 0.78},
|
||||
{"x": 0.18, "y": 0.72}
|
||||
]
|
||||
}
|
||||
],
|
||||
"directional_lines": [
|
||||
{
|
||||
"id": "entry-line",
|
||||
"version": 2,
|
||||
"kind": "directional_line",
|
||||
"enabled": true,
|
||||
"start": {"x": 0.2, "y": 0.5},
|
||||
"end": {"x": 0.8, "y": 0.5},
|
||||
"trigger_direction": "left_to_right"
|
||||
}
|
||||
]
|
||||
},
|
||||
"integrity": {
|
||||
"algorithm": "sha256",
|
||||
"value": "3336fe595bf1401b1024ac0c95c31e1655228485465a4527900fcea2c713acfe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 8,
|
||||
"published_at": "2026-08-31T01:00:00Z",
|
||||
"effective_at": "2026-08-31T01:00:00Z",
|
||||
"site": {
|
||||
"id": "site-east"
|
||||
},
|
||||
"logical_device": {
|
||||
"id": "entry-camera-01"
|
||||
},
|
||||
"profile": {
|
||||
"id": "main-stream-v2",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"encoding": "H265",
|
||||
"frame_rate": 20
|
||||
},
|
||||
"media": {
|
||||
"ref": "media:site-east/entry-01/main-v2",
|
||||
"transport": "rtsp"
|
||||
},
|
||||
"rule_set": {
|
||||
"version": "entry-rules-8",
|
||||
"state": "recalibration_required",
|
||||
"profile_binding": {
|
||||
"profile_id": "main-stream-v2",
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
"areas": [],
|
||||
"directional_lines": []
|
||||
},
|
||||
"integrity": {
|
||||
"algorithm": "sha256",
|
||||
"value": "a53e6df8bab5c9a4e3f2dae2e82959939db09d34529af9ae65d66f322be833ba"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# 生产者与消费者 mapper 字段表
|
||||
|
||||
mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体,也不得让 Brain 把共享快照当作 `brain.internal.input/v1`。
|
||||
|
||||
| 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 |
|
||||
|---|---|---|
|
||||
| `schema_version` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 |
|
||||
| `config_id` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID |
|
||||
| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `config_id` 共同做幂等、顺序和陈旧检查 |
|
||||
| `published_at` / `effective_at` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 |
|
||||
| `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 |
|
||||
| `logical_device.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` |
|
||||
| `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` |
|
||||
| `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 |
|
||||
| `profile.frame_rate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` |
|
||||
| `media.ref` | 由 `media.Route.ID/Path` 生成 `media:<opaque-resource>`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 |
|
||||
| `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 |
|
||||
| `rule_set.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` |
|
||||
| `rule_set.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 |
|
||||
| `rule_set.profile_binding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 |
|
||||
| `rule_set.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 |
|
||||
| `rule_set.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 |
|
||||
| `rule_set.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 |
|
||||
| `rule_set.directional_lines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` |
|
||||
| `rule_set.directional_lines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 |
|
||||
| `rule_set.directional_lines[].trigger_direction` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 |
|
||||
| `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision |
|
||||
|
||||
## 测试责任
|
||||
|
||||
- Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。
|
||||
- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema_version` 不等于 `brain.internal.input/v1`。
|
||||
- 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。
|
||||
|
||||
Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。
|
||||
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/source-config/v1/source-config.schema.json",
|
||||
"title": "YoVision Sense to Brain source configuration snapshot v1",
|
||||
"description": "Credential-free media source, profile binding, and normalized rule configuration published by Sense for Brain.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"config_id",
|
||||
"revision",
|
||||
"published_at",
|
||||
"effective_at",
|
||||
"site",
|
||||
"logical_device",
|
||||
"profile",
|
||||
"media",
|
||||
"rule_set",
|
||||
"integrity"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "yovision.source-config/v1"
|
||||
},
|
||||
"config_id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"published_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"effective_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"site": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logical_device": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"$ref": "#/$defs/profile"
|
||||
},
|
||||
"media": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ref", "transport"],
|
||||
"properties": {
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^media:[A-Za-z0-9][A-Za-z0-9._~/-]{0,254}$",
|
||||
"description": "Opaque credential-free reference resolved by the connector. URI authority, userinfo, query strings, and fragments are forbidden."
|
||||
},
|
||||
"transport": {
|
||||
"enum": ["rtsp"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"rule_set": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"version",
|
||||
"state",
|
||||
"profile_binding",
|
||||
"areas",
|
||||
"directional_lines"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"state": {
|
||||
"enum": ["active", "disabled", "recalibration_required"]
|
||||
},
|
||||
"profile_binding": {
|
||||
"$ref": "#/$defs/profile_binding"
|
||||
},
|
||||
"areas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/area_rule"
|
||||
},
|
||||
"maxItems": 1024
|
||||
},
|
||||
"directional_lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/directional_line_rule"
|
||||
},
|
||||
"maxItems": 1024
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["algorithm", "value"],
|
||||
"properties": {
|
||||
"algorithm": {
|
||||
"const": "sha256"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"description": "Optional namespaced, non-secret extension data. Consumers ignore unknown namespaces.",
|
||||
"propertyNames": {
|
||||
"pattern": "^[A-Za-z][A-Za-z0-9.-]{0,127}$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"stable_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
|
||||
},
|
||||
"positive_integer": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"positive_number": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "width", "height", "encoding", "frame_rate"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"width": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"height": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"encoding": {
|
||||
"enum": ["H264", "H265", "MJPEG"]
|
||||
},
|
||||
"frame_rate": {
|
||||
"$ref": "#/$defs/positive_number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile_binding": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["profile_id", "width", "height"],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"width": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"height": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"point": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["x", "y"],
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"area_rule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "version", "kind", "enabled", "points"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"version": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"kind": {
|
||||
"const": "danger_area"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/point"
|
||||
},
|
||||
"minItems": 3,
|
||||
"maxItems": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"directional_line_rule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"version",
|
||||
"kind",
|
||||
"enabled",
|
||||
"start",
|
||||
"end",
|
||||
"trigger_direction"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"version": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"kind": {
|
||||
"const": "directional_line"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"start": {
|
||||
"$ref": "#/$defs/point"
|
||||
},
|
||||
"end": {
|
||||
"$ref": "#/$defs/point"
|
||||
},
|
||||
"trigger_direction": {
|
||||
"enum": ["left_to_right", "right_to_left"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Small dependency-free validator for the JSON Schema keywords used by v1 contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def canonical_bytes(value: Any) -> bytes:
|
||||
"""Canonical bytes for checked-in JCS vectors (all vector numbers are JCS-safe)."""
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
|
||||
|
||||
def validate(instance: Any, schema: dict[str, Any], schema_path: Path, location: str = "$") -> list[str]:
|
||||
if "$ref" in schema:
|
||||
ref = schema["$ref"]
|
||||
if ref.startswith("#"):
|
||||
return [f"{location}: local fragments are not supported by the contract checker"]
|
||||
target = (schema_path.parent / ref).resolve()
|
||||
return validate(instance, load_json(target), target, location)
|
||||
|
||||
errors: list[str] = []
|
||||
for subschema in schema.get("allOf", []):
|
||||
errors.extend(validate(instance, subschema, schema_path, location))
|
||||
if "anyOf" in schema and not any(not validate(instance, item, schema_path, location) for item in schema["anyOf"]):
|
||||
errors.append(f"{location}: does not match anyOf")
|
||||
if "not" in schema and not validate(instance, schema["not"], schema_path, location):
|
||||
errors.append(f"{location}: matches forbidden schema")
|
||||
if "if" in schema and not validate(instance, schema["if"], schema_path, location):
|
||||
errors.extend(validate(instance, schema.get("then", {}), schema_path, location))
|
||||
|
||||
expected = schema.get("type")
|
||||
type_ok = {
|
||||
"object": lambda x: isinstance(x, dict),
|
||||
"array": lambda x: isinstance(x, list),
|
||||
"string": lambda x: isinstance(x, str),
|
||||
"integer": lambda x: isinstance(x, int) and not isinstance(x, bool),
|
||||
"number": lambda x: isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x),
|
||||
"boolean": lambda x: isinstance(x, bool),
|
||||
}
|
||||
if expected and (expected not in type_ok or not type_ok[expected](instance)):
|
||||
return errors + [f"{location}: expected {expected}"]
|
||||
if "const" in schema and instance != schema["const"]:
|
||||
errors.append(f"{location}: expected constant {schema['const']!r}")
|
||||
if "enum" in schema and instance not in schema["enum"]:
|
||||
errors.append(f"{location}: value not in enum")
|
||||
|
||||
if isinstance(instance, dict):
|
||||
required = schema.get("required", [])
|
||||
errors.extend(f"{location}: missing {name}" for name in required if name not in instance)
|
||||
properties = schema.get("properties", {})
|
||||
if schema.get("additionalProperties") is False:
|
||||
errors.extend(f"{location}: unknown property {name}" for name in instance if name not in properties)
|
||||
for name, value in instance.items():
|
||||
if name in properties:
|
||||
errors.extend(validate(value, properties[name], schema_path, f"{location}.{name}"))
|
||||
elif isinstance(instance, list):
|
||||
if len(instance) < schema.get("minItems", 0):
|
||||
errors.append(f"{location}: too few items")
|
||||
if "maxItems" in schema and len(instance) > schema["maxItems"]:
|
||||
errors.append(f"{location}: too many items")
|
||||
if schema.get("uniqueItems") and len({canonical_bytes(item) for item in instance}) != len(instance):
|
||||
errors.append(f"{location}: duplicate items")
|
||||
for index, value in enumerate(instance):
|
||||
errors.extend(validate(value, schema.get("items", {}), schema_path, f"{location}[{index}]"))
|
||||
elif isinstance(instance, str):
|
||||
if len(instance) < schema.get("minLength", 0):
|
||||
errors.append(f"{location}: string too short")
|
||||
if "maxLength" in schema and len(instance) > schema["maxLength"]:
|
||||
errors.append(f"{location}: string too long")
|
||||
if "pattern" in schema and re.fullmatch(schema["pattern"], instance) is None:
|
||||
errors.append(f"{location}: pattern mismatch")
|
||||
if schema.get("format") == "date-time":
|
||||
try:
|
||||
datetime.fromisoformat(instance.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
errors.append(f"{location}: invalid date-time")
|
||||
elif isinstance(instance, (int, float)) and not isinstance(instance, bool):
|
||||
if "minimum" in schema and instance < schema["minimum"]:
|
||||
errors.append(f"{location}: below minimum")
|
||||
if "maximum" in schema and instance > schema["maximum"]:
|
||||
errors.append(f"{location}: above maximum")
|
||||
return errors
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schema_version": "yovision.event/v1",
|
||||
"producer_id": "brain-school-a",
|
||||
"source_event_id": "evt-sensitive-0001",
|
||||
"site_ref": "site-school-a",
|
||||
"device_ref": "camera-east-gate",
|
||||
"profile_ref": "profile-main-stream",
|
||||
"event_type": "dangerous_area_entered",
|
||||
"occurred_at": "2026-08-31T00:00:01.125Z",
|
||||
"severity": "high",
|
||||
"rule": {"rule_id": "rule-east-danger", "version": "3"},
|
||||
"model": {"name": "anonymous-detector", "version": "2026.08"},
|
||||
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93, "face_feature": "forbidden"},
|
||||
"region": {"region_id": "region-east-danger", "kind": "area"},
|
||||
"evidence": [],
|
||||
"camera_password": "forbidden"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "yovision.event/v2",
|
||||
"producer_id": "brain-school-a",
|
||||
"source_event_id": "evt-unknown-version-0001",
|
||||
"site_ref": "site-school-a",
|
||||
"device_ref": "camera-east-gate",
|
||||
"profile_ref": "profile-main-stream",
|
||||
"event_type": "dangerous_area_entered",
|
||||
"occurred_at": "2026-08-31T00:00:01.125Z",
|
||||
"severity": "high",
|
||||
"rule": {"rule_id": "rule-east-danger", "version": "3"},
|
||||
"model": {"name": "anonymous-detector", "version": "2026.08"},
|
||||
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93},
|
||||
"region": {"region_id": "region-east-danger", "kind": "area"},
|
||||
"evidence": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"algorithm": "RFC8785-JCS+SHA-256",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "dangerous-area-original-and-reordered-duplicate",
|
||||
"fixture": "../../events/v1/examples/dangerous-area.json",
|
||||
"idempotency_key": ["brain-school-a", "evt-area-20260831-0001"],
|
||||
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1",
|
||||
"conflict_patch": {"severity": "critical"},
|
||||
"conflict_payload_sha256": "7076771f7827d97ef45831ae221046b8cb347f152dd222c2edd6f14a58e173b2"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
CONTRACTS = HERE.parents[1]
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from contract_support import canonical_bytes, load_json, validate # noqa: E402
|
||||
|
||||
|
||||
class EventV1ContractTests(unittest.TestCase):
|
||||
schema_path = CONTRACTS / "events" / "v1" / "event.schema.json"
|
||||
schema = load_json(schema_path)
|
||||
|
||||
def assert_valid(self, payload: object) -> None:
|
||||
self.assertEqual([], validate(payload, self.schema, self.schema_path))
|
||||
|
||||
def test_anonymous_area_and_line_examples_are_valid(self) -> None:
|
||||
for name in ("dangerous-area.json", "directional-line-crossed.json"):
|
||||
with self.subTest(name=name):
|
||||
self.assert_valid(load_json(CONTRACTS / "events" / "v1" / "examples" / name))
|
||||
|
||||
def test_idempotency_vector_duplicate_and_conflict(self) -> None:
|
||||
vectors = load_json(HERE / "idempotency-vectors.json")["vectors"]
|
||||
for vector in vectors:
|
||||
payload = load_json((HERE / vector["fixture"]).resolve())
|
||||
self.assertEqual(vector["idempotency_key"], [payload["producer_id"], payload["source_event_id"]])
|
||||
digest = hashlib.sha256(canonical_bytes(payload)).hexdigest()
|
||||
self.assertEqual(vector["payload_sha256"], digest)
|
||||
reordered = dict(reversed(list(payload.items())))
|
||||
self.assertEqual(digest, hashlib.sha256(canonical_bytes(reordered)).hexdigest())
|
||||
conflict = copy.deepcopy(payload)
|
||||
conflict.update(vector["conflict_patch"])
|
||||
conflict_digest = hashlib.sha256(canonical_bytes(conflict)).hexdigest()
|
||||
self.assertEqual(vector["conflict_payload_sha256"], conflict_digest)
|
||||
self.assertNotEqual(digest, conflict_digest)
|
||||
|
||||
def test_unknown_version_is_rejected(self) -> None:
|
||||
payload = load_json(HERE / "fixtures" / "unknown-version.json")
|
||||
self.assertTrue(validate(payload, self.schema, self.schema_path))
|
||||
|
||||
def test_brain_producer_sense_relay_and_bell_consumer_fixture(self) -> None:
|
||||
produced = load_json(CONTRACTS / "events" / "v1" / "examples" / "dangerous-area.json")
|
||||
self.assert_valid(produced)
|
||||
relayed = copy.deepcopy(produced)
|
||||
self.assertEqual(
|
||||
(produced["producer_id"], produced["source_event_id"]),
|
||||
(relayed["producer_id"], relayed["source_event_id"]),
|
||||
)
|
||||
self.assertEqual(canonical_bytes(produced), canonical_bytes(relayed))
|
||||
bell_allowed = set(self.schema["properties"])
|
||||
self.assertEqual(set(produced), bell_allowed)
|
||||
self.assertNotIn("alert", produced)
|
||||
self.assertNotIn("receipt", produced)
|
||||
|
||||
def test_sensitive_and_internal_fields_are_rejected(self) -> None:
|
||||
payload = load_json(HERE / "fixtures" / "sensitive-field.json")
|
||||
errors = validate(payload, self.schema, self.schema_path)
|
||||
self.assertTrue(any("camera_password" in error for error in errors))
|
||||
self.assertTrue(any("face_feature" in error for error in errors))
|
||||
base = load_json(CONTRACTS / "events" / "v1" / "examples" / "dangerous-area.json")
|
||||
for forbidden, value in {
|
||||
"user_token": "forbidden", "ack_state": "acked", "local_path": "C:/forbidden",
|
||||
"signed_url": "https://forbidden.invalid/object?signature=forbidden"
|
||||
}.items():
|
||||
with self.subTest(forbidden=forbidden):
|
||||
candidate = copy.deepcopy(base)
|
||||
candidate[forbidden] = value
|
||||
self.assertTrue(validate(candidate, self.schema, self.schema_path))
|
||||
|
||||
def test_openapi_references_exist_and_responses_are_explicit(self) -> None:
|
||||
path = CONTRACTS / "events" / "v1" / "openapi.json"
|
||||
spec = load_json(path)
|
||||
operation = spec["paths"]["/v1/events"]["post"]
|
||||
self.assertEqual({"200", "201", "400", "409", "422"}, set(operation["responses"]))
|
||||
refs: list[str] = []
|
||||
|
||||
def collect(value: object) -> None:
|
||||
if isinstance(value, dict):
|
||||
refs.extend(item for key, item in value.items() if key == "$ref")
|
||||
for item in value.values(): collect(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value: collect(item)
|
||||
|
||||
collect(spec)
|
||||
self.assertTrue(refs)
|
||||
for ref in refs:
|
||||
self.assertTrue((path.parent / ref).resolve().is_file(), ref)
|
||||
|
||||
def test_duplicate_conflict_and_unknown_version_response_examples(self) -> None:
|
||||
directory = CONTRACTS / "events" / "v1"
|
||||
cases = (
|
||||
("duplicate-result.json", "ingest-result.schema.json"),
|
||||
("idempotency-conflict-problem.json", "problem.schema.json"),
|
||||
("unsupported-version-problem.json", "problem.schema.json"),
|
||||
)
|
||||
for fixture_name, schema_name in cases:
|
||||
with self.subTest(fixture=fixture_name):
|
||||
schema_path = directory / schema_name
|
||||
errors = validate(load_json(directory / "examples" / fixture_name), load_json(schema_path), schema_path)
|
||||
self.assertEqual([], errors)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-sensitive-0001",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "snapshot",
|
||||
"status": "success",
|
||||
"captured_at": "2026-08-31T00:00:01.125Z",
|
||||
"status_updated_at": "2026-08-31T00:00:02.450Z",
|
||||
"content_type": "image/jpeg",
|
||||
"integrity": {"algorithm": "sha256", "digest": "2f77668a9dfbf8d5848b9e6d7da867800b7b6790625f16b45a101f1ca1f7da75", "size_bytes": 48215},
|
||||
"local_path": "forbidden"
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
CONTRACTS = HERE.parents[1]
|
||||
EVENT_SUPPORT = HERE.parent / "events-v1"
|
||||
sys.path.insert(0, str(EVENT_SUPPORT))
|
||||
|
||||
from contract_support import load_json, validate # noqa: E402
|
||||
|
||||
|
||||
class EvidenceV1ContractTests(unittest.TestCase):
|
||||
schema_path = CONTRACTS / "evidence" / "v1" / "evidence-reference.schema.json"
|
||||
schema = load_json(schema_path)
|
||||
|
||||
def errors_for(self, payload: object) -> list[str]:
|
||||
return validate(payload, self.schema, self.schema_path)
|
||||
|
||||
def test_pending_success_and_failed_examples_are_valid(self) -> None:
|
||||
for name in ("pending.json", "success.json", "failed.json"):
|
||||
with self.subTest(name=name):
|
||||
payload = load_json(CONTRACTS / "evidence" / "v1" / "examples" / name)
|
||||
self.assertEqual([], self.errors_for(payload))
|
||||
|
||||
def test_state_specific_metadata_is_enforced(self) -> None:
|
||||
success = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "success.json")
|
||||
for required in ("content_type", "integrity"):
|
||||
with self.subTest(success_requires=required):
|
||||
candidate = copy.deepcopy(success)
|
||||
del candidate[required]
|
||||
self.assertTrue(self.errors_for(candidate))
|
||||
|
||||
legacy_available = copy.deepcopy(success)
|
||||
legacy_available["status"] = "available"
|
||||
self.assertTrue(self.errors_for(legacy_available))
|
||||
|
||||
failed = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "failed.json")
|
||||
del failed["failure"]
|
||||
self.assertTrue(self.errors_for(failed))
|
||||
|
||||
pending = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "pending.json")
|
||||
pending["content_type"] = "image/jpeg"
|
||||
self.assertTrue(self.errors_for(pending))
|
||||
|
||||
def test_sensitive_access_material_and_unknown_version_are_rejected(self) -> None:
|
||||
fixture = load_json(HERE / "fixtures" / "sensitive-field.json")
|
||||
self.assertTrue(any("local_path" in error for error in self.errors_for(fixture)))
|
||||
base = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "pending.json")
|
||||
for forbidden, value in {
|
||||
"camera_password": "forbidden",
|
||||
"user_token": "forbidden",
|
||||
"signed_url": "https://forbidden.invalid/object?signature=forbidden",
|
||||
"face_feature": "forbidden",
|
||||
"alert_state": "acked"
|
||||
}.items():
|
||||
with self.subTest(forbidden=forbidden):
|
||||
candidate = copy.deepcopy(base)
|
||||
candidate[forbidden] = value
|
||||
self.assertTrue(self.errors_for(candidate))
|
||||
unknown = copy.deepcopy(base)
|
||||
unknown["schema_version"] = "yovision.evidence-reference/v2"
|
||||
self.assertTrue(self.errors_for(unknown))
|
||||
|
||||
def test_openapi_refs_and_degradation_responses(self) -> None:
|
||||
path = CONTRACTS / "evidence" / "v1" / "openapi.json"
|
||||
spec = load_json(path)
|
||||
operation = spec["paths"]["/v1/evidence/{evidence_id}"]["get"]
|
||||
self.assertEqual({"200", "404", "410"}, set(operation["responses"]))
|
||||
refs: list[str] = []
|
||||
|
||||
def collect(value: object) -> None:
|
||||
if isinstance(value, dict):
|
||||
refs.extend(item for key, item in value.items() if key == "$ref")
|
||||
for item in value.values(): collect(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value: collect(item)
|
||||
|
||||
collect(spec)
|
||||
for ref in refs:
|
||||
self.assertTrue((path.parent / ref).resolve().is_file(), ref)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "yovision.machine-token-vector/v1",
|
||||
"public_key_base64url": "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ",
|
||||
"token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImJyYWluLXZlY3Rvci0wMDAxIiwidHlwIjoiWU9WSVNJT04tTUFDSElORStKV1QiLCJ2ZXIiOiJ5b3Zpc2lvbi5tYWNoaW5lLWlkZW50aXR5L3YxIn0.eyJhdWQiOiJ5b3Zpc2lvbi1zZW5zZSIsImJvZHlfc2hhMjU2IjoiNDA5NDQzYTZlZTVhYTI5NmRjY2Q2YzBkMTkzZTIxNDU2OGRhYTAwNTNiNjYxNTVmYmE4YWRjYTk5NWI3ODIzZCIsImV4cCI6MTgwMDAwMDMwMCwiaHRtIjoiUE9TVCIsImh0dSI6Ii9tYWNoaW5lL3YxL3J1bnRpbWUtc3RhdHVzIiwiaWF0IjoxODAwMDAwMDAwLCJpc3MiOiJ5djpicmFpbjp2ZWN0b3IiLCJqdGkiOiJBUUlEQkFVR0J3Z0pDZ3NNRFE0UEVBIiwibmJmIjoxODAwMDAwMDAwLCJzY29wZSI6WyJydW50aW1lLXN0YXR1czp3cml0ZSJdLCJzdWIiOiJ5djpicmFpbjp2ZWN0b3IiLCJ2ZXIiOiJ5b3Zpc2lvbi5tYWNoaW5lLWlkZW50aXR5L3YxIn0.zrvo_7lRiDX1D6po6OlfQg4hDg6XXlyUEmNMYgpuRl3ArXSjvGuOLivDousIyLtRO4bYJu9xMAWX1cea7MdVBQ",
|
||||
"now": 1800000000,
|
||||
"audience": "yovision-sense",
|
||||
"required_scope": "runtime-status:write",
|
||||
"method": "POST",
|
||||
"path": "/machine/v1/runtime-status",
|
||||
"body_base64": "eyJzdGF0dXMiOiJydW5uaW5nIn0="
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
cryptography==50.0.1
|
||||
jsonschema==4.25.1
|
||||
@@ -0,0 +1,33 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$testDirectory = $PSScriptRoot
|
||||
$requirements = Join-Path $testDirectory 'requirements.txt'
|
||||
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$workDirectory = Join-Path $tempRoot ("yovision-machine-identity-v1-{0}" -f [Guid]::NewGuid().ToString('N'))
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $workDirectory | Out-Null
|
||||
$virtualEnvironment = Join-Path $workDirectory '.venv'
|
||||
python -m venv $virtualEnvironment
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' }
|
||||
|
||||
$python = Join-Path $virtualEnvironment 'Scripts\python.exe'
|
||||
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
|
||||
$env:PYTHONDONTWRITEBYTECODE = '1'
|
||||
& $python -m pip install --quiet --requirement $requirements
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned machine-identity test dependencies.' }
|
||||
|
||||
& $python $testDirectory\test_contract.py
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Machine-identity v1 contract tests failed.' }
|
||||
}
|
||||
finally {
|
||||
$resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory)
|
||||
if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to remove a temporary directory outside $tempRoot"
|
||||
}
|
||||
if (Test-Path -LiteralPath $resolvedWorkDirectory) {
|
||||
Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import base64
|
||||
import ssl
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "Brain" / "src"))
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from yovision_brain.integration.machine_identity import (
|
||||
KeyRecord,
|
||||
MachineIdentityError,
|
||||
Registry,
|
||||
ReplayStore,
|
||||
Signer,
|
||||
TransportPolicy,
|
||||
Verifier,
|
||||
load_registry,
|
||||
bearer_token,
|
||||
)
|
||||
|
||||
|
||||
class ContractFilesTest(unittest.TestCase):
|
||||
def test_closed_claim_and_registry_schemas(self) -> None:
|
||||
claims = json.loads((ROOT / "contracts/machine-identity/v1/machine-token.schema.json").read_text(encoding="utf-8"))
|
||||
registry = json.loads((ROOT / "contracts/machine-identity/v1/principal-registry.schema.json").read_text(encoding="utf-8"))
|
||||
transport = json.loads((ROOT / "contracts/transport/v1/transport-policy.schema.json").read_text(encoding="utf-8"))
|
||||
Draft202012Validator.check_schema(claims)
|
||||
Draft202012Validator.check_schema(registry)
|
||||
Draft202012Validator.check_schema(transport)
|
||||
self.assertFalse(claims["additionalProperties"])
|
||||
self.assertEqual(claims["properties"]["ver"]["const"], "yovision.machine-identity/v1")
|
||||
self.assertEqual(claims["properties"]["scope"]["items"]["enum"], [
|
||||
"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"
|
||||
])
|
||||
self.assertFalse(registry["additionalProperties"])
|
||||
self.assertEqual(transport["properties"]["verify_certificate"]["const"], True)
|
||||
self.assertEqual(transport["properties"]["verify_hostname"]["const"], True)
|
||||
|
||||
vector = json.loads((ROOT / "contracts/tests/machine-identity-v1/cross-language-vector.json").read_text(encoding="utf-8"))
|
||||
claims_object = json.loads(base64.urlsafe_b64decode(vector["token"].split(".")[1] + "=="))
|
||||
Draft202012Validator(claims).validate(claims_object)
|
||||
Draft202012Validator(transport).validate({
|
||||
"version": "yovision.transport/v1", "tls_min_version": "1.2", "verify_certificate": True,
|
||||
"verify_hostname": True, "connect_timeout_ms": 1000, "response_header_timeout_ms": 1000,
|
||||
"request_timeout_ms": 5000, "max_request_bytes": 1048576,
|
||||
})
|
||||
|
||||
def test_brain_dependency_is_frozen(self) -> None:
|
||||
pyproject = (ROOT / "Brain/pyproject.toml").read_text(encoding="utf-8")
|
||||
self.assertIn('dependencies = ["cryptography==50.0.1"]', pyproject)
|
||||
|
||||
def test_contract_documents_fail_closed(self) -> None:
|
||||
identity = (ROOT / "contracts/machine-identity/v1/README.md").read_text(encoding="utf-8")
|
||||
transport = (ROOT / "contracts/transport/v1/README.md").read_text(encoding="utf-8")
|
||||
for required in ("300 seconds", "30 seconds", "24 hours", "jti", "revoked", "browser token"):
|
||||
self.assertIn(required, identity)
|
||||
for required in ("TLS 1.2", "hostname verification", "query-string credentials", "disables the connector"):
|
||||
self.assertIn(required, transport)
|
||||
|
||||
def test_python_verifies_cross_language_vector(self) -> None:
|
||||
vector = json.loads((ROOT / "contracts/tests/machine-identity-v1/cross-language-vector.json").read_text(encoding="utf-8"))
|
||||
raw_key = base64.urlsafe_b64decode(vector["public_key_base64url"] + "=")
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
registry = Registry([KeyRecord("yv:brain:vector", "brain-vector-0001", Ed25519PublicKey.from_public_bytes(raw_key), vector["audience"], frozenset({vector["required_scope"]}))])
|
||||
verifier = Verifier(registry, ReplayStore(), clock=lambda: vector["now"])
|
||||
claims = verifier.verify(vector["token"], vector["audience"], vector["required_scope"], vector["method"], vector["path"], base64.b64decode(vector["body_base64"]))
|
||||
self.assertEqual(claims.iss, "yv:brain:vector")
|
||||
|
||||
|
||||
class BrainAdapterTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.now = 1_800_000_000
|
||||
self.private = Ed25519PrivateKey.generate()
|
||||
self.record = KeyRecord(
|
||||
principal="yv:brain:node-a",
|
||||
key_id="brain-key-0001",
|
||||
public_key=self.private.public_key(),
|
||||
audience="yovision-sense",
|
||||
scopes=frozenset({"runtime-status:write"}),
|
||||
)
|
||||
self.registry = Registry([self.record])
|
||||
self.signer = Signer("yv:brain:node-a", "brain-key-0001", self.private, clock=lambda: self.now)
|
||||
|
||||
def mint(self, body: bytes = b"{}") -> str:
|
||||
return self.signer.mint("yovision-sense", ["runtime-status:write"], "POST", "/machine/v1/runtime-status", body)
|
||||
|
||||
def verify(self, token: str, body: bytes = b"{}", **changes: str):
|
||||
verifier = Verifier(self.registry, changes.pop("replay", ReplayStore()), clock=lambda: int(changes.pop("now", self.now)))
|
||||
return verifier.verify(
|
||||
token,
|
||||
changes.pop("audience", "yovision-sense"),
|
||||
changes.pop("scope", "runtime-status:write"),
|
||||
changes.pop("method", "POST"),
|
||||
changes.pop("path", "/machine/v1/runtime-status"),
|
||||
body,
|
||||
)
|
||||
|
||||
def assert_code(self, code: str, callback) -> None:
|
||||
with self.assertRaises(MachineIdentityError) as caught:
|
||||
callback()
|
||||
self.assertEqual(caught.exception.code, code)
|
||||
self.assertEqual(str(caught.exception), code)
|
||||
|
||||
def test_valid_token_and_replay_rejection(self) -> None:
|
||||
token = self.mint()
|
||||
replay = ReplayStore()
|
||||
first = Verifier(self.registry, replay, clock=lambda: self.now)
|
||||
claims = first.verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
|
||||
self.assertEqual(claims.iss, "yv:brain:node-a")
|
||||
self.assert_code("machine_token_replayed", lambda: first.verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}"))
|
||||
|
||||
def test_bearer_token_has_no_cookie_or_query_fallback(self) -> None:
|
||||
self.assertEqual(bearer_token("Bearer compact.token.value"), "compact.token.value")
|
||||
for value in ("", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"):
|
||||
self.assert_code("machine_token_missing", lambda value=value: bearer_token(value))
|
||||
|
||||
def test_wrong_audience_scope_body_and_expiry(self) -> None:
|
||||
self.assert_code("machine_audience_denied", lambda: self.verify(self.mint(), audience="yovision-bell"))
|
||||
self.assert_code("machine_scope_denied", lambda: self.verify(self.mint(), scope="events:ingest"))
|
||||
self.assert_code("machine_token_invalid", lambda: self.verify(self.mint(), body=b"changed"))
|
||||
self.assert_code("machine_token_expired", lambda: self.verify(self.mint(), now=str(self.now + 361)))
|
||||
|
||||
def test_tampering_revocation_and_rotation(self) -> None:
|
||||
token = self.mint()
|
||||
parts = token.split(".")
|
||||
tampered = f"{parts[0]}.{parts[1][:-1]}A.{parts[2]}"
|
||||
self.assert_code("machine_token_invalid", lambda: self.verify(tampered))
|
||||
self.assertTrue(self.registry.revoke("brain-key-0001"))
|
||||
self.assert_code("machine_identity_revoked", lambda: self.verify(self.mint()))
|
||||
|
||||
new_private = Ed25519PrivateKey.generate()
|
||||
overlap = Registry([
|
||||
self.record,
|
||||
KeyRecord("yv:brain:node-a", "brain-key-0002", new_private.public_key(), "yovision-sense", frozenset({"runtime-status:write"})),
|
||||
])
|
||||
new_signer = Signer("yv:brain:node-a", "brain-key-0002", new_private, clock=lambda: self.now)
|
||||
verifier = Verifier(overlap, ReplayStore(), clock=lambda: self.now)
|
||||
verifier.verify(self.mint(), "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
|
||||
verifier.verify(new_signer.mint("yovision-sense", ["runtime-status:write"], "POST", "/machine/v1/runtime-status", b"{}"), "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
|
||||
|
||||
def test_transport_policy_requires_verified_tls(self) -> None:
|
||||
policy = TransportPolicy("1.2", True, True, 1000, 1000, 2000, 1024)
|
||||
context = policy.ssl_context()
|
||||
self.assertGreaterEqual(context.minimum_version, ssl.TLSVersion.TLSv1_2)
|
||||
self.assertTrue(context.check_hostname)
|
||||
self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED)
|
||||
with self.assertRaises(ValueError):
|
||||
TransportPolicy("1.1", True, True, 1000, 1000, 2000, 1024).validate()
|
||||
with self.assertRaises(ValueError):
|
||||
TransportPolicy("1.2", True, False, 1000, 1000, 2000, 1024).validate()
|
||||
|
||||
def test_loads_external_public_registry_and_rejects_wrong_audience(self) -> None:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
raw_public = self.private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
document = {
|
||||
"version": "yovision.machine-principal-registry/v1",
|
||||
"audience": "yovision-sense",
|
||||
"principals": [{
|
||||
"principal_id": "yv:brain:node-a", "enabled": True,
|
||||
"keys": [{"kid": "brain-key-0001", "public_key_base64url": base64.urlsafe_b64encode(raw_public).rstrip(b"=").decode(), "status": "active", "scopes": ["runtime-status:write"]}],
|
||||
}],
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "principals.json"
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
registry = load_registry(path, "yovision-sense")
|
||||
self.assertIsNotNone(registry.lookup("brain-key-0001"))
|
||||
with self.assertRaises(ValueError):
|
||||
load_registry(path, "yovision-bell")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,2 @@
|
||||
jsonschema==4.23.0
|
||||
rfc8785==0.1.4
|
||||
@@ -0,0 +1,33 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$testDirectory = $PSScriptRoot
|
||||
$requirements = Join-Path $testDirectory 'requirements.txt'
|
||||
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$workDirectory = Join-Path $tempRoot ("yovision-source-config-v1-{0}" -f [Guid]::NewGuid().ToString('N'))
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $workDirectory | Out-Null
|
||||
$virtualEnvironment = Join-Path $workDirectory '.venv'
|
||||
python -m venv $virtualEnvironment
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' }
|
||||
|
||||
$python = Join-Path $virtualEnvironment 'Scripts\python.exe'
|
||||
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
|
||||
$env:PYTHONDONTWRITEBYTECODE = '1'
|
||||
& $python -m pip install --quiet --requirement $requirements
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned contract-test dependencies.' }
|
||||
|
||||
& $python -m unittest discover -s $testDirectory -p 'test_*.py' -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Source-config v1 contract tests failed.' }
|
||||
}
|
||||
finally {
|
||||
$resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory)
|
||||
if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to remove a temporary directory outside $tempRoot"
|
||||
}
|
||||
if (Test-Path -LiteralPath $resolvedWorkDirectory) {
|
||||
Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import rfc8785
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
CONTRACT_ROOT = REPOSITORY_ROOT / "contracts" / "source-config" / "v1"
|
||||
SCHEMA_PATH = CONTRACT_ROOT / "source-config.schema.json"
|
||||
VALID_ROOT = CONTRACT_ROOT / "examples" / "valid"
|
||||
INVALID_ROOT = CONTRACT_ROOT / "examples" / "invalid"
|
||||
FORBIDDEN_KEY = re.compile(r"(?:credential|password|secret|token|username|cookie|jwt)", re.IGNORECASE)
|
||||
FORBIDDEN_MEDIA_CHARACTER = re.compile(r"[?@#\\]")
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise AssertionError(f"{path} must contain a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA = load_json(SCHEMA_PATH)
|
||||
VALIDATOR = Draft202012Validator(SCHEMA, format_checker=FormatChecker())
|
||||
|
||||
|
||||
def integrity_value(payload: dict[str, Any]) -> str:
|
||||
content = copy.deepcopy(payload)
|
||||
content.pop("integrity", None)
|
||||
return hashlib.sha256(rfc8785.dumps(content)).hexdigest()
|
||||
|
||||
|
||||
def set_integrity(payload: dict[str, Any]) -> None:
|
||||
payload["integrity"] = {"algorithm": "sha256", "value": integrity_value(payload)}
|
||||
|
||||
|
||||
def reject_secrets(value: Any, path: str = "config") -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
if FORBIDDEN_KEY.search(str(key)):
|
||||
raise ValueError(f"secret field is forbidden at {path}.{key}")
|
||||
reject_secrets(child, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
reject_secrets(child, f"{path}[{index}]")
|
||||
|
||||
|
||||
def polygon_area(points: list[dict[str, float]]) -> float:
|
||||
return abs(
|
||||
sum(
|
||||
point["x"] * points[(index + 1) % len(points)]["y"]
|
||||
- points[(index + 1) % len(points)]["x"] * point["y"]
|
||||
for index, point in enumerate(points)
|
||||
)
|
||||
/ 2
|
||||
)
|
||||
|
||||
|
||||
def validate_payload(payload: dict[str, Any]) -> None:
|
||||
if payload.get("schema_version") != "yovision.source-config/v1":
|
||||
raise ValueError("unknown schema major version")
|
||||
|
||||
reject_secrets(payload)
|
||||
media_ref = str(payload.get("media", {}).get("ref", ""))
|
||||
if (
|
||||
FORBIDDEN_MEDIA_CHARACTER.search(media_ref)
|
||||
or "://" in media_ref
|
||||
or re.match(r"^[A-Za-z]:", media_ref)
|
||||
):
|
||||
raise ValueError("secret, query, authority, or internal path in media reference")
|
||||
|
||||
errors = sorted(VALIDATOR.iter_errors(payload), key=lambda error: list(error.absolute_path))
|
||||
if errors:
|
||||
first = errors[0]
|
||||
location = ".".join(str(part) for part in first.absolute_path) or "config"
|
||||
raise ValueError(f"schema validation failed at {location}: {first.message}")
|
||||
|
||||
profile = payload["profile"]
|
||||
binding = payload["rule_set"]["profile_binding"]
|
||||
if (binding["profile_id"], binding["width"], binding["height"]) != (
|
||||
profile["id"],
|
||||
profile["width"],
|
||||
profile["height"],
|
||||
):
|
||||
raise ValueError("profile binding does not match the media profile")
|
||||
|
||||
published_at = datetime.fromisoformat(payload["published_at"].replace("Z", "+00:00"))
|
||||
effective_at = datetime.fromisoformat(payload["effective_at"].replace("Z", "+00:00"))
|
||||
if effective_at < published_at:
|
||||
raise ValueError("effective_at precedes published_at")
|
||||
|
||||
rule_set = payload["rule_set"]
|
||||
rules = [*rule_set["areas"], *rule_set["directional_lines"]]
|
||||
identifiers = [rule["id"] for rule in rules]
|
||||
if len(identifiers) != len(set(identifiers)):
|
||||
raise ValueError("rule ids must be unique across the rule set")
|
||||
if rule_set["state"] == "recalibration_required" and any(rule["enabled"] for rule in rules):
|
||||
raise ValueError("recalibration-required rules must not remain enabled")
|
||||
|
||||
for area in rule_set["areas"]:
|
||||
if polygon_area(area["points"]) <= 1e-12:
|
||||
raise ValueError(f"area {area['id']} is a degenerate polygon")
|
||||
for line in rule_set["directional_lines"]:
|
||||
if line["start"] == line["end"]:
|
||||
raise ValueError(f"directional line {line['id']} has identical endpoints")
|
||||
|
||||
if payload["integrity"]["value"] != integrity_value(payload):
|
||||
raise ValueError("integrity digest mismatch")
|
||||
|
||||
|
||||
def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None:
|
||||
validate_payload(previous)
|
||||
validate_payload(current)
|
||||
if previous["config_id"] != current["config_id"]:
|
||||
raise ValueError("config_id cannot change within one revision stream")
|
||||
if current["revision"] <= previous["revision"]:
|
||||
raise ValueError("revision must increase strictly")
|
||||
|
||||
previous_profile = previous["profile"]
|
||||
current_profile = current["profile"]
|
||||
profile_changed = any(
|
||||
previous_profile[field] != current_profile[field]
|
||||
for field in ("id", "width", "height", "encoding")
|
||||
)
|
||||
previous_rule_versions = sorted(
|
||||
(rule["id"], rule["version"])
|
||||
for rule in [*previous["rule_set"]["areas"], *previous["rule_set"]["directional_lines"]]
|
||||
)
|
||||
current_rule_versions = sorted(
|
||||
(rule["id"], rule["version"])
|
||||
for rule in [*current["rule_set"]["areas"], *current["rule_set"]["directional_lines"]]
|
||||
)
|
||||
if (
|
||||
profile_changed
|
||||
and previous_rule_versions == current_rule_versions
|
||||
and current["rule_set"]["state"] != "recalibration_required"
|
||||
):
|
||||
raise ValueError("profile changed without rule recalibration state or new rule versions")
|
||||
|
||||
|
||||
class SourceConfigV1ContractTests(unittest.TestCase):
|
||||
def test_schema_is_valid_draft_2020_12(self) -> None:
|
||||
Draft202012Validator.check_schema(SCHEMA)
|
||||
|
||||
def test_all_valid_examples_pass_schema_semantics_and_integrity(self) -> None:
|
||||
examples = sorted(VALID_ROOT.glob("*.json"))
|
||||
self.assertGreaterEqual(len(examples), 2)
|
||||
for path in examples:
|
||||
with self.subTest(path=path.name):
|
||||
validate_payload(load_json(path))
|
||||
|
||||
def test_invalid_examples_fail_for_the_declared_reason(self) -> None:
|
||||
expected = load_json(INVALID_ROOT / "expected-errors.json")
|
||||
self.assertGreaterEqual(len(expected), 6)
|
||||
for filename, reason in expected.items():
|
||||
with self.subTest(path=filename):
|
||||
with self.assertRaisesRegex(ValueError, str(reason)):
|
||||
validate_payload(load_json(INVALID_ROOT / filename))
|
||||
|
||||
def test_tampering_is_detected_after_other_validation(self) -> None:
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["revision"] += 1
|
||||
with self.assertRaisesRegex(ValueError, "integrity digest mismatch"):
|
||||
validate_payload(payload)
|
||||
|
||||
def test_namespaced_optional_extensions_are_compatible_but_not_secret_bearing(self) -> None:
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["extensions"] = {"example.analytics": {"samplingHint": "balanced"}}
|
||||
set_integrity(payload)
|
||||
validate_payload(payload)
|
||||
|
||||
payload["extensions"] = {"example.analytics": {"accessToken": "forbidden"}}
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "secret field"):
|
||||
validate_payload(payload)
|
||||
|
||||
def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None:
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["rule_set"]["profile_binding"]["width"] = 1280
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "profile binding"):
|
||||
validate_payload(payload)
|
||||
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["rule_set"]["state"] = "recalibration_required"
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "must not remain enabled"):
|
||||
validate_payload(payload)
|
||||
|
||||
def test_revision_stream_rejects_stale_and_unrecalibrated_profile_change(self) -> None:
|
||||
previous = load_json(VALID_ROOT / "active.json")
|
||||
current = copy.deepcopy(previous)
|
||||
current["revision"] = previous["revision"]
|
||||
set_integrity(current)
|
||||
with self.assertRaisesRegex(ValueError, "revision must increase"):
|
||||
validate_transition(previous, current)
|
||||
|
||||
current["revision"] += 1
|
||||
current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720})
|
||||
current["rule_set"]["profile_binding"].update(
|
||||
{"profile_id": "main-stream-v2", "width": 1280, "height": 720}
|
||||
)
|
||||
set_integrity(current)
|
||||
with self.assertRaisesRegex(ValueError, "without rule recalibration"):
|
||||
validate_transition(previous, current)
|
||||
|
||||
validate_transition(previous, load_json(VALID_ROOT / "recalibration-required.json"))
|
||||
|
||||
def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None:
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["rule_set"]["areas"][0]["points"] = [
|
||||
{"x": 0, "y": 0},
|
||||
{"x": 0.5, "y": 0.5},
|
||||
{"x": 1, "y": 1},
|
||||
]
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "degenerate polygon"):
|
||||
validate_payload(payload)
|
||||
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["rule_set"]["directional_lines"][0]["id"] = payload["rule_set"]["areas"][0]["id"]
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "ids must be unique"):
|
||||
validate_payload(payload)
|
||||
|
||||
def test_effective_time_cannot_precede_publication(self) -> None:
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["effective_at"] = "2026-08-30T23:59:59Z"
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "precedes"):
|
||||
validate_payload(payload)
|
||||
|
||||
def test_shared_payload_does_not_claim_either_product_internal_model(self) -> None:
|
||||
for path in sorted(VALID_ROOT.glob("*.json")):
|
||||
serialized = json.dumps(load_json(path), ensure_ascii=False).lower()
|
||||
self.assertNotIn("brain.internal.input", serialized)
|
||||
self.assertNotIn("streamuri", serialized)
|
||||
self.assertNotIn("profiletoken", serialized)
|
||||
self.assertNotIn("database", serialized)
|
||||
self.assertNotRegex(serialized, r"[a-z]:\\")
|
||||
|
||||
def test_mapper_documents_both_product_test_responsibilities(self) -> None:
|
||||
mapper = (CONTRACT_ROOT / "mapper-fields.md").read_text(encoding="utf-8")
|
||||
self.assertIn("Sense 生产者契约测试", mapper)
|
||||
self.assertIn("Brain 消费者契约测试", mapper)
|
||||
self.assertIn("brain.internal.input/v1", mapper)
|
||||
self.assertIn("admissionProfile.StreamURI", mapper)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,12 @@
|
||||
# Machine transport policy v1
|
||||
|
||||
`yovision.transport/v1` applies to every connector protected by machine identity v1.
|
||||
|
||||
- HTTPS is mandatory. TLS 1.2 is the minimum and TLS 1.3 is preferred.
|
||||
- Certificate-chain and hostname verification are mandatory. `InsecureSkipVerify`, plaintext fallback and query-string credentials are forbidden.
|
||||
- Connection, response-header and total request timeouts are explicit and bounded; consumers enforce a route-specific body limit before decoding.
|
||||
- `X-Request-ID` is an opaque 16–128 character correlation value. It may be generated by the caller or first trusted hop, is never an authentication factor, and must not contain credentials or personal data.
|
||||
- Retry only timeout, connection loss, `429` and `5xx` according to the connector policy. Authentication/authorization failures and contract `4xx` responses are terminal until configuration changes.
|
||||
- Each retry signs a new machine token and `jti`. The business idempotency key and payload remain unchanged.
|
||||
|
||||
Transport failures must not start Sense, Brain or Bell with weakened authentication. Rollback disables the connector and preserves local facts, Outbox/Receipt state and last-known-good configuration.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/transport/v1/transport-policy.schema.json",
|
||||
"title": "YoVision machine transport policy v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "tls_min_version", "verify_certificate", "verify_hostname", "connect_timeout_ms", "response_header_timeout_ms", "request_timeout_ms", "max_request_bytes"],
|
||||
"properties": {
|
||||
"version": {"const": "yovision.transport/v1"},
|
||||
"tls_min_version": {"enum": ["1.2", "1.3"]},
|
||||
"verify_certificate": {"const": true},
|
||||
"verify_hostname": {"const": true},
|
||||
"connect_timeout_ms": {"type": "integer", "minimum": 100, "maximum": 30000},
|
||||
"response_header_timeout_ms": {"type": "integer", "minimum": 100, "maximum": 30000},
|
||||
"request_timeout_ms": {"type": "integer", "minimum": 100, "maximum": 60000},
|
||||
"max_request_bytes": {"type": "integer", "minimum": 1, "maximum": 10485760}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 14b961599d6954357142713a5667fb37d38e86b7
|
||||
synchronized_at: 2026-08-29T12:37:31Z
|
||||
wiki_revision: 41c2193b2f1edb37abe1e8994d65d02207549039
|
||||
synchronized_at: 2026-08-31T03:18:12Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -296,3 +296,61 @@ Brain 解码层位于 `Brain/src/yovision_brain/decode/`,只依赖 #11 的内
|
||||
|
||||
内部候选包含逻辑输入引用、规则/模型版本、发生时间、匿名框和解释原因,不包含摄像头凭据、客户隐私、人脸、生物特征、机器绝对路径或证据引用。该格式不是 Brain→Bell 共享契约;Bell API、Outbox、机器身份、证据和跨项目投递必须由协调工单另行实现。
|
||||
<!-- brain-local-events-v1:end -->
|
||||
|
||||
<!-- sense-brain-contracts-v1:start -->
|
||||
## Sense↔Brain v1 契约边界
|
||||
|
||||
- Sense→Brain 配置:`contracts/source-config/v1/source-config.schema.json`;版本 `yovision.source-config/v1`。
|
||||
- Brain→Sense 状态:`contracts/runtime-status/v1/runtime-status.schema.json`;版本 `yovision.runtime-status/v1`。
|
||||
- 共同测试:`contracts/tests/source-config-v1/`、`contracts/tests/runtime-status-v1/`。
|
||||
- 生产者/消费者 mapper 责任分别记录在 `mapper-fields.md` 与 `mapping.md`;产品 adapter 后续由 #152 实现。
|
||||
|
||||
数据流固定为:
|
||||
|
||||
```text
|
||||
Sense Device/Profile/Area 内部事实
|
||||
→ source-config/v1 mapper
|
||||
→ Brain adapter(后续 #152)
|
||||
→ Brain 内部配置与运行
|
||||
→ runtime-status/v1 mapper
|
||||
→ Sense 只读运维投影(后续 #152)
|
||||
```
|
||||
|
||||
共享契约统一使用 snake_case 与 `schema_version: yovision.<contract>/v1`。源配置使用 `config_id + integer revision`;运行状态以 `configurations[]` 按 `config_id` 回报实际应用 revision。未知主版本、重复配置 ID、倒序状态、摘要失败或敏感字段必须拒绝,且不得覆盖最后已知有效配置/投影。
|
||||
|
||||
协议不得包含摄像头凭据、RTSP URL、query token、内部绝对路径、数据库模型、用户/JWT/Cookie 或 Bell Alert 语义。当前只冻结契约,没有新增网络端点、机器身份或跨端 connector。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
<!-- standard-event-evidence-v1:start -->
|
||||
## 标准事件与证据 v1 契约边界
|
||||
|
||||
- Event Schema:`contracts/events/v1/event.schema.json`,版本 `yovision.event/v1`。
|
||||
- Bell 接入描述:`contracts/events/v1/openapi.json`,返回创建、重复、幂等冲突和不支持版本等明确结果。
|
||||
- Evidence Schema/API:`contracts/evidence/v1/evidence-reference.schema.json`、`openapi.json`,版本 `yovision.evidence-reference/v1`。
|
||||
- 共同测试:`contracts/tests/events-v1/`、`contracts/tests/evidence-v1/`。
|
||||
|
||||
后续 #153 的映射流固定为:
|
||||
|
||||
```text
|
||||
Brain internal candidate / Sense local event
|
||||
→ yovision.event/v1 producer mapper
|
||||
→ Sense Outbox relay(默认拓扑,保持原 producer/source ID)
|
||||
→ Bell v1 ingress
|
||||
→ Bell private immutable Event + permanent Receipt
|
||||
→ Bell private Rule / Alert / ack / close
|
||||
```
|
||||
|
||||
规范载荷使用 RFC 8785 JCS 与 SHA-256 形成稳定摘要。同键同摘要返回原 Event;同键不同摘要返回冲突并审计,不覆盖原事实。Evidence 只提供逻辑引用与状态/完整性元数据,不授予访问权限,不包含本机路径、签名 URL 或凭据;取证授权由后续机器身份和 connector 工单实现。
|
||||
|
||||
Brain candidate、Sense candidate/Outbox 与 Bell Event/Receipt/Alert 继续是各自内部模型。当前没有新增可运行的跨端 ingress/relay,不能把冻结 Schema 解释为端到端链路已完成。
|
||||
<!-- standard-event-evidence-v1:end -->
|
||||
|
||||
<!-- machine-identity-v1:start -->
|
||||
## 三项目机器身份与安全传输 v1
|
||||
|
||||
工单 #151 已于 2026-08-31 验收并通过 PR #162 合入 `dev`。共享事实源为 `contracts/machine-identity/v1/**` 与 `contracts/transport/v1/**`;Sense、Brain、Bell 分别在自己的 `integration/machine_identity/` 中保留独立适配,不共享用户、JWT、Cookie、Casbin、数据库或业务实现。
|
||||
|
||||
v1 使用 HTTPS 上的 Ed25519 短期请求绑定 JWS。每个部署实例拥有独立 principal、`kid` 和仓库外私钥;消费者使用本地外部公钥注册表,按精确 audience 与最小 scope 授权。令牌绑定 HTTP 方法、规范化路径和正文 SHA-256,有效期最多 300 秒、时钟偏差最多 30 秒,并以 `(principal,jti)` 原子防重放。TLS 最低 1.2,证书链与主机名验证不可关闭。
|
||||
|
||||
Sense/Bell 使用 Go 标准库 Ed25519,Brain 冻结 `cryptography==50.0.1`。固定跨语言向量证明 Go/Python 可互相验签。#151 只提供身份、注册表、传输策略及可注入 replay 接口;#152/#153 才注册业务 endpoint,并必须使用各产品独立的持久原子 replay store验证重启,不能共享数据库。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 27749cbf699093d997284afc277ea53e73a5876f
|
||||
synchronized_at: 2026-08-29T12:37:41Z
|
||||
wiki_revision: 7a91edf3ca35ade3e254937c4a68b53d816a3eea
|
||||
synchronized_at: 2026-08-31T03:18:24Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -233,3 +233,44 @@ synchronized_at: 2026-08-29T12:37:41Z
|
||||
- 确认和恢复都要求 6–256 字符原因、当前版本和允许的状态;旧版本或错误状态返回冲突。所有动作写入独立流转历史和 GoAdmin 操作审计。
|
||||
- 运维告警永远设置为 Sense 内部运维记录,不创建本地安全事件或 Bell Alert,不进入跨项目 Outbox,也不实现通知升级。
|
||||
<!-- sense-ops-alerts:end -->
|
||||
|
||||
<!-- sense-brain-contracts-v1:start -->
|
||||
## Sense↔Brain 配置与状态规则
|
||||
|
||||
- **配置流**:由稳定 `config_id` 和严格递增的正整数 `revision` 标识;revision 不得复用或倒退。
|
||||
- **无凭据媒体引用**:`media.ref` 是由后续 connector 解析的不透明逻辑引用,不是 RTSP URL、本机路径或数据库主键。
|
||||
- **Profile 绑定**:规则集必须与 Profile ID、宽高一致;Profile 变化必须形成新 revision,并在需要时标记 `recalibration_required`,旧几何不得静默重投影。
|
||||
- **规则坐标**:区域与方向线使用 0–1 归一化坐标,规则 ID 在同一规则集内唯一;退化多边形和重合线端点无效。
|
||||
- **完整性**:源配置对移除 `integrity` 后的 JCS 表示计算 SHA-256;校验失败保留上一有效 revision。
|
||||
- **配置应用状态**:Brain 在 `configurations[]` 中按 `config_id` 报告 `not_configured/applying/applied/rejected` 与实际 `applied_revision`;同一消息重复 ID 整条拒绝。
|
||||
- **状态时序**:Brain 实例 sequence 单调递增;Sense 拒绝倒序消息。观测时间超过约定 90 秒时由 Sense 标记陈旧,不用未知值覆盖最后已知投影。
|
||||
- **状态边界**:运行/健康错误只形成 Sense 运维投影,不是业务 Event 或 Bell Alert;不得包含用户会话、凭据、内部路径或客户视频。
|
||||
- **版本兼容**:v1 只接受已冻结语义;破坏性字段或语义变化发布新主版本。未知主版本停止摄取并保留上一有效事实。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
<!-- standard-event-evidence-v1:start -->
|
||||
## 标准事件、证据和幂等规则
|
||||
|
||||
- **标准 Event**:匿名、不可变的跨产品安全事实,不是 Bell Alert,也不携带处置或通知状态。
|
||||
- **原始生产者**:`producer_id` 始终标识最初产生事件的 Brain 或 Sense 实例;relay 使用独立传输身份,但不得替换业务生产者。
|
||||
- **永久幂等键**:精确 UTF-8 对 `(producer_id, source_event_id)`。重试沿用同一键,不生成新事件。
|
||||
- **规范摘要**:完整 Event 使用 RFC 8785 JCS 规范化后计算 SHA-256。同键同摘要为重复成功;同键异摘要为终止性冲突,并追加脱敏审计。
|
||||
- **时间格式**:Event v1 使用 UTC RFC 3339、三位毫秒和 `Z`;可选字段缺失时省略,不发送 null。
|
||||
- **证据引用**:`evidence_id` 与 `owner_id` 是不透明逻辑引用,不是 URL、文件路径或访问凭据。
|
||||
- **证据状态**:`pending → processing → success|failed`。success 要求内容类型和摘要/大小;failed 要求稳定错误码和是否可重试。
|
||||
- **降级原则**:证据失败、未知或过期不删除 Event,不自动关闭 Alert,也不伪装成完整成功。
|
||||
- **Bell 所有权**:Bell 独占内部 Event/Receipt、规则、Alert、ack、close、通知与用户审计;上游不得写入这些状态。
|
||||
- **兼容与回退**:未知主版本终止接收但保留已有事实;破坏性变化发布新主版本。回退停用新生产者版本,不删除 Outbox、Receipt、Event 或审计。
|
||||
<!-- standard-event-evidence-v1:end -->
|
||||
|
||||
<!-- machine-identity-v1:start -->
|
||||
## 机器身份、权限和重放规则
|
||||
|
||||
- 机器 principal 格式为 `yv:<sense|brain|bell>:<instance>`,每个产品实例独立;不得替代、携带或映射 Sense/Bell 用户身份。
|
||||
- v1 audience 只允许 `yovision-sense`、`yovision-brain`、`yovision-bell`;scope 只允许 `source-config:write`、`runtime-status:write`、`events:ingest`、`evidence:read`,没有通配符。
|
||||
- 机器令牌只能从 Authorization Bearer 读取,不接受管理员密码、浏览器 JWT/Cookie、query token、共享 secret 或其他产品用户身份。
|
||||
- 相同 `(principal,jti)` 只能成功一次;传输重试必须签发新令牌和 jti,但业务幂等键与载荷保持不变。
|
||||
- 私钥只存在于仓库外受操作系统保护的文件或秘密存储;运行配置只引用路径。公钥注册表属于各消费者本地配置,不是共享数据库。
|
||||
- 轮换先登记新 `kid`,最多并存 24 小时,切换后移除旧 key;禁用 principal 或吊销 `kid` 对每次请求即时生效。
|
||||
- 回退只能关闭 connector 并恢复三端独立运行,不得降级为明文、共享管理员身份、共享 JWT 或跳过签名/TLS 验证。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: e6068ff0e42765d32ff4e0ee0e8e51cf7d79b7da
|
||||
synchronized_at: 2026-08-29T12:37:58Z
|
||||
wiki_revision: 771fdd92eb03e9dff7d3ae20b7cb89f1ca288959
|
||||
synchronized_at: 2026-08-31T03:18:34Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -624,3 +624,85 @@ Brain\.venv\Scripts\python.exe -m yovision_brain.app --config Brain\tests\fixtur
|
||||
|
||||
CLI 将内部事件 JSON Lines 写入 stdout,并把 completed/cancelled、帧数、检测数和事件数摘要写入 stderr。配置文件必须显式提供,当前使用 JSON;无命中正常返回零事件,读取/配置/模块失败返回非零且不回显机器路径。命令不启动 Sense/Bell、不连接摄像头或网络。
|
||||
<!-- brain-local-events-v1:end -->
|
||||
|
||||
<!-- sense-brain-contracts-v1:start -->
|
||||
## Sense↔Brain v1 契约验证
|
||||
|
||||
源/规则配置契约:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File contracts/tests/source-config-v1/run.ps1
|
||||
```
|
||||
|
||||
脚本在系统临时目录创建隔离虚拟环境,按固定依赖运行 Schema、跨字段语义、JCS/SHA-256、版本/重校准和秘密拒绝测试,结束后清理所属临时目录。
|
||||
|
||||
运行状态契约不需要第三方包:
|
||||
|
||||
```powershell
|
||||
python contracts/tests/runtime-status-v1/test_contract.py
|
||||
```
|
||||
|
||||
测试覆盖六态运行状态、30 秒未来时间偏差、90 秒陈旧边界、空/多配置流、四种配置应用状态、重复 `config_id`、integer revision mismatch、倒序消息、未知主版本、回退保留和敏感字段拒绝。
|
||||
|
||||
仓库级复核:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tests -v
|
||||
python dev_scripts/harness.py check --strict
|
||||
git diff --check
|
||||
```
|
||||
|
||||
这些命令只验证冻结契约,不验证 #152 产品 adapter、真实网络传输、机器身份、现场断网恢复或端到端链路。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
<!-- standard-event-evidence-v1:start -->
|
||||
## 标准事件与证据 v1 契约验证
|
||||
|
||||
两组测试均只使用 Python 标准库:
|
||||
|
||||
```powershell
|
||||
python contracts/tests/events-v1/test_contract.py
|
||||
python contracts/tests/evidence-v1/test_contract.py
|
||||
```
|
||||
|
||||
事件测试覆盖匿名危险区域/方向越线样例、Brain producer→Sense relay→Bell consumer mapper fixture、RFC 8785/SHA-256 幂等向量、重复/冲突、未知版本、敏感字段拒绝和 OpenAPI 引用。
|
||||
|
||||
证据测试覆盖 `pending/processing/success/failed` 状态约束、success 完整性、失败降级、旧 `available` 状态拒绝、敏感访问材料拒绝和证据 API 响应引用。
|
||||
|
||||
仓库级复核:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tests -v
|
||||
python dev_scripts/harness.py check --strict
|
||||
git diff --check
|
||||
```
|
||||
|
||||
这些测试只验证冻结契约,不验证 #153 产品 mapper/relay/ingress、#151 机器身份、实际证据存储/授权、网络断线补投或跨项目 E2E。
|
||||
<!-- standard-event-evidence-v1:end -->
|
||||
|
||||
<!-- machine-identity-v1:start -->
|
||||
## 机器身份与安全传输 v1 验证
|
||||
|
||||
从仓库根目录执行隔离契约测试:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File contracts/tests/machine-identity-v1/run.ps1
|
||||
```
|
||||
|
||||
脚本在系统临时目录建立虚拟环境,按固定 `cryptography==50.0.1` 和 `jsonschema==4.25.1` 验证封闭 Schema、Go/Python 固定 Ed25519 向量、请求绑定、Bearer-only、错 audience/scope、过期、重放、轮换、吊销和 TLS policy,结束后删除所属临时目录。
|
||||
|
||||
三端定向验证:
|
||||
|
||||
```powershell
|
||||
cd Sense/server
|
||||
go test -race ./app/sense/integration/machine_identity
|
||||
|
||||
cd ../../Bell/server
|
||||
go test -race ./app/bell/integration/machine_identity
|
||||
|
||||
cd ../..
|
||||
Brain\.venv\Scripts\python.exe contracts\tests\machine-identity-v1\test_contract.py
|
||||
```
|
||||
|
||||
这些测试只证明 #151 身份和传输基础。真实客户 PKI/网络、现场时钟漂移、业务 endpoint、断网补投以及持久 replay 重启恢复由 #152/#153/#155 验证;不得用进程内 replay store替代生产结论。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user