From 4688abadefcb5109209b82406e88140097921c2e Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 15:44:29 +0800 Subject: [PATCH] feat(auth): enforce vehicle grant time boundaries --- .../apps/api/internal/app/auth_store.go | 63 ++++++- .../apps/api/internal/app/auth_store_test.go | 95 ++++++++++ .../apps/api/internal/platform/handler.go | 2 + .../api/internal/platform/mysql_queries.go | 39 ++++ .../apps/api/internal/platform/principal.go | 48 +++-- .../internal/platform/query_builders_test.go | 36 ++++ .../apps/api/internal/platform/service.go | 169 +++++++++++++++++- .../api/internal/platform/service_test.go | 108 ++++++++++- .../014_customer_vehicle_grant_time.sql | 44 +++++ .../docs/authentication-authorization.md | 6 +- vehicle-data-platform/docs/deployment.md | 5 +- 11 files changed, 580 insertions(+), 35 deletions(-) create mode 100644 vehicle-data-platform/apps/api/internal/app/auth_store_test.go create mode 100644 vehicle-data-platform/deploy/migrations/014_customer_vehicle_grant_time.sql diff --git a/vehicle-data-platform/apps/api/internal/app/auth_store.go b/vehicle-data-platform/apps/api/internal/app/auth_store.go index baef7c01..c3a0285d 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_store.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_store.go @@ -320,6 +320,7 @@ func (s *authStore) localCredential(ctx context.Context, username string) (local func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) { menus := append([]string(nil), adminMenus...) vehicles := []string{} + vehicleGrants := []platform.VehicleGrant{} if user.UserType == "customer" { menus = []string{} rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID) @@ -339,17 +340,24 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo if err := rows.Close(); err != nil { return platform.Principal{}, err } - rows, err = s.db.QueryContext(ctx, `SELECT vin FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID) + rows, err = s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID) if err != nil { return platform.Principal{}, err } for rows.Next() { var vin string - if err := rows.Scan(&vin); err != nil { + var validFrom time.Time + var validTo sql.NullTime + if err := rows.Scan(&vin, &validFrom, &validTo); err != nil { rows.Close() return platform.Principal{}, err } vehicles = append(vehicles, vin) + grant := platform.VehicleGrant{VIN: vin, ValidFrom: validFrom} + if validTo.Valid { + grant.ValidTo = &validTo.Time + } + vehicleGrants = append(vehicleGrants, grant) } if err := rows.Close(); err != nil { return platform.Principal{}, err @@ -358,7 +366,7 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo return platform.Principal{ SubjectID: strconv.FormatUint(user.ID, 10), Name: user.DisplayName, Username: user.Username, Role: user.UserType, UserType: user.UserType, CustomerRef: user.CustomerRef, TenantRef: user.TenantRef, - AuthProvider: user.AuthProvider, MenuKeys: menus, VehicleVINs: vehicles, + AuthProvider: user.AuthProvider, MenuKeys: menus, VehicleVINs: vehicles, VehicleGrants: vehicleGrants, }, nil } @@ -626,11 +634,56 @@ func replaceGrants(ctx context.Context, tx *sql.Tx, userID uint64, menus, vins [ return err } } - if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=?`, userID); err != nil { + currentVINs := map[string]bool{} + rows, err := tx.QueryContext(ctx, `SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`, userID) + if err != nil { return err } + for rows.Next() { + var vin string + if err := rows.Scan(&vin); err != nil { + rows.Close() + return err + } + currentVINs[strings.ToUpper(strings.TrimSpace(vin))] = true + } + if err := rows.Close(); err != nil { + return err + } + selectedVINs := make(map[string]bool, len(vins)) for _, vin := range vins { - if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle(user_id,vin,source_system,granted_by) VALUES(?,?,'manual',?)`, userID, vin, actor); err != nil { + selectedVINs[vin] = true + } + if len(vins) == 0 { + if _, err := tx.ExecContext(ctx, `UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL`, actor, userID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=?`, userID); err != nil { + return err + } + } else { + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(vins)), ",") + args := make([]any, 0, len(vins)+1) + args = append(args, userID) + for _, vin := range vins { + args = append(args, vin) + } + historyArgs := append([]any{actor}, args...) + if _, err := tx.ExecContext(ctx, `UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (`+placeholders+`)`, historyArgs...); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (`+placeholders+`)`, args...); err != nil { + return err + } + } + for _, vin := range vins { + if currentVINs[vin] && selectedVINs[vin] { + continue + } + if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle(user_id,vin,valid_from,source_system,granted_by) VALUES(?,?,NOW(3),'manual',?)`, userID, vin, actor); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle_grant_history(user_id,vin,valid_from,source_system,external_ref,granted_by) SELECT user_id,vin,valid_from,source_system,external_ref,granted_by FROM platform_user_vehicle WHERE user_id=? AND vin=?`, userID, vin); err != nil { return err } } diff --git a/vehicle-data-platform/apps/api/internal/app/auth_store_test.go b/vehicle-data-platform/apps/api/internal/app/auth_store_test.go new file mode 100644 index 00000000..c20e17e5 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/auth_store_test.go @@ -0,0 +1,95 @@ +package app + +import ( + "context" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestReplaceGrantsClosesRemovedHistoryAndCreatesNewInterval(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + mock.ExpectBegin() + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)). + WithArgs(uint64(7)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)). + WithArgs(uint64(7), "monitor", "admin-a"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`)). + WithArgs(uint64(7)). + WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001")) + mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (?)`)). + WithArgs("admin-a", uint64(7), "VIN002"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)). + WithArgs(uint64(7), "VIN002"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle(user_id,vin,valid_from,source_system,granted_by) VALUES(?,?,NOW(3),'manual',?)`)). + WithArgs(uint64(7), "VIN002", "admin-a"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle_grant_history(user_id,vin,valid_from,source_system,external_ref,granted_by) SELECT user_id,vin,valid_from,source_system,external_ref,granted_by FROM platform_user_vehicle WHERE user_id=? AND vin=?`)). + WithArgs(uint64(7), "VIN002"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []string{"VIN002"}, "admin-a"); err != nil { + t.Fatalf("replaceGrants failed: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestReplaceGrantsPreservesUnchangedVehicleStartTime(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + mock.ExpectBegin() + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)). + WithArgs(uint64(7)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)). + WithArgs(uint64(7), "monitor", "admin-a"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`)). + WithArgs(uint64(7)). + WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001")) + mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (?)`)). + WithArgs("admin-a", uint64(7), "VIN001"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)). + WithArgs(uint64(7), "VIN001"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + + if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []string{"VIN001"}, "admin-a"); err != nil { + t.Fatalf("replaceGrants failed: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index d1e8aef8..cc48ea4d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -482,6 +482,8 @@ func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err er switch { case clientErr.Code == "VEHICLE_PERMISSION_DENIED": status = http.StatusForbidden + case strings.HasPrefix(clientErr.Code, "HISTORY_SCOPE_"), clientErr.Code == "HISTORY_BEFORE_AUTHORIZATION": + status = http.StatusForbidden case strings.HasSuffix(clientErr.Code, "_NOT_FOUND"): status = http.StatusNotFound case strings.HasSuffix(clientErr.Code, "_CONFLICT"), clientErr.Code == "ALERT_ACTION_NOT_ALLOWED": diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index dd6b92ad..7fba5f81 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -1,7 +1,9 @@ package platform import ( + "encoding/json" "net/url" + "sort" "strconv" "strings" ) @@ -455,6 +457,7 @@ func buildDailyMileageSQL(query url.Values) SQLQuery { } where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins")) where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins")) + where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", query.Get("scopeVehicleFrom")) protocols := parseMileageProtocols(query.Get("protocols")) if len(protocols) > 0 { where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) @@ -517,6 +520,7 @@ func buildMileageSummarySQL(query url.Values) SQLQuery { } where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins")) where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins")) + where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", query.Get("scopeVehicleFrom")) if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 { where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) } else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { @@ -552,6 +556,7 @@ func buildMileageStatisticsWhere(query url.Values) (string, []any) { } where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins")) where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins")) + where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", query.Get("scopeVehicleFrom")) if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 { where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) } else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { @@ -700,6 +705,40 @@ func appendVINListFilter(where []string, args []any, column string, raw string) return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args } +func appendVehicleDateScope(where []string, args []any, vinColumn, dateColumn, raw string) ([]string, []any) { + raw = strings.TrimSpace(raw) + if raw == "" { + return where, args + } + var parsed map[string]string + if err := json.Unmarshal([]byte(raw), &parsed); err != nil || len(parsed) == 0 { + return append(where, "1 = 0"), args + } + cutoffs := make(map[string]string, len(parsed)) + for vin, cutoff := range parsed { + vin = strings.ToUpper(strings.TrimSpace(vin)) + cutoff = strings.TrimSpace(cutoff) + if vin == "" || cutoff == "" { + continue + } + cutoffs[vin] = cutoff + } + if len(cutoffs) == 0 { + return append(where, "1 = 0"), args + } + vins := make([]string, 0, len(cutoffs)) + for vin := range cutoffs { + vins = append(vins, vin) + } + sort.Strings(vins) + predicates := make([]string, 0, len(vins)) + for _, vin := range vins { + predicates = append(predicates, "("+vinColumn+" = ? AND "+dateColumn+" >= ?)") + args = append(args, vin, cutoffs[vin]) + } + return append(where, "("+strings.Join(predicates, " OR ")+")"), args +} + func buildLimitOffset(query url.Values) (int, int) { return parseSQLPageSize(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0) } diff --git a/vehicle-data-platform/apps/api/internal/platform/principal.go b/vehicle-data-platform/apps/api/internal/platform/principal.go index dcf147b1..9cca9808 100644 --- a/vehicle-data-platform/apps/api/internal/platform/principal.go +++ b/vehicle-data-platform/apps/api/internal/platform/principal.go @@ -1,25 +1,37 @@ package platform -import "context" +import ( + "context" + "strings" + "time" +) + +type VehicleGrant struct { + VIN string `json:"-"` + ValidFrom time.Time `json:"-"` + ValidTo *time.Time `json:"-"` +} type Principal struct { - SubjectID string `json:"subjectId,omitempty"` - SessionID string `json:"-"` - Name string `json:"name"` - Username string `json:"username,omitempty"` - Role string `json:"role"` - UserType string `json:"userType"` - CustomerRef string `json:"customerRef,omitempty"` - TenantRef string `json:"tenantRef,omitempty"` - AuthProvider string `json:"authProvider"` - MenuKeys []string `json:"menuKeys"` - VehicleVINs []string `json:"-"` - VehicleCount int `json:"vehicleCount"` + SubjectID string `json:"subjectId,omitempty"` + SessionID string `json:"-"` + Name string `json:"name"` + Username string `json:"username,omitempty"` + Role string `json:"role"` + UserType string `json:"userType"` + CustomerRef string `json:"customerRef,omitempty"` + TenantRef string `json:"tenantRef,omitempty"` + AuthProvider string `json:"authProvider"` + MenuKeys []string `json:"menuKeys"` + VehicleVINs []string `json:"-"` + VehicleGrants []VehicleGrant `json:"-"` + VehicleCount int `json:"vehicleCount"` } func (p Principal) Clone() Principal { p.MenuKeys = append([]string(nil), p.MenuKeys...) p.VehicleVINs = append([]string(nil), p.VehicleVINs...) + p.VehicleGrants = append([]VehicleGrant(nil), p.VehicleGrants...) p.VehicleCount = len(p.VehicleVINs) return p } @@ -48,6 +60,16 @@ func (p Principal) CanVIN(vin string) bool { return false } +func (p Principal) VehicleGrant(vin string) (VehicleGrant, bool) { + vin = strings.ToUpper(strings.TrimSpace(vin)) + for _, grant := range p.VehicleGrants { + if strings.ToUpper(strings.TrimSpace(grant.VIN)) == vin { + return grant, true + } + } + return VehicleGrant{}, false +} + type principalContextKey struct{} func WithPrincipal(ctx context.Context, principal Principal) context.Context { diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 9ecb7c8b..f1627de7 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -331,6 +331,42 @@ func TestMileageQueriesFilterExactVINSelection(t *testing.T) { } } +func TestMileageQueriesApplyPerVehicleAuthorizationDates(t *testing.T) { + query := url.Values{ + "scopeVins": {"VIN001,VIN002"}, + "scopeVehicleFrom": {`{"vin002":"2026-07-12","VIN001":"2026-07-11"}`}, + } + for name, built := range map[string]SQLQuery{ + "daily": buildDailyMileageSQL(query), + "summary": buildMileageSummarySQL(query), + "statistics": buildMileageStatisticsSummarySQL(query), + } { + for _, want := range []string{ + "(m.vin = ? AND m.stat_date >= ?)", + "VIN001 2026-07-11", + "VIN002 2026-07-12", + } { + text := built.Text + if strings.HasPrefix(want, "VIN") { + text = fmt.Sprint(built.Args) + } + if !strings.Contains(text, want) { + t.Fatalf("%s authorization scope missing %q: %s args=%#v", name, want, built.Text, built.Args) + } + } + } +} + +func TestMileageQueriesFailClosedForMalformedAuthorizationDates(t *testing.T) { + built := buildDailyMileageSQL(url.Values{ + "scopeVins": {"VIN001"}, + "scopeVehicleFrom": {"not-json"}, + }) + if !strings.Contains(built.Text, "1 = 0") || !strings.Contains(built.CountText, "1 = 0") { + t.Fatalf("malformed authorization scope must fail closed: %s / %s", built.Text, built.CountText) + } +} + func TestMileageQueriesCanRestrictFleetScopeToAuthoritativelyBoundVehicles(t *testing.T) { query := url.Values{"vehicleScope": {"bound"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}} for name, built := range map[string]SQLQuery{ diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 5f9984cd..0ad97685 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -920,6 +920,16 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string rawQuery.Protocol = protocol qualityQuery.Set("protocol", protocol) } + historyQuery, err = applyPrincipalHistoryTimeScope(ctx, queryVIN, historyQuery) + if err != nil { + return VehicleDetail{}, err + } + rawQuery.DateFrom = historyQuery.Get("dateFrom") + rawQuery.DateTo = historyQuery.Get("dateTo") + mileageQuery, err = applyPrincipalVehicleScope(ctx, mileageQuery) + if err != nil { + return VehicleDetail{}, err + } realtimeSummary, err := s.store.VehicleRealtime(ctx, url.Values{"vin": {queryVIN}, "limit": {"1"}}) if err != nil { return VehicleDetail{}, err @@ -1273,20 +1283,21 @@ func coverageStatus(sourceCount int, onlineSourceCount int) string { } func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus { + scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{}) for index := range statuses { protocol := statuses[index].Protocol if protocol == "" { continue } - if !statuses[index].HasHistory { - query := url.Values{"vin": {vin}, "protocol": {protocol}, "limit": {"1"}} + if !statuses[index].HasHistory && scopeErr == nil { + query := url.Values{"vin": {vin}, "protocol": {protocol}, "dateFrom": {scopedWindow.Get("dateFrom")}, "limit": {"1"}} if page, err := s.store.HistoryLocationsFromTDengine(ctx, query); err == nil && len(page.Items) > 0 { statuses[index].HasHistory = true statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime)) } } - if !statuses[index].HasRaw { - page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, Limit: 1}) + if !statuses[index].HasRaw && scopeErr == nil { + page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), Limit: 1}) if err == nil && len(page.Items) > 0 { statuses[index].HasRaw = true statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime)) @@ -1347,6 +1358,10 @@ func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[ if err != nil { return Page[HistoryLocationRow]{}, err } + resolvedQuery, err = applyPrincipalHistoryTimeScope(ctx, resolvedQuery.Get("vin"), resolvedQuery) + if err != nil { + return Page[HistoryLocationRow]{}, err + } return s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery) } @@ -1369,6 +1384,10 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla if err != nil { return TrackPlaybackResponse{}, err } + resolvedQuery, err = applyPrincipalHistoryTimeScope(ctx, resolvedQuery.Get("vin"), resolvedQuery) + if err != nil { + return TrackPlaybackResponse{}, err + } resolvedQuery.Set("limit", "5000") resolvedQuery.Set("offset", "0") page, err := s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery) @@ -1401,7 +1420,7 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla Quality: quality, AsOf: time.Now().UTC().Format(time.RFC3339), } - result.Coverage = trackCoverage(query, page.Total, len(rawPoints), len(points), 0, result.Truncated, false) + result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), 0, result.Truncated, false) applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints) if len(points) == 0 { return result, nil @@ -1440,7 +1459,7 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla for index := range result.Stops { result.Stops[index].SampledIndex = nearestSampledIndex((result.Stops[index].startIndex+result.Stops[index].endIndex)/2, sampledIndexes) } - result.Coverage = trackCoverage(query, page.Total, len(rawPoints), len(points), len(result.Points), result.Truncated, result.Sampled) + result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), len(result.Points), result.Truncated, result.Sampled) applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints) result.Coverage.ActualStart = result.Summary.StartTime result.Coverage.ActualEnd = result.Summary.EndTime @@ -1538,11 +1557,15 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates if err := authorizeVehicleVIN(ctx, resolvedVIN); err != nil { return LatestTelemetryResponse{}, err } + scopedWindow, err := applyPrincipalHistoryTimeScope(ctx, resolvedVIN, url.Values{}) + if err != nil { + return LatestTelemetryResponse{}, err + } rawResult := make(chan latestTelemetryRawResult, len(canonicalVehicleProtocols)) catalogResult := make(chan latestTelemetryCatalogResult, 1) for _, protocol := range canonicalVehicleProtocols { go func(protocol string) { - page, queryErr := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, IncludeFields: true, Limit: 5, SkipCount: true}) + page, queryErr := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true}) rawResult <- latestTelemetryRawResult{page: page, err: queryErr} }(protocol) } @@ -1981,7 +2004,17 @@ func (s *Service) HistorySeries(ctx context.Context, query url.Values) (HistoryS if resolveErr != nil { return HistorySeriesResponse{}, resolveErr } - buckets, aggregateErr := store.HistoryLocationSeries(ctx, HistoryLocationSeriesQuery{VIN: vin, Protocol: protocol, DateFrom: dateFrom, DateTo: dateTo, GrainSeconds: grainSeconds}) + if err := authorizeVehicleVIN(ctx, vin); err != nil { + return HistorySeriesResponse{}, err + } + scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{ + "dateFrom": {dateFrom}, + "dateTo": {dateTo}, + }) + if scopeErr != nil { + return HistorySeriesResponse{}, scopeErr + } + buckets, aggregateErr := store.HistoryLocationSeries(ctx, HistoryLocationSeriesQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), GrainSeconds: grainSeconds}) if aggregateErr != nil { return HistorySeriesResponse{}, aggregateErr } @@ -2132,6 +2165,10 @@ func (s *Service) historyDataForVehicle(ctx context.Context, category, keyword s if err != nil { return nil, 0, err } + resolvedQuery, err = applyPrincipalHistoryTimeScope(ctx, resolvedQuery.Get("vin"), resolvedQuery) + if err != nil { + return nil, 0, err + } page, err := s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery) if err != nil { return nil, 0, err @@ -2150,7 +2187,7 @@ func (s *Service) historyDataForVehicle(ctx context.Context, category, keyword s if err != nil { return nil, 0, err } - page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"), IncludeFields: true, Limit: fetchLimit}) + page, err := s.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"), IncludeFields: true, Limit: fetchLimit}) if err != nil { return nil, 0, err } @@ -2170,6 +2207,9 @@ func (s *Service) historyDataForVehicle(ctx context.Context, category, keyword s if err != nil { return nil, 0, err } + if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil { + return nil, 0, err + } page, err := s.store.DailyMileage(ctx, resolvedQuery) if err != nil { return nil, 0, err @@ -3195,6 +3235,18 @@ func (s *Service) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawF if resolvedVIN != "" { query.VIN = resolvedVIN } + if err := authorizeVehicleVIN(ctx, query.VIN); err != nil { + return Page[RawFrameRow]{}, err + } + scoped, err := applyPrincipalHistoryTimeScope(ctx, query.VIN, url.Values{ + "dateFrom": {query.DateFrom}, + "dateTo": {query.DateTo}, + }) + if err != nil { + return Page[RawFrameRow]{}, err + } + query.DateFrom = scoped.Get("dateFrom") + query.DateTo = scoped.Get("dateTo") return s.store.RawFrames(ctx, query) } @@ -3211,6 +3263,9 @@ func (s *Service) MileageSummary(ctx context.Context, query url.Values) (Mileage if err != nil { return MileageSummary{}, err } + if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil { + return MileageSummary{}, err + } return s.store.MileageSummary(ctx, resolvedQuery) } @@ -3219,6 +3274,9 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail if err != nil { return Page[DailyMileageRow]{}, err } + if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil { + return Page[DailyMileageRow]{}, err + } resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery) if err != nil { return Page[DailyMileageRow]{}, err @@ -3235,6 +3293,9 @@ func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (Mile if err != nil { return MileageStatistics{}, err } + if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil { + return MileageStatistics{}, err + } resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery) if err != nil { return MileageStatistics{}, err @@ -3730,9 +3791,99 @@ func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Valu } else { next.Set("scopeVins", strings.Join(principal.VehicleVINs, ",")) } + validFromByVIN := make(map[string]string, len(principal.VehicleGrants)) + shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) + for _, grant := range principal.VehicleGrants { + vin := strings.ToUpper(strings.TrimSpace(grant.VIN)) + if vin == "" || grant.ValidFrom.IsZero() { + continue + } + validFrom := grant.ValidFrom.In(shanghai) + firstFullDay := time.Date(validFrom.Year(), validFrom.Month(), validFrom.Day(), 0, 0, 0, 0, shanghai) + if !validFrom.Equal(firstFullDay) { + firstFullDay = firstFullDay.AddDate(0, 0, 1) + } + validFromByVIN[vin] = firstFullDay.Format("2006-01-02") + } + encoded, err := json.Marshal(validFromByVIN) + if err != nil { + return nil, err + } + next.Set("scopeVehicleFrom", string(encoded)) return next, nil } +func applyPrincipalHistoryTimeScope(ctx context.Context, vin string, query url.Values) (url.Values, error) { + next := cloneValues(query) + principal, ok := PrincipalFromContext(ctx) + if !ok || principal.UserType != "customer" { + return next, nil + } + grant, ok := principal.VehicleGrant(vin) + if !ok || grant.ValidFrom.IsZero() { + return nil, clientError{Code: "HISTORY_SCOPE_UNAVAILABLE", Message: "当前车辆缺少可验证的历史授权起始时间"} + } + cutoff := grant.ValidFrom + var requestedStart time.Time + if value := strings.TrimSpace(next.Get("dateTo")); value != "" { + end, parsed := parseTrackRequestTime(value) + if !parsed { + return nil, clientError{Code: "TRACK_TIME_INVALID", Message: "轨迹时间格式无效"} + } + if !end.After(cutoff) { + return nil, clientError{Code: "HISTORY_BEFORE_AUTHORIZATION", Message: "所选时间范围早于该车辆的授权开始时间"} + } + } + if value := strings.TrimSpace(next.Get("dateFrom")); value != "" { + start, parsed := parseTrackRequestTime(value) + if !parsed { + return nil, clientError{Code: "TRACK_TIME_INVALID", Message: "轨迹时间格式无效"} + } + requestedStart = start + } + if grant.ValidTo != nil && !grant.ValidTo.IsZero() { + if !requestedStart.IsZero() && !requestedStart.Before(*grant.ValidTo) { + return nil, clientError{Code: "HISTORY_AFTER_AUTHORIZATION", Message: "所选时间范围晚于该车辆的授权结束时间"} + } + if value := strings.TrimSpace(next.Get("dateTo")); value == "" { + next.Set("dateTo", grant.ValidTo.Format(time.RFC3339)) + } else if end, _ := parseTrackRequestTime(value); end.After(*grant.ValidTo) { + next.Set("dateTo", grant.ValidTo.Format(time.RFC3339)) + } + } + if !requestedStart.IsZero() && !requestedStart.Before(cutoff) { + return next, nil + } + next.Set("dateFrom", cutoff.Format(time.RFC3339)) + return next, nil +} + +func requirePrincipalHistoricalGrantScope(ctx context.Context, query url.Values) error { + principal, ok := PrincipalFromContext(ctx) + if !ok || principal.UserType != "customer" { + return nil + } + requested := make([]string, 0) + for _, raw := range []string{query.Get("vin"), query.Get("vins")} { + for _, vin := range strings.Split(raw, ",") { + vin = strings.ToUpper(strings.TrimSpace(vin)) + if vin != "" { + requested = append(requested, vin) + } + } + } + if len(requested) == 0 { + requested = principal.VehicleVINs + } + for _, vin := range requested { + grant, exists := principal.VehicleGrant(vin) + if !exists || grant.ValidFrom.IsZero() { + return clientError{Code: "HISTORY_SCOPE_UNAVAILABLE", Message: "当前车辆缺少可验证的历史授权起始时间"} + } + } + return nil +} + func authorizeVehicleVIN(ctx context.Context, vin string) error { principal, ok := PrincipalFromContext(ctx) if !ok || principal.UserType != "customer" { diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index 1d54afe5..2962ceab 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -48,11 +48,14 @@ func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) { type countingStore struct { *MockStore - vehiclesCalls int - vehicleRealtimeCalls int - overviewBatchCalls int - lastVehicleQuery url.Values - lastRealtimeQuery url.Values + vehiclesCalls int + vehicleRealtimeCalls int + overviewBatchCalls int + lastVehicleQuery url.Values + lastRealtimeQuery url.Values + lastHistoryQuery url.Values + lastDailyMileageQuery url.Values + lastMileageStatisticsQuery url.Values } func newCountingStore() *countingStore { @@ -71,6 +74,21 @@ func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) ( return s.MockStore.VehicleRealtime(ctx, query) } +func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) { + s.lastHistoryQuery = cloneValues(query) + return s.MockStore.HistoryLocationsFromTDengine(ctx, query) +} + +func (s *countingStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) { + s.lastDailyMileageQuery = cloneValues(query) + return s.MockStore.DailyMileage(ctx, query) +} + +func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) { + s.lastMileageStatisticsQuery = cloneValues(query) + return s.MockStore.MileageStatistics(ctx, query) +} + func TestCustomerVehicleScopeIsInjectedAndExplicitBypassIsDenied(t *testing.T) { store := newCountingStore() service := NewService(store) @@ -88,6 +106,86 @@ func TestCustomerVehicleScopeIsInjectedAndExplicitBypassIsDenied(t *testing.T) { } } +func TestCustomerHistoryIsClampedToVehicleGrantStart(t *testing.T) { + store := newCountingStore() + service := NewService(store) + validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) + principal := Principal{ + Name: "客户甲", Role: "customer", UserType: "customer", + VehicleVINs: []string{"LB9A32A24R0LS1426"}, + VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}}, + } + ctx := WithPrincipal(context.Background(), principal) + _, err := service.HistoryLocations(ctx, url.Values{ + "vin": {"LB9A32A24R0LS1426"}, + "dateFrom": {"2026-07-01T00:00:00+08:00"}, + "dateTo": {"2026-07-11T00:00:00+08:00"}, + }) + if err != nil { + t.Fatalf("history query failed: %v", err) + } + if got := store.lastHistoryQuery.Get("dateFrom"); got != validFrom.Format(time.RFC3339) { + t.Fatalf("dateFrom=%q want=%q", got, validFrom.Format(time.RFC3339)) + } +} + +func TestCustomerHistoryBeforeGrantIsForbidden(t *testing.T) { + service := NewService(newCountingStore()) + validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) + ctx := WithPrincipal(context.Background(), Principal{ + Name: "客户甲", Role: "customer", UserType: "customer", + VehicleVINs: []string{"LB9A32A24R0LS1426"}, + VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}}, + }) + _, err := service.HistoryLocations(ctx, url.Values{ + "vin": {"LB9A32A24R0LS1426"}, + "dateFrom": {"2026-07-01T00:00:00+08:00"}, + "dateTo": {"2026-07-10T12:34:56+08:00"}, + }) + clientErr, ok := asClientError(err) + if !ok || clientErr.Code != "HISTORY_BEFORE_AUTHORIZATION" { + t.Fatalf("expected HISTORY_BEFORE_AUTHORIZATION, err=%v", err) + } +} + +func TestCustomerMileageStartsAtFirstCompleteAuthorizedDay(t *testing.T) { + store := newCountingStore() + service := NewService(store) + validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) + ctx := WithPrincipal(context.Background(), Principal{ + Name: "客户甲", Role: "customer", UserType: "customer", + VehicleVINs: []string{"LB9A32A24R0LS1426"}, + VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}}, + }) + if _, err := service.DailyMileage(ctx, url.Values{ + "vins": {"LB9A32A24R0LS1426"}, + "dateFrom": {"2026-07-01"}, + "dateTo": {"2026-07-15"}, + }); err != nil { + t.Fatalf("daily mileage failed: %v", err) + } + if got := store.lastDailyMileageQuery.Get("scopeVehicleFrom"); got != `{"LB9A32A24R0LS1426":"2026-07-11"}` { + t.Fatalf("scopeVehicleFrom=%q", got) + } +} + +func TestCustomerMileageFailsClosedWithoutGrantTime(t *testing.T) { + service := NewService(newCountingStore()) + ctx := WithPrincipal(context.Background(), Principal{ + Name: "客户甲", Role: "customer", UserType: "customer", + VehicleVINs: []string{"LB9A32A24R0LS1426"}, + }) + _, err := service.MileageStatistics(ctx, url.Values{ + "vins": {"LB9A32A24R0LS1426"}, + "dateFrom": {"2026-07-01"}, + "dateTo": {"2026-07-15"}, + }) + clientErr, ok := asClientError(err) + if !ok || clientErr.Code != "HISTORY_SCOPE_UNAVAILABLE" { + t.Fatalf("expected HISTORY_SCOPE_UNAVAILABLE, err=%v", err) + } +} + func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { s.overviewBatchCalls++ return s.MockStore.VehicleServiceOverviews(ctx, query) diff --git a/vehicle-data-platform/deploy/migrations/014_customer_vehicle_grant_time.sql b/vehicle-data-platform/deploy/migrations/014_customer_vehicle_grant_time.sql new file mode 100644 index 00000000..7a448551 --- /dev/null +++ b/vehicle-data-platform/deploy/migrations/014_customer_vehicle_grant_time.sql @@ -0,0 +1,44 @@ +UPDATE platform_user_vehicle +SET valid_from = granted_at +WHERE valid_from IS NULL; + +CREATE INDEX idx_platform_user_vehicle_active_time + ON platform_user_vehicle(user_id, valid_from, valid_to, vin); + +CREATE TABLE IF NOT EXISTS platform_user_vehicle_grant_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + vin VARCHAR(64) NOT NULL, + valid_from DATETIME(3) NOT NULL, + valid_to DATETIME(3) NULL, + source_system VARCHAR(32) NOT NULL DEFAULT 'manual', + external_ref VARCHAR(128) NOT NULL DEFAULT '', + granted_by VARCHAR(96) NOT NULL, + revoked_by VARCHAR(96) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + INDEX idx_platform_vehicle_grant_user_time (user_id, valid_from, valid_to), + INDEX idx_platform_vehicle_grant_vin_time (vin, valid_from, valid_to), + CONSTRAINT fk_platform_vehicle_grant_user FOREIGN KEY (user_id) REFERENCES platform_user(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO platform_user_vehicle_grant_history( + user_id, vin, valid_from, valid_to, source_system, external_ref, granted_by, revoked_by +) +SELECT + current_grant.user_id, + current_grant.vin, + COALESCE(current_grant.valid_from, current_grant.granted_at), + current_grant.valid_to, + current_grant.source_system, + current_grant.external_ref, + current_grant.granted_by, + CASE WHEN current_grant.valid_to IS NULL THEN '' ELSE 'migration' END +FROM platform_user_vehicle current_grant +WHERE NOT EXISTS ( + SELECT 1 + FROM platform_user_vehicle_grant_history history + WHERE history.user_id = current_grant.user_id + AND history.vin = current_grant.vin + AND history.valid_from = COALESCE(current_grant.valid_from, current_grant.granted_at) +); diff --git a/vehicle-data-platform/docs/authentication-authorization.md b/vehicle-data-platform/docs/authentication-authorization.md index 847807e6..50dbe74f 100644 --- a/vehicle-data-platform/docs/authentication-authorization.md +++ b/vehicle-data-platform/docs/authentication-authorization.md @@ -22,10 +22,14 @@ Shared vehicle APIs used by more than one customer menu are allowed when the acc ## Vehicle Scope -`platform_user_vehicle` is the authoritative local grant table. Customer principals carry a bounded list of active VIN grants. Service queries inject it as `scopeVins` before SQL construction, and the MySQL builders apply the list to vehicle, realtime, monitor, track resolution and mileage queries. +`platform_user_vehicle` is the authoritative active-grant projection. `platform_user_vehicle_grant_history` retains every grant interval, including repeated grants of the same vehicle to the same customer. Customer principals carry a bounded list of active VIN grants with `validFrom/validTo`. Service queries inject the VIN list as `scopeVins`; historical services additionally apply the grant time boundary. Explicit VINs outside the grant set return `403 VEHICLE_PERMISSION_DENIED`. A missing vehicle grant is fail-closed and produces an empty collection. Plate-number resolution is performed inside the same VIN Scope. +Track, location-history and RAW queries are clamped to the exact active `valid_from` instant. A request ending at or before that instant returns `403 HISTORY_BEFORE_AUTHORIZATION`. Daily mileage is a natural-day aggregate, so a grant beginning after local midnight starts at the next complete `Asia/Shanghai` day; this prevents the first visible row from including pre-grant mileage. Missing grant timestamps return `403 HISTORY_SCOPE_UNAVAILABLE` rather than falling back to unrestricted history. + +Saving an unchanged vehicle assignment preserves its original start time. Removing a vehicle closes the active history interval, and assigning it again creates a new interval. The current projection is deleted on removal so existing sessions fail closed after their short cache window. + ## Sessions and password policy - Passwords are stored with bcrypt cost 12. diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index 31a625ef..fb8ec8fb 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -169,10 +169,11 @@ test -n "$MYSQL_DSN" /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/010_alert_stream_metric_mapping.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/011_alert_stream_invalid_evidence.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/012_business_scope_projection.sql \ - /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/013_platform_identity_access.sql + /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/013_platform_identity_access.sql \ + /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/014_customer_vehicle_grant_time.sql ``` -The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. +The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration `014` backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history. Install and start the evaluator as a separate unit only after API smoke checks and a small enabled-rule review: