feat: 推广 V3.5 实时氢耗并完善导出
This commit is contained in:
@@ -150,6 +150,10 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/admin/users") && !principal.CanMenu("users") {
|
||||
httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问账号管理", "users", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
required := requiredRole(r)
|
||||
if roleRank(principal.Role) < roleRank(required) {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
||||
@@ -243,7 +247,7 @@ func requiredMenu(r *http.Request) string {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
|
||||
return "monitor"
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", path == "/api/vehicle-service/overviews", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
return "shared"
|
||||
case path == "/api/v2/tracks":
|
||||
return "tracks"
|
||||
|
||||
@@ -40,6 +40,11 @@ var customerMenuSet = map[string]bool{
|
||||
|
||||
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"}
|
||||
|
||||
var adminMenuSet = map[string]bool{
|
||||
"monitor": true, "vehicles": true, "tracks": true, "history": true, "statistics": true,
|
||||
"alerts": true, "access": true, "operations": true, "users": true,
|
||||
}
|
||||
|
||||
type authUser struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
@@ -551,14 +556,13 @@ 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...)
|
||||
menus := []string{}
|
||||
vehicles := []string{}
|
||||
vehicleGrants := []platform.VehicleGrant{}
|
||||
businessScopeLevel := ""
|
||||
departmentIDs := []string{}
|
||||
responsibleUserID := ""
|
||||
if user.UserType == "customer" {
|
||||
menus = []string{}
|
||||
if user.UserType == "customer" || user.UserType == "admin" {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
@@ -569,14 +573,19 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
|
||||
rows.Close()
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
if customerMenuSet[value] {
|
||||
if (user.UserType == "customer" && customerMenuSet[value]) || (user.UserType == "admin" && adminMenuSet[value]) {
|
||||
menus = append(menus, value)
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
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 user.UserType == "admin" && len(menus) == 0 {
|
||||
menus = append([]string(nil), adminMenus...)
|
||||
}
|
||||
if user.UserType == "customer" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -90,6 +90,30 @@ FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalForAdminHonorsExplicitMenus(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := &authStore{db: db, cache: map[string]cachedSession{}}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
|
||||
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("monitor").AddRow("operations"))
|
||||
|
||||
principal, err := store.principalForUser(context.Background(), authUser{
|
||||
ID: 8, DisplayName: "业务管理", Username: "ln-bm", UserType: "admin", AuthProvider: "local",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !principal.CanMenu("operations") || principal.CanMenu("users") {
|
||||
t.Fatalf("restricted admin menus were not honored: %+v", principal.MenuKeys)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -212,6 +213,39 @@ func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedAdminCannotAccessAccountManagement(t *testing.T) {
|
||||
token := "restricted-admin-token-at-least-16"
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
authenticator := &apiAuthenticator{
|
||||
mode: "enforce",
|
||||
tokens: []tokenPrincipal{{
|
||||
hash: hash,
|
||||
principal: platform.Principal{
|
||||
Name: "业务管理", Username: "ln-bm", Role: "admin", UserType: "admin",
|
||||
MenuKeys: []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := authenticator.middleware(next)
|
||||
|
||||
usersRequest := httptest.NewRequest(http.MethodGet, "/api/v2/admin/users", nil)
|
||||
usersRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
usersResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(usersResponse, usersRequest)
|
||||
if usersResponse.Code != http.StatusForbidden || !strings.Contains(usersResponse.Body.String(), "MENU_PERMISSION_DENIED") {
|
||||
t.Fatalf("restricted admin account management status=%d body=%s", usersResponse.Code, usersResponse.Body.String())
|
||||
}
|
||||
|
||||
operationsRequest := httptest.NewRequest(http.MethodGet, "/api/v2/operations/source-rules", nil)
|
||||
operationsRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
operationsResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(operationsResponse, operationsRequest)
|
||||
if operationsResponse.Code != http.StatusNoContent {
|
||||
t.Fatalf("restricted admin should retain other admin permissions, status=%d", operationsResponse.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
|
||||
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
@@ -224,6 +258,29 @@ func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchVehicleLookupAllowsEveryCustomerMenuScope(t *testing.T) {
|
||||
const token = "customer-token-at-least-16"
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
for _, menu := range customerMenuKeys {
|
||||
t.Run(menu, func(t *testing.T) {
|
||||
authenticator := &apiAuthenticator{mode: "enforce", tokens: []tokenPrincipal{{
|
||||
hash: hash,
|
||||
principal: platform.Principal{
|
||||
Name: "客户甲", Role: "customer", UserType: "customer", MenuKeys: []string{menu},
|
||||
},
|
||||
}}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/vehicle-service/overviews", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
authenticator.middleware(next).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("customer with %s menu should reach batch lookup, status=%d body=%s", menu, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
|
||||
cases := []config.Config{
|
||||
{AuthMode: "enforce"},
|
||||
|
||||
Reference in New Issue
Block a user