diff --git a/backend/api/internal/initdb/platform.go b/backend/api/internal/initdb/platform.go index d987e8e..28c3042 100644 --- a/backend/api/internal/initdb/platform.go +++ b/backend/api/internal/initdb/platform.go @@ -37,11 +37,17 @@ func InitPlatformAccess(database *gorm.DB) error { {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "delivery", Name: "配送管理", Icon: "icon-car", Path: "/delivery/basic", SortNo: 30}, {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "staff", Name: "服务人员", Icon: "icon-user", Path: "/staff/list", SortNo: 40}, {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "user", Name: "业主客户", Icon: "icon-user-group", Path: "/user/list", SortNo: 50}, - {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "ec", Name: "电商管理", Icon: "icon-shopping", Path: "/ec/product", SortNo: 60}, - {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "finance", Name: "财务管理", Icon: "icon-safe", Path: "/finance/payment", SortNo: 70}, - {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "wallet", Name: "钱包中心", Icon: "icon-wallet", Path: "/wallet/list", SortNo: 80}, - {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "report", Name: "统计报表", Icon: "icon-bar-chart", Path: "/report/list", SortNo: 90}, - {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "platform", Name: "平台配置", Icon: "icon-settings", Path: "/platform/account", SortNo: 100}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "device", Name: "设备管理", Icon: "icon-storage", Path: "/device", SortNo: 60}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "safety", Name: "安全治理", Icon: "icon-safe", Path: "/safety", SortNo: 70}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "ec", Name: "电商管理", Icon: "icon-shopping", Path: "/ec/product", SortNo: 80}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "finance", Name: "财务管理", Icon: "icon-safe", Path: "/finance/payment", SortNo: 90}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "wallet", Name: "钱包中心", Icon: "icon-wallet", Path: "/wallet/list", SortNo: 100}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "report", Name: "统计报表", Icon: "icon-bar-chart", Path: "/report/list", SortNo: 110}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "content", Name: "内容管理", Icon: "icon-file", Path: "/content", SortNo: 120}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "notification", Name: "通知管理", Icon: "icon-notification", Path: "/notification", SortNo: 130}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "customer_service", Name: "客户服务", Icon: "icon-customer-service", Path: "/customer_service", SortNo: 140}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "audit", Name: "审计合规", Icon: "icon-history", Path: "/audit", SortNo: 150}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "platform", Name: "平台配置", Icon: "icon-settings", Path: "/platform/account", SortNo: 160}, } for index := range menus { menu := menus[index] diff --git a/backend/api/internal/initdb/platform_test.go b/backend/api/internal/initdb/platform_test.go new file mode 100644 index 0000000..9b6ad07 --- /dev/null +++ b/backend/api/internal/initdb/platform_test.go @@ -0,0 +1,51 @@ +package initdb + +import ( + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestInitPlatformAccessSeedsEveryProtectedFrontendDomain(t *testing.T) { + sqlDatabase, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDatabase.Close() }) + database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE role_code = $1 ORDER BY "platform_role"."id" LIMIT $2`)). + WithArgs("root", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "version", "role_code", "name", "data_scope", "is_system"}). + AddRow(uint64(1), "root-role", "enabled", 1, "root", "Root", "global", true)) + + domains := []string{ + "dashboard", "gas", "delivery", "staff", "user", "device", "safety", + "ec", "finance", "wallet", "report", "content", "notification", + "customer_service", "audit", "platform", + } + for index, domain := range domains { + menuID := uint64(index + 10) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_menu" WHERE menu_code = $1 ORDER BY "platform_menu"."id" LIMIT $2`)). + WithArgs(domain, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}). + AddRow(menuID, domain+"-menu", "enabled", 1, uint64(0), domain, domain, "", "/"+domain, index)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role_menu_relation" WHERE platform_role_id = $1 AND platform_menu_id = $2 ORDER BY "platform_role_menu_relation"."id" LIMIT $3`)). + WithArgs(uint64(1), menuID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "platform_role_id", "platform_menu_id"}). + AddRow(uint64(index+100), uint64(1), menuID)) + } + + if err := InitPlatformAccess(database); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/platform/access.go b/backend/api/internal/logic/platform/access.go index 9b876e2..3402273 100644 --- a/backend/api/internal/logic/platform/access.go +++ b/backend/api/internal/logic/platform/access.go @@ -80,3 +80,12 @@ func RequirePlatformMenuAccess() gin.HandlerFunc { ctx.Next() } } + +func requirePlatformRoot(ctx *gin.Context) bool { + claims, err := middleware.ParseAuth(ctx) + if err == nil && claims.Role == "root" { + return true + } + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return false +} diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index b4c0200..05e7146 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -157,6 +157,7 @@ func TestPlatformRoleStatusAndArchiveReturnNotFoundWhenUpdateAffectsZeroRows(t * mock.ExpectCommit() ctx, recorder := updateContext(test.method, "/roles/role-a", "role-a", []byte(test.body)) + ctx.Set("Auth", &types.JwtClaims{Role: "root"}) test.handler(ctx) assertResponseCode(t, recorder, int32(status.Code(errcode.ErrRecordNotFound))) @@ -470,6 +471,105 @@ func TestExplicitPreciseScopeRetainsCoordinatesButStillMasksPII(t *testing.T) { } } +func TestPublicResponseProjectionRemovesCredentialAndAttachmentSecrets(t *testing.T) { + ctx, _ := updateContext(http.MethodGet, "/staff/credential/credential-a", "credential-a", nil) + ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}}) + response := protectPreciseLocation(ctx, &models.StaffCredential{}, map[string]any{ + "identity": "credential-a", + "credential_type": "installer", + "credential_no": "CERT-123456", + "evidence_uri": "private://evidence", + "file_uri": "private://file", + "attachment_uri": "private://attachment", + }) + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + body := string(encoded) + if !strings.Contains(body, `"identity":"credential-a"`) || !strings.Contains(body, `"credential_type":"installer"`) { + t.Fatalf("safe credential fields were removed: %s", body) + } + for _, forbidden := range []string{"credential_no", "evidence_uri", "file_uri", "attachment_uri", "CERT-123456", "private://"} { + if strings.Contains(body, forbidden) { + t.Fatalf("credential response leaked %s: %s", forbidden, body) + } + } +} + +func TestPlatformAccountDetailMasksDisplayNameAndAvatarWithPreciseScope(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platfrom_account" WHERE identity = $1 ORDER BY "platfrom_account"."id" LIMIT $2`)). + WithArgs("account-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "username", "display_name", "avatar", "password_hash", "platform_role_code", "phone"}). + AddRow(uint64(1), "account-a", nil, nil, "enabled", 1, "operator", "张三", "private://avatar", "hash", "finance_operator", "13800138000")) + + ctx, recorder := updateContext(http.MethodGet, "/platform/platfrom_account/account-a", "account-a", nil) + ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}}) + GetPlatfromAccount(ctx) + + assertResponseCode(t, recorder, 0) + body := recorder.Body.String() + if !strings.Contains(body, `"display_name_masked"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) { + t.Fatalf("platform account detail omitted masked PII: %s", body) + } + for _, forbidden := range []string{`"display_name":`, `"avatar":`, "张三", "private://avatar"} { + if strings.Contains(body, forbidden) { + t.Fatalf("platform account detail leaked %s: %s", forbidden, body) + } + } + assertMockExpectations(t, mock) +} + +func TestPlatformAccountListMasksDisplayNameAndAvatar(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "platfrom_account"`)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platfrom_account" ORDER BY created_at desc LIMIT $1`)). + WithArgs(20). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "username", "display_name", "avatar", "password_hash", "platform_role_code", "phone"}). + AddRow(uint64(1), "account-a", nil, nil, "enabled", 1, "operator", "张三", "private://avatar", "hash", "finance_operator", "13800138000")) + + ctx, recorder := updateContext(http.MethodGet, "/platform/platfrom_account", "", nil) + ListPlatfromAccount(ctx) + + assertResponseCode(t, recorder, 0) + body := recorder.Body.String() + if !strings.Contains(body, `"display_name_masked"`) || strings.Contains(body, `"display_name":`) || strings.Contains(body, `"avatar":`) { + t.Fatalf("platform account list did not apply the masked projection: %s", body) + } + assertMockExpectations(t, mock) +} + +func TestNonRootCannotManagePlatformRolesOrMenus(t *testing.T) { + tests := []struct { + name string + handler gin.HandlerFunc + method string + target string + identity string + body string + }{ + {"create role", CreatePlatformRole, http.MethodPost, "/platform/platform_role", "", `{"role_code":"auditor","name":"Auditor"}`}, + {"create menu", CreatePlatformMenu, http.MethodPost, "/platform/platform_menu", "", `{"menu_code":"audit","name":"Audit","path":"/audit"}`}, + {"update menu status", UpdatePlatformMenuStatus, http.MethodPatch, "/platform/platform_menu/menu-a/status", "menu-a", `{"status":"disabled"}`}, + {"archive menu", ArchivePlatformMenu, http.MethodDelete, "/platform/platform_menu/menu-a", "menu-a", ``}, + {"replace role menus", ReplacePlatformRoleMenus, http.MethodPut, "/platform/platform_role/role-a/menu", "role-a", `{"menu_identities":[]}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + ctx, recorder := updateContext(test.method, test.target, test.identity, []byte(test.body)) + ctx.Set("Auth", &types.JwtClaims{Role: "platform_operator"}) + + test.handler(ctx) + + assertResponseCode(t, recorder, int32(status.Code(errcode.ErrPermissionDenied))) + assertMockExpectations(t, mock) + }) + } +} + func TestPlatformMenuAllowsOnlyAssignedDomain(t *testing.T) { menus := []models.PlatformMenu{ {MenuCode: "finance", Path: "/finance"}, @@ -495,6 +595,7 @@ func TestCreatePlatformAccountRequiresAssignableNonRootRole(t *testing.T) { t.Run(test.name, func(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) ctx, recorder := updateContext(http.MethodPost, "/platform/platfrom_account", "", []byte(test.body)) + ctx.Set("Auth", &types.JwtClaims{Role: "root"}) CreatePlatfromAccount(ctx) @@ -504,6 +605,31 @@ func TestCreatePlatformAccountRequiresAssignableNonRootRole(t *testing.T) { } } +func TestNonRootCannotAssignPlatformAccountRole(t *testing.T) { + tests := []struct { + name string + handler gin.HandlerFunc + method string + identity string + body string + }{ + {"create account", CreatePlatfromAccount, http.MethodPost, "", `{"username":"operator","password":"secure-password","platform_role_code":"auditor"}`}, + {"change account role", UpdatePlatfromAccount, http.MethodPut, "account-a", `{"platform_role_code":"auditor"}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + ctx, recorder := updateContext(test.method, "/platform/platfrom_account/"+test.identity, test.identity, []byte(test.body)) + ctx.Set("Auth", &types.JwtClaims{Role: "platform_operator"}) + + test.handler(ctx) + + assertResponseCode(t, recorder, int32(status.Code(errcode.ErrPermissionDenied))) + assertMockExpectations(t, mock) + }) + } +} + func TestListSafetyEventDisposalsReturnsOnlyTheRequestedEventHistory(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "saf_event_disposal" WHERE saf_event_identity = $1`)). @@ -708,6 +834,7 @@ func TestReplacePlatformRoleMenusAllowsAnEmptySetToClearAssignmentsTransactional mock.ExpectCommit() ctx, recorder := updateContext(http.MethodPut, "/roles/role-a/menus", "role-a", []byte(`{"menu_identities":[]}`)) + ctx.Set("Auth", &types.JwtClaims{Role: "root"}) ReplacePlatformRoleMenus(ctx) assertResponseCode(t, recorder, 0) @@ -723,6 +850,7 @@ func TestReplacePlatformRoleMenusRejectsSystemRoleBeforeChangingRelations(t *tes mock.ExpectRollback() ctx, recorder := updateContext(http.MethodPut, "/roles/root-role/menus", "root-role", []byte(`{"menu_identities":[]}`)) + ctx.Set("Auth", &types.JwtClaims{Role: "root"}) ReplacePlatformRoleMenus(ctx) assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) diff --git a/backend/api/internal/logic/platform/role.go b/backend/api/internal/logic/platform/role.go index e11f5e9..ba98f5e 100644 --- a/backend/api/internal/logic/platform/role.go +++ b/backend/api/internal/logic/platform/role.go @@ -22,6 +22,9 @@ func GetPlatformRole(ctx *gin.Context) { getByIdentity[models.PlatformRole](ctx) // CreatePlatformRole 创建非内置平台角色。 func CreatePlatformRole(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request models.PlatformRole if err := ctx.ShouldBindJSON(&request); err != nil || request.RoleCode == "" || request.Name == "" || request.RoleCode == "root" { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -41,6 +44,9 @@ func CreatePlatformRole(ctx *gin.Context) { // UpdatePlatformRole 更新非内置平台角色。 func UpdatePlatformRole(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request struct { Name string `json:"name" binding:"required,max=64"` DataScope string `json:"data_scope" binding:"required,max=32"` @@ -111,6 +117,9 @@ func GetPlatformMenu(ctx *gin.Context) { } func CreatePlatformMenu(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request platformMenuRequest if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -130,6 +139,9 @@ func CreatePlatformMenu(ctx *gin.Context) { } func UpdatePlatformMenu(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request platformMenuRequest if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -143,12 +155,29 @@ func UpdatePlatformMenu(ctx *gin.Context) { updateAllowedByIdentity(ctx, &models.PlatformMenu{}, gin.H{"parent_id": parentID, "name": request.Name, "icon": request.Icon, "path": request.Path, "sort_no": request.SortNo}, []string{"parent_id", "name", "icon", "path", "sort_no"}) } +func UpdatePlatformMenuStatus(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } + UpdateRecordStatus(ctx, &models.PlatformMenu{}) +} + +func ArchivePlatformMenu(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } + ArchiveRecord(ctx, &models.PlatformMenu{}) +} + type platformRoleMenusRequest struct { - MenuIdentities []string `json:"menu_identities" binding:"required"` + MenuIdentities []string `json:"menu_identities"` } // ReplacePlatformRoleMenus replaces every menu assignment for a role atomically. func ReplacePlatformRoleMenus(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request platformRoleMenusRequest if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -198,6 +227,9 @@ func ReplacePlatformRoleMenus(ctx *gin.Context) { // ListPlatformRoleMenuIdentities returns the current assignment for the role editor. func ListPlatformRoleMenuIdentities(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var role models.PlatformRole if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil { respondRecordError(ctx, err) @@ -217,6 +249,9 @@ func ListPlatformRoleMenuIdentities(ctx *gin.Context) { // UpdatePlatformRoleStatus 更新非内置平台角色状态,系统角色始终受保护。 func UpdatePlatformRoleStatus(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request struct { Status string `json:"status" binding:"required,max=32"` } @@ -238,6 +273,9 @@ func UpdatePlatformRoleStatus(ctx *gin.Context) { // ArchivePlatformRole 归档非内置平台角色,系统角色始终受保护。 func ArchivePlatformRole(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var role models.PlatformRole if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil { respondRecordError(ctx, err) @@ -279,9 +317,11 @@ func ListPlatfromAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - views := make([]gin.H, 0, len(list)) + views := make([]map[string]any, 0, len(list)) for _, item := range list { - views = append(views, gin.H{"identity": item.Identity, "username": item.Username, "display_name": item.DisplayName, "avatar": item.Avatar, "phone_masked": maskPhone(item.Phone), "platform_role_code": item.PlatformRoleCode, "status": item.Status}) + view := platformAccountView(item) + protectPreciseLocation(ctx, &models.PlatfromAccount{}, view) + views = append(views, view) } infra.Response.Success(ctx, gin.H{"total": total, "list": views}) } @@ -295,18 +335,12 @@ type platfromAccountRequest struct { Phone string `json:"phone" binding:"max=32"` } -type platfromAccountView struct { - Identity string `json:"identity"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Avatar string `json:"avatar"` - PhoneMasked string `json:"phone_masked"` - PlatformRoleCode string `json:"platform_role_code"` - Status string `json:"status"` -} - -func platformAccountView(account models.PlatfromAccount) platfromAccountView { - return platfromAccountView{Identity: account.Identity, Username: account.Username, DisplayName: account.DisplayName, Avatar: account.Avatar, PhoneMasked: maskPhone(account.Phone), PlatformRoleCode: account.PlatformRoleCode, Status: account.Status} +func platformAccountView(account models.PlatfromAccount) map[string]any { + return map[string]any{ + "identity": account.Identity, "username": account.Username, + "display_name": account.DisplayName, "avatar": account.Avatar, "phone": account.Phone, + "platform_role_code": account.PlatformRoleCode, "status": account.Status, + } } func GetPlatfromAccount(ctx *gin.Context) { @@ -315,10 +349,14 @@ func GetPlatfromAccount(ctx *gin.Context) { respondRecordError(ctx, err) return } - infra.Response.Success(ctx, platformAccountView(account)) + view := platformAccountView(account) + infra.Response.Success(ctx, protectPreciseLocation(ctx, &models.PlatfromAccount{}, view)) } func CreatePlatfromAccount(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request platfromAccountRequest if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -338,7 +376,8 @@ func CreatePlatfromAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, platformAccountView(account)) + view := platformAccountView(account) + infra.Response.Success(ctx, protectPreciseLocation(ctx, &models.PlatfromAccount{}, view)) } func UpdatePlatfromAccount(ctx *gin.Context) { @@ -354,6 +393,9 @@ func UpdatePlatfromAccount(ctx *gin.Context) { } values := gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "phone": request.Phone} if request.PlatformRoleCode != nil { + if !requirePlatformRoot(ctx) { + return + } if !isAssignablePlatformRole(*request.PlatformRoleCode) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index b7e7777..71cc671 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -190,19 +190,29 @@ func isCreatedResponseField(key string) bool { func protectPreciseLocation(ctx *gin.Context, model, value any) any { maskPersonalName := reflect.TypeOf(model) == reflect.TypeOf(&models.UserAccount{}) || reflect.TypeOf(model) == reflect.TypeOf(&models.StaffAccount{}) - protectPublicFields(value, maskPersonalName, hasPreciseLocationScope(ctx)) + maskDisplayName := reflect.TypeOf(model) == reflect.TypeOf(&models.PlatfromAccount{}) + protectPublicFields(value, maskPersonalName, maskDisplayName, hasPreciseLocationScope(ctx)) return value } -func protectPublicFields(value any, maskPersonalName, retainCoordinates bool) { +var sensitiveResponseFields = map[string]bool{ + "avatar": true, "address": true, "credential_no": true, + "evidence_uri": true, "evidence_url": true, "file_uri": true, + "attachment_uri": true, "attachment_url": true, + "certificate_uri": true, "certificate_url": true, + "credential_uri": true, "credential_url": true, +} + +func protectPublicFields(value any, maskPersonalName, maskDisplayName, retainCoordinates bool) { switch data := value.(type) { case map[string]any: if phone, ok := data["phone"].(string); ok && phone != "" { data["phone_masked"] = maskPhone(phone) } delete(data, "phone") - delete(data, "avatar") - delete(data, "address") + for key := range sensitiveResponseFields { + delete(data, key) + } if maskPersonalName { if name, ok := data["name"].(string); ok && name != "" { data["name_masked"] = maskPersonalNameValue(name) @@ -213,16 +223,22 @@ func protectPublicFields(value any, maskPersonalName, retainCoordinates bool) { } delete(data, "real_name") } + if maskDisplayName { + if name, ok := data["display_name"].(string); ok && name != "" { + data["display_name_masked"] = maskPersonalNameValue(name) + } + delete(data, "display_name") + } if !retainCoordinates { delete(data, "longitude") delete(data, "latitude") } for _, item := range data { - protectPublicFields(item, maskPersonalName, retainCoordinates) + protectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates) } case []any: for _, item := range data { - protectPublicFields(item, maskPersonalName, retainCoordinates) + protectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates) } } } diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index fc30a1a..1576003 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -107,7 +107,13 @@ func registerPlatformRoute(group *gin.RouterGroup) { role.GET("/:identity/menu", platform.ListPlatformRoleMenuIdentities) role.PUT("/:identity/menu", platform.ReplacePlatformRoleMenus) role.PUT("/:identity/menus", platform.ReplacePlatformRoleMenus) - registerWritableResource(group, "/platform/platform_menu", platform.ListPlatformMenu, platform.CreatePlatformMenu, platform.GetPlatformMenu, platform.UpdatePlatformMenu, &models.PlatformMenu{}) + menu := group.Group("/platform/platform_menu") + menu.GET("", platform.ListPlatformMenu) + menu.POST("", platform.CreatePlatformMenu) + menu.GET("/:identity", platform.GetPlatformMenu) + menu.PUT("/:identity", platform.UpdatePlatformMenu) + menu.PATCH("/:identity/status", platform.UpdatePlatformMenuStatus) + menu.DELETE("/:identity", platform.ArchivePlatformMenu) } func registerFinanceRoute(group *gin.RouterGroup) { diff --git a/docs/平台总后台审计报告-2026-07-27.md b/docs/平台总后台审计报告-2026-07-27.md index b1e3e1c..6e4e11c 100644 --- a/docs/平台总后台审计报告-2026-07-27.md +++ b/docs/平台总后台审计报告-2026-07-27.md @@ -4,7 +4,7 @@ 本次审计覆盖 46 个平台后台资源:33 个可写资源、12 个只读资源和 1 个仅追加的安全事件处置资源。后端资源契约现同时声明领域、资源名、HTTP 路径、页面类型和读写模式,并由运行时注册路由生成清单。前端审计据此逐项校验后端路由、前端资源声明、实际加载的页面组件和带菜单元数据的路由;任一层缺失均以 `领域/资源: missing ` 失败。 -终审追加识别的 8 项 Important 问题已全部处置并纳入回归验证:创建响应安全投影、轨迹点精确位置授权、关键字筛选一致性、表单字段类型、空可选关系、审批动作、树节点归档,以及 Biome/模型中文注释完整性。终审后复核又关闭 8 项 Important:JSONB 字符串绑定、日期 RFC3339 边界、编辑密码语义、创建响应与关键字查询的显式安全白名单、服务端角色菜单授权、平台账户非 root 角色约束、默认 PII/坐标响应投影,以及角色菜单分配 UI。 +终审追加识别的 8 项 Important 问题已全部处置并纳入回归验证:创建响应安全投影、轨迹点精确位置授权、关键字筛选一致性、表单字段类型、空可选关系、审批动作、树节点归档,以及 Biome/模型中文注释完整性。终审后两轮复核又关闭 12 项 Important:JSONB 字符串绑定、日期 RFC3339 边界、编辑密码语义、创建响应与关键字查询的显式安全白名单、服务端角色菜单授权、平台账户非 root 角色约束、默认 PII/坐标响应投影、角色菜单分配 UI,以及管理权限升级、证件附件响应、空菜单撤权和缺失菜单种子。 ## 发现与处置 @@ -21,6 +21,10 @@ | 创建平台账户未指定角色时默认成为 root,角色变更也可写入 root | 移除模型的 root 数据库默认值;创建账户强制显式指定启用的非系统角色;创建与更新都拒绝 root、缺失、停用或系统角色;前端只提供可分配角色选择 | `TestCreatePlatformAccountRequiresAssignableNonRootRole`、`pnpm type:check` | | 通用列表与详情仅投影关联 identity,姓名、电话、头像、地址和非轨迹资源坐标可能原样返回 | 所有通用 list/detail 出口统一执行响应投影:电话和个人姓名脱敏,移除头像与地址;除显式 `location_scope=precise` 外移除所有经纬度,显式坐标授权也不放宽 PII | `TestDefaultResourceResponseMasksPIIAndCoordinates`、`TestExplicitPreciseScopeRetainsCoordinatesButStillMasksPII` | | 角色菜单替换只有非规范复数 URL,前端没有可用入口 | 增加规范 `GET/PUT /platform/platform_role/:identity/menu`,保留原复数 PUT 兼容入口;角色详情可读取已选菜单、多选菜单 identity 并原子替换 | `TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD`、`final-important.test.mjs` 的角色菜单 UI 用例 | +| 拥有 `platform` 菜单的非 root 角色可继续创建角色、菜单或改写授权,形成权限升级 | 角色与菜单写操作、角色菜单读取/替换,以及平台账户角色创建/改派均增加 root-only handler 边界;菜单状态和归档使用专用受保护 handler | `TestNonRootCannotManagePlatformRolesOrMenus`、`TestNonRootCannotAssignPlatformAccountRole` | +| 通用投影未覆盖证件编号、检查凭证和附件地址,平台账户自定义 list/detail 仍返回显示名和头像 | 集中维护敏感响应键,移除 `credential_no`、证据、文件、附件和证件 URI;平台账户自定义响应复用同一投影,显示名脱敏、头像移除,精确定位 scope 不放宽 PII | `TestPublicResponseProjectionRemovesCredentialAndAttachmentSecrets`、`TestPlatformAccountDetailMasksDisplayNameAndAvatarWithPreciseScope`、`TestPlatformAccountListMasksDisplayNameAndAvatar` | +| 角色菜单 UI 将 `menu_identities` 视为必填非空,无法撤销角色的全部菜单 | 仅该多选字段允许空数组并原样提交;其他必填字段规则不变,后端事务继续以空集合删除全部关系 | `TestReplacePlatformRoleMenusAllowsAnEmptySetToClearAssignmentsTransactionally`、`final-important.test.mjs` 的空菜单撤权用例 | +| 初始化菜单缺少受保护前端域,非 root 角色无法被授予对应入口 | 幂等种子补齐 `device`、`safety`、`content`、`notification`、`customer_service`、`audit`,并继续为 root 建立关系 | `TestInitPlatformAccessSeedsEveryProtectedFrontendDomain` | | 通用关键字只影响列表或使用 JSONB、身份及敏感文本字段,可能导致总数与结果不一致或扩大数据暴露面 | 仅查询显式允许的安全文本列,并按模型排除个人姓名等敏感列;同一条件同时应用于 count/list | `TestListGasAccountAppliesKeywordToCountAndRows`、`TestKeywordColumnsUseSafeTextAllowlist` | | 前端表单缺少持久化类型边界,JSONB 字符串可能变成对象,日期不能绑定 Go `time.Time` | JSON 字段在前端校验后保留字符串,后端按模型 GORM 标签将对象/数组规范化为有效 JSON 字符串并拒绝非法文本;日期与时间统一提交 RFC3339 | `TestPrepareResourceValuesNormalizesStringJSONBFields`、`final-important.test.mjs` 的 JSON 与日期用例、`pnpm type:check` | | 创建与编辑复用密码必填规则,编辑时可能要求或误提交密码 | 密码仅在创建模式必填并进入请求体;编辑表单隐藏密码字段,载荷边界也强制忽略密码 | `final-important.test.mjs` 的创建/编辑密码语义用例 | @@ -63,7 +67,7 @@ pnpm build | --- | --- | --- | | `go test ./...` | 0 | 通过 | | `go build ./cmd/main` | 0 | 通过 | -| `node --test frontend/platform_admin/scripts/*.test.mjs` | 0 | 通过,14 个静态审计与终审回归用例;脚本按自身路径定位项目,不依赖当前工作目录 | +| `node --test frontend/platform_admin/scripts/*.test.mjs` | 0 | 通过,15 个静态审计与终审回归用例;脚本按自身路径定位项目,不依赖当前工作目录 | | `pnpm lint` | 0 | 通过;Biome 检查 169 个文件,无错误,保留 190 个非阻断 warning 和 12 个 info | | `pnpm type:check` | 0 | 通过 | | `pnpm audit:platform` | 0 | 通过 | diff --git a/frontend/platform_admin/scripts/final-important.test.mjs b/frontend/platform_admin/scripts/final-important.test.mjs index 10a4239..6b15810 100644 --- a/frontend/platform_admin/scripts/final-important.test.mjs +++ b/frontend/platform_admin/scripts/final-important.test.mjs @@ -172,3 +172,21 @@ test('platform role UI replaces assigned menu identities through the singular co assert.match(platformApi, /\/platform\/platform_role\/\$\{identity\}\/menu/); assert.match(platformApi, /method:\s*['"]PUT['"]/); }); + +test('platform role UI permits an empty menu selection to revoke every assignment', () => { + const resources = loadResources(); + const { buildResourcePayload, isMissingField } = loadResourceForm(); + const role = resources.find((item) => item.name === 'platform_role'); + const menuField = role.detailActions + .flatMap((action) => action.fields) + .find((field) => field.key === 'menu_identities'); + + assert.equal(menuField.required, undefined); + assert.equal(isMissingField([]), true); + assert.deepEqual( + JSON.parse(JSON.stringify(buildResourcePayload([menuField], { + menu_identities: [], + }))), + { menu_identities: [] }, + ); +}); diff --git a/frontend/platform_admin/src/api/resource-form.ts b/frontend/platform_admin/src/api/resource-form.ts index b7eda88..5e99cba 100644 --- a/frontend/platform_admin/src/api/resource-form.ts +++ b/frontend/platform_admin/src/api/resource-form.ts @@ -23,6 +23,10 @@ export function buildResourcePayload( for (const field of fields) { if (mode === 'edit' && field.type === 'password') continue; const value = form[field.key]; + if (field.type === 'menu-identities' && Array.isArray(value)) { + payload[field.key] = value; + continue; + } if (isMissingField(value)) { if (!field.required) continue; payload[field.key] = value; diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 1e4c189..4c430bf 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -559,7 +559,7 @@ export const resources: ResourceUiDefinition[] = [ action( '分配菜单', '/platform/platform_role/:identity/menu', - ['menu_identities!'], + ['menu_identities'], ), ], ),