feat(auth): enforce vehicle grant time boundaries

This commit is contained in:
lingniu
2026-07-16 15:44:29 +08:00
parent a541d10c7b
commit 4688abadef
11 changed files with 580 additions and 35 deletions

View File

@@ -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
}
}
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 {
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 {
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle(user_id,vin,source_system,granted_by) VALUES(?,?,'manual',?)`, userID, vin, actor); err != nil {
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
}
}

View File

@@ -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)
}
}

View File

@@ -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":

View File

@@ -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)
}

View File

@@ -1,6 +1,16 @@
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"`
@@ -14,12 +24,14 @@ type Principal struct {
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 {

View File

@@ -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{

View File

@@ -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" {

View File

@@ -53,6 +53,9 @@ type countingStore struct {
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)

View File

@@ -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)
);

View File

@@ -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.

View File

@@ -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: