fix(security): run RLS-scoping hooks in the same transaction as their queries
Tests / Unit Tests (push) Failing after 12s
Tests / Integration Tests (push) Failing after 16s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 51s
Build , Vet Test, and Lint / Build (push) Successful in 1m24s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m31s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m4s

BeforeRead/BeforeCreate hooks (used to set session-scoped RLS GUCs) were
firing against the pooled db handle while the actual queries ran as
separate calls to the same pool. Under connection pooling these could
land on different physical connections, silently bypassing row-level
security on creates and reads. handleUpdate already did this correctly;
handleRead/handleCreate in both resolvespec and restheadspec now wrap
hook execution and queries in a single RunInTransaction call.
This commit is contained in:
Hein
2026-07-24 12:40:27 +02:00
parent a85e572732
commit 47708fc87a
2 changed files with 910 additions and 732 deletions
+222 -83
View File
@@ -259,9 +259,44 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
sliceType := reflect.SliceOf(reflect.PointerTo(modelType)) sliceType := reflect.SliceOf(reflect.PointerTo(modelType))
modelPtr := reflect.New(sliceType).Interface() modelPtr := reflect.New(sliceType).Interface()
// Everything below runs inside a single transaction so that the BeforeRead
// hook (which sets session-scoped RLS GUCs via SET LOCAL) executes on the
// same physical connection as the queries it is meant to protect. Under
// connection pooling, firing the hook against h.db and then querying
// against h.db again may hand out two different connections, silently
// bypassing RLS.
var (
result interface{}
total int
rowNumber *int64
limit int
offset int
statusCode int
errCode string
errMsg string
)
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: id,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
return err
}
options = hookCtx.Options
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count() // Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
// Bun's Model() accepts both single pointers and slice pointers // Bun's Model() accepts both single pointers and slice pointers
query := h.db.NewSelect().Model(modelPtr) query := tx.NewSelect().Model(modelPtr)
// Only set Table() if the model doesn't provide a table name via the underlying type // Only set Table() if the model doesn't provide a table name via the underlying type
// Create a temporary instance to check for TableNameProvider // Create a temporary instance to check for TableNameProvider
@@ -296,8 +331,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query, err = h.applyPreloads(model, query, options.Preload) query, err = h.applyPreloads(model, query, options.Preload)
if err != nil { if err != nil {
logger.Error("Failed to apply preloads: %v", err) logger.Error("Failed to apply preloads: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_preload", "Failed to apply preloads", err) statusCode, errCode, errMsg = http.StatusBadRequest, "invalid_preload", "Failed to apply preloads"
return return err
} }
} }
@@ -339,8 +374,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options, nil) cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options, nil)
if err != nil { if err != nil {
logger.Error("Error building cursor filter: %v", err) logger.Error("Error building cursor filter: %v", err)
h.sendError(w, http.StatusBadRequest, "cursor_error", "Invalid cursor pagination", err) statusCode, errCode, errMsg = http.StatusBadRequest, "cursor_error", "Invalid cursor pagination"
return return err
} }
// Apply cursor filter to query // Apply cursor filter to query
@@ -355,9 +390,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
} }
} }
// Get total count before pagination
var total int
// Try to get from cache first // Try to get from cache first
// Use extended cache key if cursors are present // Use extended cache key if cursors are present
var cacheKeyHash string var cacheKeyHash string
@@ -384,8 +416,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Try to retrieve from cache // Try to retrieve from cache
var cachedTotal cachedTotal var cachedTotal cachedTotal
err := cache.GetDefaultCache().Get(ctx, cacheKey, &cachedTotal) if err := cache.GetDefaultCache().Get(ctx, cacheKey, &cachedTotal); err == nil {
if err == nil {
total = cachedTotal.Total total = cachedTotal.Total
logger.Debug("Total records (from cache): %d", total) logger.Debug("Total records (from cache): %d", total)
} else { } else {
@@ -394,8 +425,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
count, err := query.Count(ctx) count, err := query.Count(ctx)
if err != nil { if err != nil {
logger.Error("Error counting records: %v", err) logger.Error("Error counting records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error counting records", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error counting records"
return return err
} }
total = count total = count
logger.Debug("Total records (from query): %d", total) logger.Debug("Total records (from query): %d", total)
@@ -411,7 +442,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
} }
// Handle FetchRowNumber if requested // Handle FetchRowNumber if requested
var rowNumber *int64
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" { if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber) logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber)
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
@@ -439,7 +469,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
RowNum int64 `bun:"row_num"` RowNum int64 `bun:"row_num"`
} }
rowNumQuery := h.db.NewSelect().Table(tableName). rowNumQuery := tx.NewSelect().Table(tableName).
ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)). ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)).
Column(pkName) Column(pkName)
@@ -457,8 +487,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber) rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber)
// Execute query to get row number // Execute query to get row number
var result RowNumResult var rnResult RowNumResult
if err := rowNumQuery.Scan(ctx, &result); err != nil { if err := rowNumQuery.Scan(ctx, &rnResult); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
// Build filter description for error message // Build filter description for error message
filterInfo := fmt.Sprintf("filters: %d", len(options.Filters)) filterInfo := fmt.Sprintf("filters: %d", len(options.Filters))
@@ -474,7 +504,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Warn("Error fetching row number: %v", err) logger.Warn("Error fetching row number: %v", err)
} }
} else { } else {
rowNumber = &result.RowNum rowNumber = &rnResult.RowNum
logger.Debug("Found row number: %d", *rowNumber) logger.Debug("Found row number: %d", *rowNumber)
} }
} }
@@ -492,7 +522,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
} }
// Execute query // Execute query
var result interface{}
if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") { if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") {
// Single record query - either by URL ID or FetchRowNumber // Single record query - either by URL ID or FetchRowNumber
var targetID string var targetID string
@@ -511,8 +540,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID) query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := query.Scan(ctx, singleResult); err != nil { if err := query.Scan(ctx, singleResult); err != nil {
logger.Error("Error querying record: %v", err) logger.Error("Error querying record: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return return err
} }
result = singleResult result = singleResult
} else { } else {
@@ -520,17 +549,25 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Use the modelPtr already created and set on the query // Use the modelPtr already created and set on the query
if err := query.Scan(ctx, modelPtr); err != nil { if err := query.Scan(ctx, modelPtr); err != nil {
logger.Error("Error querying records: %v", err) logger.Error("Error querying records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return return err
} }
result = reflect.ValueOf(modelPtr).Elem().Interface() result = reflect.ValueOf(modelPtr).Elem().Interface()
} }
logger.Info("Successfully retrieved records") logger.Info("Successfully retrieved records")
return nil
})
if txErr != nil {
if statusCode == 0 {
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
}
h.sendError(w, statusCode, errCode, errMsg, txErr)
return
}
// Build metadata // Build metadata
limit := 0
offset := 0
count := int64(total) count := int64(total)
// When FetchRowNumber is used, we only return 1 record // When FetchRowNumber is used, we only return 1 record
@@ -585,54 +622,107 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
// Check if we should use nested processing // Check if we should use nested processing
if h.shouldUseNestedProcessor(v, model) { if h.shouldUseNestedProcessor(v, model) {
logger.Info("Using nested CUD processor for create operation") logger.Info("Using nested CUD processor for create operation")
result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", v, model, make(map[string]interface{}), tableName) var nestedResult *common.ProcessResult
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
v = modifiedData
}
originalProcessor := h.nestedProcessor
h.nestedProcessor = common.NewNestedCUDProcessor(tx, h.registry, h)
defer func() {
h.nestedProcessor = originalProcessor
}()
var procErr error
nestedResult, procErr = h.nestedProcessor.ProcessNestedCUD(ctx, "insert", v, model, make(map[string]interface{}), tableName)
return procErr
})
if err != nil { if err != nil {
logger.Error("Error in nested create: %v", err) logger.Error("Error in nested create: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record with nested data", err) h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record with nested data", err)
return return
} }
logger.Info("Successfully created record with nested data, ID: %v", result.ID) logger.Info("Successfully created record with nested data, ID: %v", nestedResult.ID)
// Invalidate cache for this table // Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName) cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil { if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err) logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
} }
h.sendResponse(w, result.Data, nil) h.sendResponse(w, nestedResult.Data, nil)
return return
} }
// Standard processing without nested relations // Standard processing without nested relations
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
query := h.db.NewInsert().Table(tableName) var responseData interface{} = v
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
v = modifiedData
}
responseData = v
query := tx.NewInsert().Table(tableName)
for key, value := range v { for key, value := range v {
query = query.Value(key, common.ConvertSliceForBun(value)) query = query.Value(key, common.ConvertSliceForBun(value))
} }
var responseData interface{} = v
if pkName == "" { if pkName == "" {
// No PK on model — insert and return input as-is. // No PK on model — insert and return input as-is.
result, err := query.Exec(ctx) result, err := query.Exec(ctx)
if err != nil { if err != nil {
logger.Error("Error creating record: %v", err) return err
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
} }
logger.Info("Successfully created record, rows affected: %d", result.RowsAffected()) logger.Info("Successfully created record, rows affected: %d", result.RowsAffected())
} else { return nil
}
var insertedID interface{} var insertedID interface{}
if err := query.Returning(pkName).Scan(ctx, &insertedID); err != nil { if err := query.Returning(pkName).Scan(ctx, &insertedID); err != nil {
logger.Error("Error creating record: %v", err) return err
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
} }
logger.Info("Successfully created record with %s: %v", pkName, insertedID) logger.Info("Successfully created record with %s: %v", pkName, insertedID)
fetchedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface() fetchedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
if fetchErr := h.db.NewSelect().Model(fetchedRecord). if fetchErr := tx.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), insertedID). Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), insertedID).
ScanModel(ctx); fetchErr == nil { ScanModel(ctx); fetchErr == nil {
responseData = mergeWithInput(fetchedRecord, v) responseData = mergeWithInput(fetchedRecord, v)
} else { } else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, insertedID, fetchErr) logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, insertedID, fetchErr)
} }
return nil
})
if err != nil {
logger.Error("Error creating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
} }
// Invalidate cache for this table // Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName) cacheTags := buildCacheTags(schema, tableName)
@@ -663,6 +753,24 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}() }()
for _, item := range v { for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
item = modifiedData
}
result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", item, model, make(map[string]interface{}), tableName) result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", item, model, make(map[string]interface{}), tableName)
if err != nil { if err != nil {
return fmt.Errorf("failed to process item: %w", err) return fmt.Errorf("failed to process item: %w", err)
@@ -689,10 +797,27 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
// Standard batch insert without nested relations // Standard batch insert without nested relations
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
modelElemType := reflection.GetPointerElement(reflect.TypeOf(model)) modelElemType := reflection.GetPointerElement(reflect.TypeOf(model))
originals := make([]map[string]interface{}, 0, len(v)) responseItems := make([]interface{}, 0, len(v))
insertedIDs := make([]interface{}, 0, len(v))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error { err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v { for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
item = modifiedData
}
txQuery := tx.NewInsert().Table(tableName) txQuery := tx.NewInsert().Table(tableName)
for key, value := range item { for key, value := range item {
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value)) txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
@@ -701,16 +826,22 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if _, err := txQuery.Exec(ctx); err != nil { if _, err := txQuery.Exec(ctx); err != nil {
return err return err
} }
originals = append(originals, item) responseItems = append(responseItems, item)
insertedIDs = append(insertedIDs, nil)
continue continue
} }
var returnedID interface{} var returnedID interface{}
if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil { if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil {
return err return err
} }
originals = append(originals, item) fetchedRecord := reflect.New(modelElemType).Interface()
insertedIDs = append(insertedIDs, returnedID) if fetchErr := tx.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), returnedID).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, item))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, returnedID, fetchErr)
responseItems = append(responseItems, item)
}
} }
return nil return nil
}) })
@@ -725,23 +856,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if err := invalidateCacheForTags(ctx, cacheTags); err != nil { if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err) logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
} }
// Re-fetch each record after transaction commits; fall back to input on failure.
responseItems := make([]interface{}, 0, len(insertedIDs))
for i, pkVal := range insertedIDs {
if pkVal == nil {
responseItems = append(responseItems, originals[i])
continue
}
fetchedRecord := reflect.New(modelElemType).Interface()
if fetchErr := h.db.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), pkVal).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, originals[i]))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, pkVal, fetchErr)
responseItems = append(responseItems, originals[i])
}
}
h.sendResponse(w, responseItems, nil) h.sendResponse(w, responseItems, nil)
case []interface{}: case []interface{}:
@@ -770,6 +884,24 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
for _, item := range v { for _, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok { if itemMap, ok := item.(map[string]interface{}); ok {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
itemMap = modifiedData
}
result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", itemMap, model, make(map[string]interface{}), tableName) result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", itemMap, model, make(map[string]interface{}), tableName)
if err != nil { if err != nil {
return fmt.Errorf("failed to process item: %w", err) return fmt.Errorf("failed to process item: %w", err)
@@ -797,14 +929,32 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
// Standard batch insert without nested relations // Standard batch insert without nested relations
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
modelElemType := reflection.GetPointerElement(reflect.TypeOf(model)) modelElemType := reflection.GetPointerElement(reflect.TypeOf(model))
originals := make([]map[string]interface{}, 0, len(v)) responseItems := make([]interface{}, 0, len(v))
insertedIDs := make([]interface{}, 0, len(v))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error { err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v { for _, item := range v {
itemMap, ok := item.(map[string]interface{}) itemMap, ok := item.(map[string]interface{})
if !ok { if !ok {
continue continue
} }
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
itemMap = modifiedData
}
txQuery := tx.NewInsert().Table(tableName) txQuery := tx.NewInsert().Table(tableName)
for key, value := range itemMap { for key, value := range itemMap {
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value)) txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
@@ -813,16 +963,22 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if _, err := txQuery.Exec(ctx); err != nil { if _, err := txQuery.Exec(ctx); err != nil {
return err return err
} }
originals = append(originals, itemMap) responseItems = append(responseItems, itemMap)
insertedIDs = append(insertedIDs, nil)
continue continue
} }
var returnedID interface{} var returnedID interface{}
if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil { if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil {
return err return err
} }
originals = append(originals, itemMap) fetchedRecord := reflect.New(modelElemType).Interface()
insertedIDs = append(insertedIDs, returnedID) if fetchErr := tx.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), returnedID).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, itemMap))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, returnedID, fetchErr)
responseItems = append(responseItems, itemMap)
}
} }
return nil return nil
}) })
@@ -837,23 +993,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if err := invalidateCacheForTags(ctx, cacheTags); err != nil { if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err) logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
} }
// Re-fetch each record after transaction commits; fall back to input on failure.
responseItems := make([]interface{}, 0, len(insertedIDs))
for i, pkVal := range insertedIDs {
if pkVal == nil {
responseItems = append(responseItems, originals[i])
continue
}
fetchedRecord := reflect.New(modelElemType).Interface()
if fetchErr := h.db.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), pkVal).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, originals[i]))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, pkVal, fetchErr)
responseItems = append(responseItems, originals[i])
}
}
h.sendResponse(w, responseItems, nil) h.sendResponse(w, responseItems, nil)
default: default:
+92 -53
View File
@@ -327,26 +327,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
options.SingleRecordAsObject = false options.SingleRecordAsObject = false
} }
// Execute BeforeRead hooks
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Model: model,
Options: options,
ID: id,
Writer: w,
Tx: h.db,
}
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
logger.Error("BeforeRead hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Validate and unwrap model type to get base struct // Validate and unwrap model type to get base struct
modelType := reflect.TypeOf(model) modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) { for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
@@ -364,9 +344,43 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Info("Reading records from %s.%s", schema, entity) logger.Info("Reading records from %s.%s", schema, entity)
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Model: model,
Options: options,
ID: id,
Writer: w,
}
// Everything below runs inside a single transaction so that the BeforeRead/BeforeScan
// hooks (which may set session-scoped RLS GUCs via SET LOCAL) execute on the same
// physical connection as the queries they are meant to protect. Under connection
// pooling, firing a hook against h.db and then querying against h.db again may hand
// out two different connections, silently bypassing RLS.
var (
total int
fetchedRowNumber *int64
statusCode int
errCode string
errMsg string
)
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx.Tx = tx
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err
}
options = hookCtx.Options
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count() // Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
// Bun's Model() accepts both single pointers and slice pointers // Bun's Model() accepts both single pointers and slice pointers
query := h.db.NewSelect().Model(modelPtr) query := tx.NewSelect().Model(modelPtr)
// Only set Table() if the model doesn't provide a table name via the underlying type // Only set Table() if the model doesn't provide a table name via the underlying type
// Create a temporary instance to check for TableNameProvider // Create a temporary instance to check for TableNameProvider
@@ -483,9 +497,9 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
fixedWhere, err := common.ValidateAndFixPreloadWhere(preload.Where, preload.Relation) fixedWhere, err := common.ValidateAndFixPreloadWhere(preload.Where, preload.Relation)
if err != nil { if err != nil {
logger.Error("Invalid preload WHERE clause for relation '%s': %v", preload.Relation, err) logger.Error("Invalid preload WHERE clause for relation '%s': %v", preload.Relation, err)
h.sendError(w, http.StatusBadRequest, "invalid_preload_where", statusCode, errCode, errMsg = http.StatusBadRequest, "invalid_preload_where",
fmt.Sprintf("Invalid preload WHERE clause for relation '%s'", preload.Relation), err) fmt.Sprintf("Invalid preload WHERE clause for relation '%s'", preload.Relation)
return return err
} }
preload.Where = fixedWhere preload.Where = fixedWhere
} }
@@ -602,7 +616,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Handle FetchRowNumber before applying ID filter // Handle FetchRowNumber before applying ID filter
// This must happen before the query to get the row position, then filter by PK // This must happen before the query to get the row position, then filter by PK
var fetchedRowNumber *int64
var fetchRowNumberPKValue string var fetchRowNumberPKValue string
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" { if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
@@ -610,11 +623,11 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Debug("FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue) logger.Debug("FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue)
rowNum, err := h.FetchRowNumber(ctx, tableName, pkName, fetchRowNumberPKValue, options, model) rowNum, err := h.FetchRowNumber(ctx, tx, tableName, pkName, fetchRowNumberPKValue, options, model)
if err != nil { if err != nil {
logger.Error("Failed to fetch row number: %v", err) logger.Error("Failed to fetch row number: %v", err)
h.sendError(w, http.StatusBadRequest, "fetch_rownumber_error", "Failed to fetch row number", err) statusCode, errCode, errMsg = http.StatusBadRequest, "fetch_rownumber_error", "Failed to fetch row number"
return return err
} }
fetchedRowNumber = &rowNum fetchedRowNumber = &rowNum
@@ -655,7 +668,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
} }
// Get total count before pagination (unless skip count is requested) // Get total count before pagination (unless skip count is requested)
var total int
if !options.SkipCount { if !options.SkipCount {
// Try to get from cache first (unless SkipCache is true) // Try to get from cache first (unless SkipCache is true)
var cachedTotalData *cachedTotal var cachedTotalData *cachedTotal
@@ -703,8 +715,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
count, err := query.Count(ctx) count, err := query.Count(ctx)
if err != nil { if err != nil {
logger.Error("Error counting records: %v", err) logger.Error("Error counting records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error counting records", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error counting records"
return return err
} }
total = count total = count
logger.Debug("Total records (from query): %d", total) logger.Debug("Total records (from query): %d", total)
@@ -764,8 +776,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
cursorFilter, err := options.GetCursorFilter(tableName, pkName, modelColumns, expandJoins) cursorFilter, err := options.GetCursorFilter(tableName, pkName, modelColumns, expandJoins)
if err != nil { if err != nil {
logger.Error("Error building cursor filter: %v", err) logger.Error("Error building cursor filter: %v", err)
h.sendError(w, http.StatusBadRequest, "cursor_error", "Invalid cursor pagination", err) statusCode, errCode, errMsg = http.StatusBadRequest, "cursor_error", "Invalid cursor pagination"
return return err
} }
// Apply cursor filter to query // Apply cursor filter to query
@@ -782,8 +794,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
hookCtx.Query = query hookCtx.Query = query
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil { if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err) logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return return err
} }
// Use potentially modified query from hook context // Use potentially modified query from hook context
@@ -794,7 +806,18 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Execute query - modelPtr was already created earlier // Execute query - modelPtr was already created earlier
if err := query.ScanModel(ctx); err != nil { if err := query.ScanModel(ctx); err != nil {
logger.Error("Error executing query: %v", err) logger.Error("Error executing query: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return err
}
return nil
})
if txErr != nil {
if statusCode == 0 {
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
}
h.sendError(w, statusCode, errCode, errMsg, txErr)
return return
} }
@@ -839,7 +862,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Debug("FetchRowNumber: Row number %d set in metadata", *fetchedRowNumber) logger.Debug("FetchRowNumber: Row number %d set in metadata", *fetchedRowNumber)
} }
// Execute AfterRead hooks // Execute AfterRead hooks (runs after the transaction commits, against the pooled db)
hookCtx.Tx = h.db
hookCtx.Result = modelPtr hookCtx.Result = modelPtr
hookCtx.Error = nil hookCtx.Error = nil
@@ -1133,7 +1157,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
logger.Info("Creating record in %s.%s", schema, entity) logger.Info("Creating record in %s.%s", schema, entity)
// Execute BeforeCreate hooks
hookCtx := &HookContext{ hookCtx := &HookContext{
Context: ctx, Context: ctx,
Handler: h, Handler: h,
@@ -1144,28 +1167,38 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Options: options, Options: options,
Data: data, Data: data,
Writer: w, Writer: w,
Tx: h.db,
} }
// Everything below (including the BeforeCreate hook) runs inside a single
// transaction so that session-scoped RLS GUCs set by the hook execute on the
// same physical connection as the inserts they are meant to protect.
var (
dataSlice []interface{}
originalDataMaps []map[string]interface{}
statusCode int
errCode string
errMsg string
)
// Process all items in a transaction
results := make([]interface{}, 0)
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx.Tx = tx
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
logger.Error("BeforeCreate hook failed: %v", err) statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) return err
return
} }
// Use potentially modified data from hook context // Use potentially modified data from hook context
data = hookCtx.Data data = hookCtx.Data
// Normalize data to slice for unified processing // Normalize data to slice for unified processing
dataSlice := h.normalizeToSlice(data) dataSlice = h.normalizeToSlice(data)
logger.Debug("Processing %d item(s) for creation", len(dataSlice)) logger.Debug("Processing %d item(s) for creation", len(dataSlice))
// Store original data maps for merging later // Store original data maps for merging later
originalDataMaps := make([]map[string]interface{}, 0, len(dataSlice)) originalDataMaps = make([]map[string]interface{}, 0, len(dataSlice))
// Process all items in a transaction
results := make([]interface{}, 0, len(dataSlice))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// Create temporary nested processor with transaction // Create temporary nested processor with transaction
txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h) txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h)
@@ -1266,9 +1299,12 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
return nil return nil
}) })
if err != nil { if txErr != nil {
logger.Error("Error creating records: %v", err) if statusCode == 0 {
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "create_error", "Error creating records"
}
logger.Error("Error creating records: %v", txErr)
h.sendError(w, statusCode, errCode, errMsg, txErr)
return return
} }
@@ -1284,7 +1320,10 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
} }
} }
// Execute AfterCreate hooks // Execute AfterCreate hooks (runs after the transaction commits, against the
// pooled db — hookCtx.Tx was pointed at the now-closed transaction inside the
// RunInTransaction closure above and must not be reused here).
hookCtx.Tx = h.db
var responseData interface{} var responseData interface{}
if len(mergedResults) == 1 { if len(mergedResults) == 1 {
responseData = mergedResults[0] responseData = mergedResults[0]
@@ -2828,7 +2867,7 @@ func (h *Handler) sendError(w common.ResponseWriter, statusCode int, code, messa
// FetchRowNumber calculates the row number of a specific record based on sorting and filtering // FetchRowNumber calculates the row number of a specific record based on sorting and filtering
// Returns the 1-based row number of the record with the given primary key value // Returns the 1-based row number of the record with the given primary key value
func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName string, pkValue string, options ExtendedRequestOptions, model any) (int64, error) { func (h *Handler) FetchRowNumber(ctx context.Context, db common.Database, tableName string, pkName string, pkValue string, options ExtendedRequestOptions, model any) (int64, error) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
logger.Error("Panic during FetchRowNumber: %v", r) logger.Error("Panic during FetchRowNumber: %v", r)
@@ -2912,7 +2951,7 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s
RN int64 `bun:"rn"` RN int64 `bun:"rn"`
} }
logger.Debug("[FetchRowNumber] BEFORE Query call - about to execute raw query") logger.Debug("[FetchRowNumber] BEFORE Query call - about to execute raw query")
err := h.db.Query(ctx, &result, queryStr, pkValue) err := db.Query(ctx, &result, queryStr, pkValue)
logger.Debug("[FetchRowNumber] AFTER Query call - query completed with %d results, err: %v", len(result), err) logger.Debug("[FetchRowNumber] AFTER Query call - query completed with %d results, err: %v", len(result), err)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to fetch row number: %w", err) return 0, fmt.Errorf("failed to fetch row number: %w", err)