feat: add customer authentication and scoped RBAC
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user