feat(auth): enforce vehicle grant time boundaries
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user