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

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

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

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