fix: enforce platform role menu access

This commit is contained in:
2026-07-27 12:48:35 +08:00
parent cd58e1f6b6
commit 160172bf10
33 changed files with 481 additions and 107 deletions

View File

@@ -0,0 +1,82 @@
package platform
import (
"strings"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
const platformMenusContextKey = "platform_authorized_menus"
func loadPlatformMenus(roleCode string) ([]models.PlatformMenu, error) {
var menus []models.PlatformMenu
if roleCode == "root" {
err := impl.DBService.Order("sort_no asc, id asc").Find(&menus).Error
return menus, err
}
var role models.PlatformRole
if err := impl.DBService.Where("role_code = ? AND status = ?", roleCode, "enabled").First(&role).Error; err != nil {
return nil, err
}
err := impl.DBService.
Select("platform_menu.*").
Joins("JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id").
Where("platform_role_menu_relation.platform_role_id = ? AND platform_menu.status = ?", role.ID, "enabled").
Order("sort_no asc, id asc").
Find(&menus).Error
return menus, err
}
func platformMenuAllowsPath(menus []models.PlatformMenu, requestPath string) bool {
marker := "/platform/v1/"
index := strings.Index(requestPath, marker)
if index < 0 {
return false
}
relative := strings.Trim(requestPath[index+len(marker):], "/")
domain := strings.Split(relative, "/")[0]
for _, menu := range menus {
if menu.MenuCode == domain {
return true
}
menuPath := strings.Trim(menu.Path, "/")
if menuPath != "" && strings.Split(menuPath, "/")[0] == domain {
return true
}
}
return false
}
// RequirePlatformMenuAccess enforces role-menu authorization after JWT authentication.
func RequirePlatformMenuAccess() gin.HandlerFunc {
return func(ctx *gin.Context) {
if strings.Contains(ctx.Request.URL.Path, "/platform/v1/auth/") {
ctx.Next()
return
}
claims, err := middleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, err)
ctx.Abort()
return
}
if claims.Role == "root" {
ctx.Next()
return
}
menus, err := loadPlatformMenus(claims.Role)
if err != nil || !platformMenuAllowsPath(menus, ctx.Request.URL.Path) {
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
ctx.Abort()
return
}
ctx.Set(platformMenusContextKey, menus)
ctx.Next()
}
}

View File

@@ -90,9 +90,18 @@ func CurrentProfile(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
menus, err := loadPlatformMenus(account.PlatformRoleCode)
if err != nil {
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
return
}
menuCodes := make([]string, 0, len(menus))
for _, menu := range menus {
menuCodes = append(menuCodes, menu.MenuCode)
}
infra.Response.Success(ctx, gin.H{
"identity": account.Identity, "username": account.Username, "display_name": account.DisplayName,
"avatar": account.Avatar, "role_code": account.PlatformRoleCode,
"avatar": account.Avatar, "role_code": account.PlatformRoleCode, "menu_codes": menuCodes,
})
}

View File

