feat: add customer authentication and scoped RBAC

This commit is contained in:
lingniu
2026-07-16 13:58:28 +08:00
parent 6d6c9ce534
commit a1195fb97d
28 changed files with 1738 additions and 97 deletions

View File

@@ -41,6 +41,19 @@ func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSumm
}
func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[AlertEvent], error) {
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
if strings.TrimSpace(query.Keyword) == "" {
return Page[AlertEvent]{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "客户账号仅可查询已授权车辆的告警"}
}
vin, err := s.resolveVehicleVIN(ctx, query.Keyword, query.Protocol)
if err != nil {
return Page[AlertEvent]{}, err
}
if err := authorizeVehicleVIN(ctx, vin); err != nil {
return Page[AlertEvent]{}, err
}
query.Keyword = vin
}
store, err := s.alertStore()
if err != nil {
return Page[AlertEvent]{}, err

View File

@@ -480,6 +480,8 @@ func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err er
if clientErr, ok := asClientError(err); ok {
status := http.StatusBadRequest
switch {
case clientErr.Code == "VEHICLE_PERMISSION_DENIED":
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

@@ -21,6 +21,7 @@ func buildVehicleListSQL(query url.Values) SQLQuery {
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{}
where := []string{"1 = 1"}
where, args = appendVINListFilter(where, args, "s.vin", query.Get("scopeVins"))
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
where = append(where, "(b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
like := "%" + keyword + "%"
@@ -72,6 +73,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{}
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
having := []string{}
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
@@ -173,6 +175,7 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{}
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
having := []string{}
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
@@ -294,6 +297,7 @@ func buildRealtimeLocationSQL(query url.Values) SQLQuery {
offset := parsePositive(query.Get("offset"), 0)
args := []any{}
where := []string{"1 = 1"}
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "l.protocol = ?")
args = append(args, protocol)
@@ -323,6 +327,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{}
where := []string{"l.vin IS NOT NULL", "l.vin <> ''"}
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
having := []string{}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "l.protocol = ?")
@@ -449,6 +454,7 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
args = append(args, like, like, like, like)
}
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
protocols := parseMileageProtocols(query.Get("protocols"))
if len(protocols) > 0 {
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
@@ -510,6 +516,7 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
args = append(args, like, like, like, like)
}
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
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 != "" {
@@ -544,6 +551,7 @@ func buildMileageStatisticsWhere(query url.Values) (string, []any) {
args = append(args, like, like, like, like)
}
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
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 != "" {
@@ -616,6 +624,7 @@ func buildFleetLatestMileageSQL(query url.Values) SQLQuery {
args = append(args, like, like, like, like)
}
where, args = appendVINListFilter(where, args, "l.vin", query.Get("vins"))
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
protocols := parseMileageProtocols(query.Get("protocols"))
if len(protocols) > 0 {
where, args = appendMileageProtocolFilter(where, args, "l.protocol", protocols)

View File

@@ -3,8 +3,49 @@ package platform
import "context"
type Principal struct {
Name string `json:"name"`
Role string `json:"role"`
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"`
}
func (p Principal) Clone() Principal {
p.MenuKeys = append([]string(nil), p.MenuKeys...)
p.VehicleVINs = append([]string(nil), p.VehicleVINs...)
p.VehicleCount = len(p.VehicleVINs)
return p
}
func (p Principal) CanMenu(key string) bool {
if p.UserType == "admin" || p.Role == "admin" {
return true
}
for _, value := range p.MenuKeys {
if value == key {
return true
}
}
return false
}
func (p Principal) CanVIN(vin string) bool {
if p.UserType != "customer" {
return true
}
for _, allowed := range p.VehicleVINs {
if allowed == vin {
return true
}
}
return false
}
type principalContextKey struct{}

View File

@@ -29,6 +29,9 @@ var vehicleProfileSourceSystemPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._:-
func (s *Service) VehicleProfile(ctx context.Context, vin string) (VehicleProfile, error) {
vin = strings.TrimSpace(vin)
if err := authorizeVehicleVIN(ctx, vin); err != nil {
return VehicleProfile{}, err
}
if err := validateProfileVIN(vin); err != nil {
return VehicleProfile{}, err
}

View File

@@ -484,7 +484,11 @@ func matchesMonitorStatus(row VehicleRealtimeRow, requested string) bool {
}
func (s *Service) MonitorSummary(ctx context.Context, query url.Values) (MonitorSummary, error) {
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query))
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
if err != nil {
return MonitorSummary{}, err
}
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery))
if err != nil {
return MonitorSummary{}, err
}
@@ -538,7 +542,11 @@ func (s *Service) buildMonitorSummary(ctx context.Context, query url.Values, veh
}
func (s *Service) MonitorWorkspace(ctx context.Context, query url.Values) (MonitorWorkspaceResponse, error) {
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query))
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
if err != nil {
return MonitorWorkspaceResponse{}, err
}
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery))
if err != nil {
return MonitorWorkspaceResponse{}, err
}
@@ -566,7 +574,11 @@ func (s *Service) MonitorWorkspace(ctx context.Context, query url.Values) (Monit
}
func (s *Service) MonitorMap(ctx context.Context, query url.Values) (MonitorMapResponse, error) {
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query))
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
if err != nil {
return MonitorMapResponse{}, err
}
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery))
if err != nil {
return MonitorMapResponse{}, err
}
@@ -684,7 +696,11 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
}
func (s *Service) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
return s.store.Vehicles(ctx, query)
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
if err != nil {
return Page[VehicleRow]{}, err
}
return s.store.Vehicles(ctx, scopedQuery)
}
func (s *Service) ResolveVehicleIdentity(ctx context.Context, keyword string, protocol string) (VehicleIdentityResolution, error) {
@@ -704,11 +720,19 @@ func (s *Service) ResolveVehicleIdentity(ctx context.Context, keyword string, pr
}
func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
return s.store.VehicleCoverage(ctx, query)
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
if err != nil {
return Page[VehicleCoverageRow]{}, err
}
return s.store.VehicleCoverage(ctx, scopedQuery)
}
func (s *Service) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
return s.store.VehicleCoverageSummary(ctx, query)
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
if err != nil {
return VehicleCoverageSummary{}, err
}
return s.store.VehicleCoverageSummary(ctx, scopedQuery)
}
func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) {
@@ -743,6 +767,15 @@ func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[V
func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, protocol string) (VehicleServiceOverview, error) {
keyword = strings.TrimSpace(keyword)
protocol = strings.TrimSpace(protocol)
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
resolvedScopeVIN, err := s.resolveVehicleVIN(ctx, keyword, protocol)
if err != nil {
return VehicleServiceOverview{}, err
}
if err := authorizeVehicleVIN(ctx, resolvedScopeVIN); err != nil {
return VehicleServiceOverview{}, err
}
}
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
Keywords: []string{keyword},
@@ -827,6 +860,10 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
keyword := strings.TrimSpace(vin)
protocol = strings.TrimSpace(protocol)
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}}
vehicleQuery, err := applyPrincipalVehicleScope(ctx, vehicleQuery)
if err != nil {
return VehicleDetail{}, err
}
vehicles, err := s.store.Vehicles(ctx, vehicleQuery)
if err != nil {
return VehicleDetail{}, err
@@ -843,6 +880,9 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
if queryVIN == "" {
queryVIN = keyword
}
if err := authorizeVehicleVIN(ctx, queryVIN); err != nil {
return VehicleDetail{}, err
}
if resolvedVIN == "" {
qualityQuery := url.Values{"keyword": {keyword}, "limit": {"20"}}
if protocol != "" {
@@ -1495,6 +1535,9 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates
if err != nil {
return LatestTelemetryResponse{}, err
}
if err := authorizeVehicleVIN(ctx, resolvedVIN); err != nil {
return LatestTelemetryResponse{}, err
}
rawResult := make(chan latestTelemetryRawResult, len(canonicalVehicleProtocols))
catalogResult := make(chan latestTelemetryCatalogResult, 1)
for _, protocol := range canonicalVehicleProtocols {
@@ -3616,7 +3659,10 @@ func cloneStringMap(values map[string]string) map[string]string {
}
func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (url.Values, error) {
resolved := cloneValues(query)
resolved, err := applyPrincipalVehicleScope(ctx, query)
if err != nil {
return nil, err
}
if strings.TrimSpace(resolved.Get("keywords")) != "" {
return resolved, nil
}
@@ -3629,6 +3675,9 @@ func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (ur
return nil, err
}
if resolvedVIN != "" {
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" && !principal.CanVIN(resolvedVIN) {
return nil, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
}
resolved.Set("vin", resolvedVIN)
}
return resolved, nil
@@ -3640,6 +3689,10 @@ func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protoco
return keyword, nil
}
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}}
vehicleQuery, err := applyPrincipalVehicleScope(ctx, vehicleQuery)
if err != nil {
return "", err
}
if protocol = strings.TrimSpace(protocol); protocol != "" {
vehicleQuery.Set("protocol", protocol)
}
@@ -3654,6 +3707,44 @@ func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protoco
return identity.VIN, nil
}
func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Values, error) {
next := cloneValues(query)
principal, ok := PrincipalFromContext(ctx)
if !ok || principal.UserType != "customer" {
return next, nil
}
allowed := make(map[string]bool, len(principal.VehicleVINs))
for _, vin := range principal.VehicleVINs {
allowed[strings.ToUpper(strings.TrimSpace(vin))] = true
}
for _, value := range []string{next.Get("vin"), next.Get("vins")} {
for _, vin := range strings.Split(value, ",") {
vin = strings.ToUpper(strings.TrimSpace(vin))
if vin != "" && isLikelyVIN(vin) && !allowed[vin] {
return nil, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
}
}
}
if len(principal.VehicleVINs) == 0 {
next.Set("scopeVins", "__NO_VEHICLE_SCOPE__")
} else {
next.Set("scopeVins", strings.Join(principal.VehicleVINs, ","))
}
return next, nil
}
func authorizeVehicleVIN(ctx context.Context, vin string) error {
principal, ok := PrincipalFromContext(ctx)
if !ok || principal.UserType != "customer" {
return nil
}
vin = strings.ToUpper(strings.TrimSpace(vin))
if vin == "" || !principal.CanVIN(vin) {
return clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
}
return nil
}
func cloneValues(values url.Values) url.Values {
cloned := make(url.Values, len(values))
for key, current := range values {

View File

@@ -51,6 +51,8 @@ type countingStore struct {
vehiclesCalls int
vehicleRealtimeCalls int
overviewBatchCalls int
lastVehicleQuery url.Values
lastRealtimeQuery url.Values
}
func newCountingStore() *countingStore {
@@ -59,14 +61,33 @@ func newCountingStore() *countingStore {
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
s.vehiclesCalls++
s.lastVehicleQuery = cloneValues(query)
return s.MockStore.Vehicles(ctx, query)
}
func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
s.vehicleRealtimeCalls++
s.lastRealtimeQuery = cloneValues(query)
return s.MockStore.VehicleRealtime(ctx, query)
}
func TestCustomerVehicleScopeIsInjectedAndExplicitBypassIsDenied(t *testing.T) {
store := newCountingStore()
service := NewService(store)
principal := Principal{Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}}
ctx := WithPrincipal(context.Background(), principal)
if _, err := service.Vehicles(ctx, url.Values{"limit": {"20"}}); err != nil {
t.Fatalf("scoped vehicle list failed: %v", err)
}
if got := store.lastVehicleQuery.Get("scopeVins"); got != "LB9A32A24R0LS1426" {
t.Fatalf("scopeVins=%q", got)
}
_, err := service.VehicleRealtime(ctx, url.Values{"vin": {"LMRKH9AC2R1004087"}})
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" {
t.Fatalf("cross-vehicle query should be forbidden, err=%v", err)
}
}
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
s.overviewBatchCalls++
return s.MockStore.VehicleServiceOverviews(ctx, query)