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