@@ -55,7 +55,7 @@ func listPage[T any](ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": response})
infra.Response.Success(ctx, gin.H{"total": total, "list": protectPreciseLocation(ctx, model, response)})
}
var keywordSafeColumns = map[string]bool{
@@ -140,7 +140,7 @@ func getByIdentity[T any](ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, response)
infra.Response.Success(ctx, protectPreciseLocation(ctx, new(T), response))
}
func updateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) {

View File

@@ -346,6 +346,7 @@ func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) {
AddRow(uint64(2), "child-a", nil, nil, "enabled", 1, uint64(1), "child", "Child", "", "/child", 2))
ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil)
ctx.Set("Auth", &types.JwtClaims{Role: "root"})
ListPlatformMenu(ctx)
assertResponseCode(t, recorder, 0)
@@ -355,6 +356,29 @@ func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) {
assertMockExpectations(t, mock)
}
func TestListPlatformMenuReturnsOnlyMenusAssignedToNonRootRole(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE role_code = $1 AND status = $2 ORDER BY "platform_role"."id" LIMIT $3`)).
WithArgs("finance_operator", "enabled", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "role_code", "name", "data_scope", "is_system"}).
AddRow(uint64(7), "finance-role", nil, nil, "enabled", 1, "finance_operator", "Finance", "global", false))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT platform_menu.* FROM "platform_menu" JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id WHERE platform_role_menu_relation.platform_role_id = $1 AND platform_menu.status = $2 ORDER BY sort_no asc, id asc`)).
WithArgs(uint64(7), "enabled").
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}).
AddRow(uint64(9), "finance-menu", nil, nil, "enabled", 1, uint64(0), "finance", "Finance", "", "/finance/payment", 1))
ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil)
ctx.Set("Auth", &types.JwtClaims{Role: "finance_operator"})
ListPlatformMenu(ctx)
assertResponseCode(t, recorder, 0)
body := recorder.Body.String()
if !strings.Contains(body, `"identity":"finance-menu"`) || strings.Contains(body, `"gas-menu"`) {
t.Fatalf("non-root menu response was not constrained: %s", body)
}
assertMockExpectations(t, mock)
}
func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) {
response := resourceResponse(map[string]any{"identity": "item-a", "id": uint64(1), "ec_order_id": uint64(2), "ec_product_id": uint64(3), "items": []any{map[string]any{"identity": "child-a", "delivery_task_id": uint64(4)}}})
encoded, err := json.Marshal(response)
@@ -398,6 +422,88 @@ func TestCreatedResourceResponseUsesSafeAllowlist(t *testing.T) {
}
}
func TestDefaultResourceResponseMasksPIIAndCoordinates(t *testing.T) {
ctx, _ := updateContext(http.MethodGet, "/user/account/user-a", "user-a", nil)
response := protectPreciseLocation(ctx, &models.UserAccount{}, map[string]any{
"identity": "user-a",
"name": "张三",
"phone": "13800138000",
"avatar": "https://private.example/avatar.png",
"address": "敏感详细地址",
"longitude": "120.123456",
"latitude": "30.456789",
})
encoded, err := json.Marshal(response)
if err != nil {
t.Fatal(err)
}
body := string(encoded)
if !strings.Contains(body, `"identity":"user-a"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) || !strings.Contains(body, `"name_masked"`) {
t.Fatalf("default response omitted safe identity or masked PII: %s", body)
}
for _, forbidden := range []string{`"phone":`, `"name":`, `"avatar":`, `"address":`, `"longitude":"120.123456"`, `"latitude":"30.456789"`, "张三", "敏感详细地址", "private.example"} {
if strings.Contains(body, forbidden) {
t.Fatalf("default response leaked %s: %s", forbidden, body)
}
}
}
func TestExplicitPreciseScopeRetainsCoordinatesButStillMasksPII(t *testing.T) {
ctx, _ := updateContext(http.MethodGet, "/user/address/address-a", "address-a", nil)
ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}})
response := protectPreciseLocation(ctx, &models.UserAddress{}, map[string]any{
"identity": "address-a",
"address": "敏感详细地址",
"longitude": "120.123456",
"latitude": "30.456789",
})
encoded, err := json.Marshal(response)
if err != nil {
t.Fatal(err)
}
body := string(encoded)
if !strings.Contains(body, `"longitude":"120.123456"`) || !strings.Contains(body, `"latitude":"30.456789"`) {
t.Fatalf("authorized response omitted precise coordinates: %s", body)
}
if strings.Contains(body, `"address":`) || strings.Contains(body, "敏感详细地址") {
t.Fatalf("precise location scope leaked address PII: %s", body)
}
}
func TestPlatformMenuAllowsOnlyAssignedDomain(t *testing.T) {
menus := []models.PlatformMenu{
{MenuCode: "finance", Path: "/finance"},
{MenuCode: "fin_payment", Path: "/finance/fin-payment"},
}
if !platformMenuAllowsPath(menus, "/heqi/platform/v1/finance/fin_payment") {
t.Fatal("assigned finance domain should be allowed")
}
if platformMenuAllowsPath(menus, "/heqi/platform/v1/user/account") {
t.Fatal("unassigned user domain should be denied")
}
}
func TestCreatePlatformAccountRequiresAssignableNonRootRole(t *testing.T) {
tests := []struct {
name string
body string
}{
{"missing role", `{"username":"operator","password":"secure-password"}`},
{"root role", `{"username":"operator","password":"secure-password","platform_role_code":"root"}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
ctx, recorder := updateContext(http.MethodPost, "/platform/platfrom_account", "", []byte(test.body))
CreatePlatfromAccount(ctx)
assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument)))
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`)).

View File

@@ -5,6 +5,7 @@ import (
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
@@ -195,6 +196,25 @@ func ReplacePlatformRoleMenus(ctx *gin.Context) {
infra.Response.Success(ctx, gin.H{"updated": true})
}
// ListPlatformRoleMenuIdentities returns the current assignment for the role editor.
func ListPlatformRoleMenuIdentities(ctx *gin.Context) {
var role models.PlatformRole
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil {
respondRecordError(ctx, err)
return
}
var identities []string
if err := impl.DBService.Model(&models.PlatformMenu{}).
Joins("JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id").
Where("platform_role_menu_relation.platform_role_id = ?", role.ID).
Order("platform_menu.sort_no asc, platform_menu.id asc").
Pluck("platform_menu.identity", &identities).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"menu_identities": identities})
}
// UpdatePlatformRoleStatus 更新非内置平台角色状态,系统角色始终受保护。
func UpdatePlatformRoleStatus(ctx *gin.Context) {
var request struct {
@@ -232,8 +252,13 @@ func ArchivePlatformRole(ctx *gin.Context) {
// ListPlatformMenu 返回菜单树构建所需的有序菜单列表。
func ListPlatformMenu(ctx *gin.Context) {
var list []models.PlatformMenu
if err := impl.DBService.Order("sort_no asc, id asc").Find(&list).Error; err != nil {
claims, err := middleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, err)
return
}
list, err := loadPlatformMenus(claims.Role)
if err != nil {
infra.Response.Error(ctx, err)
return
}
@@ -266,7 +291,7 @@ type platfromAccountRequest struct {
Password string `json:"password" binding:"required,min=8,max=128"`
DisplayName string `json:"display_name" binding:"max=64"`
Avatar string `json:"avatar" binding:"max=512"`
PlatformRoleCode string `json:"platform_role_code" binding:"max=64"`
PlatformRoleCode string `json:"platform_role_code" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"`
}
@@ -299,15 +324,16 @@ func CreatePlatfromAccount(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if !isAssignablePlatformRole(request.PlatformRoleCode) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
hash, err := passwordHash(request.Password)
if err != nil {
infra.Response.Error(ctx, err)
return
}
account := models.PlatfromAccount{Entity: newEntity("enabled"), Username: request.Username, DisplayName: request.DisplayName, Avatar: request.Avatar, PasswordHash: hash, PlatformRoleCode: request.PlatformRoleCode, Phone: request.Phone}
if account.PlatformRoleCode == "" {
account.PlatformRoleCode = "root"
}
if err := impl.DBService.Create(&account).Error; err != nil {
infra.Response.Error(ctx, err)
return
@@ -317,14 +343,30 @@ func CreatePlatfromAccount(ctx *gin.Context) {
func UpdatePlatfromAccount(ctx *gin.Context) {
var request struct {
DisplayName string `json:"display_name" binding:"max=64"`
Avatar string `json:"avatar" binding:"max=512"`
PlatformRoleCode string `json:"platform_role_code" binding:"max=64"`
Phone string `json:"phone" binding:"max=32"`
DisplayName string `json:"display_name" binding:"max=64"`
Avatar string `json:"avatar" binding:"max=512"`
PlatformRoleCode *string `json:"platform_role_code" binding:"omitempty,max=64"`
Phone string `json:"phone" binding:"max=32"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
updateAllowedByIdentity(ctx, &models.PlatfromAccount{}, gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "platform_role_code": request.PlatformRoleCode, "phone": request.Phone}, []string{"display_name", "avatar", "platform_role_code", "phone"})
values := gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "phone": request.Phone}
if request.PlatformRoleCode != nil {
if !isAssignablePlatformRole(*request.PlatformRoleCode) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
values["platform_role_code"] = *request.PlatformRoleCode
}
updateAllowedByIdentity(ctx, &models.PlatfromAccount{}, values, []string{"display_name", "avatar", "platform_role_code", "phone"})
}
func isAssignablePlatformRole(roleCode string) bool {
if roleCode == "" || roleCode == "root" {
return false
}
var role models.PlatformRole
return impl.DBService.Where("role_code = ? AND status = ? AND is_system = ?", roleCode, "enabled", false).First(&role).Error == nil
}

View File

@@ -188,37 +188,61 @@ func isCreatedResponseField(key string) bool {
}
func protectPreciseLocation(ctx *gin.Context, model, value any) any {
if reflect.TypeOf(model) != reflect.TypeOf(&models.DeliveryTrackPoint{}) || hasPreciseLocationScope(ctx) {
return value
}
clearCoordinateFields(value)
maskPersonalName := reflect.TypeOf(model) == reflect.TypeOf(&models.UserAccount{}) ||
reflect.TypeOf(model) == reflect.TypeOf(&models.StaffAccount{})
protectPublicFields(value, maskPersonalName, hasPreciseLocationScope(ctx))
return value
}
func protectPublicFields(value any, maskPersonalName, 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")
if maskPersonalName {
if name, ok := data["name"].(string); ok && name != "" {
data["name_masked"] = maskPersonalNameValue(name)
}
delete(data, "name")
if name, ok := data["real_name"].(string); ok && name != "" {
data["real_name_masked"] = maskPersonalNameValue(name)
}
delete(data, "real_name")
}
if !retainCoordinates {
delete(data, "longitude")
delete(data, "latitude")
}
for _, item := range data {
protectPublicFields(item, maskPersonalName, retainCoordinates)
}
case []any:
for _, item := range data {
protectPublicFields(item, maskPersonalName, retainCoordinates)
}
}
}
func maskPersonalNameValue(name string) string {
runes := []rune(name)
if len(runes) == 0 {
return ""
}
if len(runes) == 1 {
return "*"
}
return string(runes[0]) + strings.Repeat("*", len(runes)-1)
}
func hasPreciseLocationScope(ctx *gin.Context) bool {
claims, err := middleware.ParseAuth(ctx)
return err == nil && claims.Extend["location_scope"] == "precise"
}
func clearCoordinateFields(value any) {
switch data := value.(type) {
case map[string]any:
if _, ok := data["longitude"]; ok {
data["longitude"] = ""
}
if _, ok := data["latitude"]; ok {
data["latitude"] = ""
}
for _, item := range data {
clearCoordinateFields(item)
}
case []any:
for _, item := range data {
clearCoordinateFields(item)
}
}
}
func updateResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
var input map[string]any
if err := ctx.ShouldBindJSON(&input); err != nil {