fix(platform): enforce identity relations

This commit is contained in:
2026-07-27 02:59:28 +08:00
parent 57f44e0719
commit 08bc6b3ec4
5 changed files with 180 additions and 30 deletions

View File

@@ -89,6 +89,7 @@ func ExpectedResources() []ResourceContract {
{Domain: "ec", Name: "ec_product_image", Mode: Writable, PageKind: "list"},
{Domain: "ec", Name: "ec_cart", Mode: Writable, PageKind: "list"},
{Domain: "ec", Name: "ec_order", Mode: Writable, PageKind: "list"},
{Domain: "ec", Name: "ec_order_item", Mode: Writable, PageKind: "list"},
{Domain: "ec", Name: "ec_review", Mode: Writable, PageKind: "list"},
{Domain: "delivery", Name: "delivery_task", Mode: Writable, PageKind: "list"},
{Domain: "delivery", Name: "delivery_track", Mode: Writable, PageKind: "list"},

View File

@@ -25,6 +25,7 @@ import (
func TestExpectedResources(t *testing.T) {
assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list")
assertContract(t, ExpectedResources(), "safety", "saf_event", Writable, "list")
assertContract(t, ExpectedResources(), "ec", "ec_order_item", Writable, "list")
assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list")
}
@@ -183,6 +184,49 @@ func TestGetEcOrderReturnsOrderItems(t *testing.T) {
assertMockExpectations(t, mock)
}
func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalidPayloads(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)).
WithArgs("order-a", 1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1)))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_product" WHERE identity = $1 ORDER BY "ec_product"."id" LIMIT $2`)).
WithArgs("product-a", 1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2)))
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","quantity":2}`))
values, err := prepareResourceValues(ctx, []string{"quantity"}, []ResourceRelation{
{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true},
{Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true},
})
if err != nil {
t.Fatal(err)
}
if values["ec_order_id"] != uint64(1) || values["ec_product_id"] != uint64(2) || values["quantity"] != float64(2) {
t.Fatalf("unexpected resolved values: %#v", values)
}
assertMockExpectations(t, mock)
for _, body := range []string{`{}`, `{"ec_order_id":1}`, `{"quantity":2}`} {
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(body))
if _, err := prepareResourceValues(ctx, []string{"quantity"}, []ResourceRelation{{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}}); err == nil {
t.Fatalf("payload %s was accepted", body)
}
}
}
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)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{`"id"`, `"ec_order_id"`, `"ec_product_id"`, `"delivery_task_id"`} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("response exposed internal relation key %s: %s", forbidden, encoded)
}
}
}
func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
now := time.Now().UTC()

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"reflect"
"strings"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
@@ -14,13 +15,22 @@ import (
"gorm.io/gorm"
)
// ResourceRelation accepts a stable external identity while retaining the
// relation's database ID as an internal persistence detail.
type ResourceRelation struct {
Input string
Column string
Model any
Required bool
}
// ResourceHandlers supplies the common identity-based CRUD boundary used by
// platform resources whose writable fields are explicitly declared by routes.
func ResourceHandlers(model any, createFields, updateFields []string) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
func ResourceHandlers(model any, createFields, updateFields []string, relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
return func(ctx *gin.Context) { listResource(ctx, model) },
func(ctx *gin.Context) { createResource(ctx, model, createFields) },
func(ctx *gin.Context) { createResource(ctx, model, createFields, relations) },
func(ctx *gin.Context) { getResource(ctx, model) },
func(ctx *gin.Context) { updateResource(ctx, model, updateFields) }
func(ctx *gin.Context) { updateResource(ctx, model, updateFields, relations) }
}
func listResource(ctx *gin.Context, model any) {
@@ -36,7 +46,7 @@ func listResource(ctx *gin.Context, model any) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": list.Elem().Interface()})
infra.Response.Success(ctx, gin.H{"total": total, "list": resourceResponse(list.Elem().Interface())})
}
func getResource(ctx *gin.Context, model any) {
@@ -45,16 +55,15 @@ func getResource(ctx *gin.Context, model any) {
respondRecordError(ctx, err)
return
}
infra.Response.Success(ctx, data.Interface())
infra.Response.Success(ctx, resourceResponse(data.Interface()))
}
func createResource(ctx *gin.Context, model any, allowedFields []string) {
var input map[string]any
if err := ctx.ShouldBindJSON(&input); err != nil {
func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
values, err := prepareResourceValues(ctx, allowedFields, relations)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
values := filterFields(input, allowedFields)
encoded, err := json.Marshal(values)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
@@ -70,16 +79,100 @@ func createResource(ctx *gin.Context, model any, allowedFields []string) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, data.Interface())
infra.Response.Success(ctx, resourceResponse(data.Interface()))
}
func updateResource(ctx *gin.Context, model any, allowedFields []string) {
func updateResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
var input map[string]any
if err := ctx.ShouldBindJSON(&input); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
updateAllowedByIdentity(ctx, model, input, allowedFields)
values, err := resolveResourceRelations(input, allowedFields, relations, false)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if len(values) == 0 {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
updateAllowedByIdentity(ctx, model, values, append(allowedFields, relationColumns(relations)...))
}
func prepareResourceValues(ctx *gin.Context, allowedFields []string, relations []ResourceRelation) (map[string]any, error) {
var input map[string]any
if err := ctx.ShouldBindJSON(&input); err != nil || len(input) == 0 {
return nil, errors.New("invalid resource payload")
}
values, err := resolveResourceRelations(input, allowedFields, relations, true)
if err != nil || len(values) == 0 {
return nil, errors.New("invalid resource payload")
}
return values, nil
}
func resolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) {
values := filterFields(input, allowedFields)
for _, relation := range relations {
raw, exists := input[relation.Input]
if !exists {
if requireRelations && relation.Required {
return nil, errors.New("missing required relation")
}
continue
}
identity, ok := raw.(string)
if !ok || strings.TrimSpace(identity) == "" {
return nil, errors.New("invalid relation identity")
}
var related struct{ ID uint64 }
if err := impl.DBService.Model(relation.Model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil {
return nil, err
}
values[relation.Column] = related.ID
}
return values, nil
}
func relationColumns(relations []ResourceRelation) []string {
columns := make([]string, 0, len(relations))
for _, relation := range relations {
columns = append(columns, relation.Column)
}
return columns
}
// resourceResponse strips database surrogate IDs from API data. Business
// identities are the only public relation keys accepted or returned.
func resourceResponse(value any) any {
encoded, err := json.Marshal(value)
if err != nil {
return value
}
var decoded any
if err := json.Unmarshal(encoded, &decoded); err != nil {
return value
}
return stripInternalIDs(decoded)
}
func stripInternalIDs(value any) any {
switch data := value.(type) {
case map[string]any:
for key, item := range data {
if key == "id" || strings.HasSuffix(key, "_id") {
delete(data, key)
continue
}
data[key] = stripInternalIDs(item)
}
case []any:
for index := range data {
data[index] = stripInternalIDs(data[index])
}
}
return value
}
// DisposeSafetyEvent atomically updates an event and appends its operator-owned
@@ -130,7 +223,7 @@ func DisposeSafetyEvent(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, disposal)
infra.Response.Success(ctx, resourceResponse(disposal))
}
// GetEcOrder returns the order together with its immutable item snapshots.
@@ -145,7 +238,7 @@ func GetEcOrder(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"order": order, "items": items})
infra.Response.Success(ctx, resourceResponse(gin.H{"order": order, "items": items}))
}
// GetDeliveryTrack returns time-ordered points. Precise coordinates are only
@@ -171,5 +264,5 @@ func GetDeliveryTrack(ctx *gin.Context) {
points[index].Latitude = ""
}
}
infra.Response.Success(ctx, gin.H{"track": track, "points": points})
infra.Response.Success(ctx, resourceResponse(gin.H{"track": track, "points": points}))
}