mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-07-31 06:37:39 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b23916048a | |||
| 47708fc87a | |||
| a85e572732 | |||
| 598fd687f6 |
@@ -331,7 +331,10 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
|
|||||||
w.Header().Set("Content-Range", fmt.Sprintf("items %d-%d/%d", respOffset, respOffset+len(dbobjlist), total))
|
w.Header().Set("Content-Range", fmt.Sprintf("items %d-%d/%d", respOffset, respOffset+len(dbobjlist), total))
|
||||||
logger.Info("Serving: Records %d of %d", len(dbobjlist), total)
|
logger.Info("Serving: Records %d of %d", len(dbobjlist), total)
|
||||||
|
|
||||||
// Execute BeforeResponse hook
|
// Execute BeforeResponse hook. The transaction has already committed by
|
||||||
|
// this point, so hooks must use the pooled connection rather than the
|
||||||
|
// now-dead tx.
|
||||||
|
hookCtx.Tx = h.db
|
||||||
hookCtx.Result = dbobjlist
|
hookCtx.Result = dbobjlist
|
||||||
hookCtx.Total = total
|
hookCtx.Total = total
|
||||||
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
|
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
|
||||||
@@ -631,7 +634,10 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute BeforeResponse hook
|
// Execute BeforeResponse hook. The transaction has already committed by
|
||||||
|
// this point, so hooks must use the pooled connection rather than the
|
||||||
|
// now-dead tx.
|
||||||
|
hookCtx.Tx = h.db
|
||||||
hookCtx.Result = dbobj
|
hookCtx.Result = dbobj
|
||||||
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
|
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
|
||||||
logger.Error("BeforeResponse hook failed: %v", err)
|
logger.Error("BeforeResponse hook failed: %v", err)
|
||||||
|
|||||||
@@ -71,6 +71,16 @@ func (f *funcSpecSecurityContext) GetUserID() (int, bool) {
|
|||||||
return int(f.ctx.UserContext.UserID), true
|
return int(f.ctx.UserContext.UserID), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserRef returns an opaque user identifier for row security lookups.
|
||||||
|
// It returns the full *security.UserContext so providers can read JWT claims
|
||||||
|
// (e.g. a UUID subject) instead of relying on the int user ID.
|
||||||
|
func (f *funcSpecSecurityContext) GetUserRef() (any, bool) {
|
||||||
|
if f.ctx.UserContext == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return f.ctx.UserContext, true
|
||||||
|
}
|
||||||
|
|
||||||
func (f *funcSpecSecurityContext) GetSchema() string {
|
func (f *funcSpecSecurityContext) GetSchema() string {
|
||||||
// funcspec doesn't have a schema concept, extract from SQL query or use default
|
// funcspec doesn't have a schema concept, extract from SQL query or use default
|
||||||
return "public"
|
return "public"
|
||||||
|
|||||||
@@ -71,6 +71,17 @@ func (s *securityContext) GetUserID() (int, bool) {
|
|||||||
return security.GetUserID(s.ctx.Context)
|
return security.GetUserID(s.ctx.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserRef returns an opaque user identifier for row security lookups.
|
||||||
|
// It prefers the full *security.UserContext (so providers can read JWT claims,
|
||||||
|
// e.g. a UUID subject) and falls back to the int user ID.
|
||||||
|
func (s *securityContext) GetUserRef() (any, bool) {
|
||||||
|
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
|
||||||
|
return userCtx, true
|
||||||
|
}
|
||||||
|
userID, ok := security.GetUserID(s.ctx.Context)
|
||||||
|
return userID, ok
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetSchema() string {
|
func (s *securityContext) GetSchema() string {
|
||||||
return s.ctx.Schema
|
return s.ctx.Schema
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ func (s *securityContext) GetUserID() (int, bool) {
|
|||||||
return security.GetUserID(s.ctx.Context)
|
return security.GetUserID(s.ctx.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserRef returns an opaque user identifier for row security lookups.
|
||||||
|
// It prefers the full *security.UserContext (so providers can read JWT claims,
|
||||||
|
// e.g. a UUID subject) and falls back to the int user ID.
|
||||||
|
func (s *securityContext) GetUserRef() (any, bool) {
|
||||||
|
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
|
||||||
|
return userCtx, true
|
||||||
|
}
|
||||||
|
userID, ok := security.GetUserID(s.ctx.Context)
|
||||||
|
return userID, ok
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetSchema() string {
|
func (s *securityContext) GetSchema() string {
|
||||||
return s.ctx.Schema
|
return s.ctx.Schema
|
||||||
}
|
}
|
||||||
|
|||||||
+249
-107
@@ -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:
|
||||||
@@ -919,7 +1058,33 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
|||||||
|
|
||||||
// Wrap in transaction to ensure BeforeUpdate hook is inside transaction
|
// Wrap in transaction to ensure BeforeUpdate hook is inside transaction
|
||||||
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
||||||
// First, read the existing record from the database
|
// Execute BeforeUpdate hooks inside transaction, before any queries run.
|
||||||
|
// BeforeUpdate hooks may set session-scoped RLS GUCs (via SET LOCAL);
|
||||||
|
// they must run before the existence-check select so that select is
|
||||||
|
// also subject to RLS on this connection/transaction.
|
||||||
|
hookCtx := &HookContext{
|
||||||
|
Context: ctx,
|
||||||
|
Handler: h,
|
||||||
|
Schema: schema,
|
||||||
|
Entity: entity,
|
||||||
|
Model: model,
|
||||||
|
Options: options,
|
||||||
|
ID: urlID,
|
||||||
|
Data: updates,
|
||||||
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
|
||||||
|
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use potentially modified data from hook context
|
||||||
|
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
|
||||||
|
updates = modifiedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now read the existing record from the database
|
||||||
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
||||||
selectQuery := tx.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...)
|
selectQuery := tx.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...)
|
||||||
|
|
||||||
@@ -957,29 +1122,6 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
|||||||
return fmt.Errorf("error unmarshaling existing record: %w", err)
|
return fmt.Errorf("error unmarshaling existing record: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute BeforeUpdate hooks inside transaction
|
|
||||||
hookCtx := &HookContext{
|
|
||||||
Context: ctx,
|
|
||||||
Handler: h,
|
|
||||||
Schema: schema,
|
|
||||||
Entity: entity,
|
|
||||||
Model: model,
|
|
||||||
Options: options,
|
|
||||||
ID: urlID,
|
|
||||||
Data: updates,
|
|
||||||
Writer: w,
|
|
||||||
Tx: tx,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
|
|
||||||
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use potentially modified data from hook context
|
|
||||||
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
|
|
||||||
updates = modifiedData
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge only non-null and non-empty values from the incoming request into the existing record
|
// Merge only non-null and non-empty values from the incoming request into the existing record
|
||||||
for key, newValue := range updates {
|
for key, newValue := range updates {
|
||||||
// Skip if the value is nil
|
// Skip if the value is nil
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ func (s *securityContext) GetUserID() (int, bool) {
|
|||||||
return security.GetUserID(s.ctx.Context)
|
return security.GetUserID(s.ctx.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserRef returns an opaque user identifier for row security lookups.
|
||||||
|
// It prefers the full *security.UserContext (so providers can read JWT claims,
|
||||||
|
// e.g. a UUID subject) and falls back to the int user ID.
|
||||||
|
func (s *securityContext) GetUserRef() (any, bool) {
|
||||||
|
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
|
||||||
|
return userCtx, true
|
||||||
|
}
|
||||||
|
userID, ok := security.GetUserID(s.ctx.Context)
|
||||||
|
return userID, ok
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetSchema() string {
|
func (s *securityContext) GetSchema() string {
|
||||||
return s.ctx.Schema
|
return s.ctx.Schema
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-78
@@ -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]
|
||||||
@@ -1366,7 +1405,34 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
// 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)
|
||||||
|
|
||||||
// First, read the existing record from the database
|
// Execute BeforeUpdate hooks inside transaction, before any queries run.
|
||||||
|
// BeforeUpdate hooks may set session-scoped RLS GUCs (via SET LOCAL);
|
||||||
|
// they must run before the existence-check select so that select is
|
||||||
|
// also subject to RLS on this connection/transaction.
|
||||||
|
hookCtx = &HookContext{
|
||||||
|
Context: ctx,
|
||||||
|
Handler: h,
|
||||||
|
Schema: schema,
|
||||||
|
Entity: entity,
|
||||||
|
TableName: tableName,
|
||||||
|
Tx: tx,
|
||||||
|
Model: model,
|
||||||
|
Options: options,
|
||||||
|
ID: id,
|
||||||
|
Data: dataMap,
|
||||||
|
Writer: w,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
|
||||||
|
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use potentially modified data from hook context
|
||||||
|
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
|
||||||
|
dataMap = modifiedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now read the existing record from the database
|
||||||
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
||||||
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
||||||
if err := selectQuery.ScanModel(ctx); err != nil {
|
if err := selectQuery.ScanModel(ctx); err != nil {
|
||||||
@@ -1398,30 +1464,6 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
nestedRelations = relations
|
nestedRelations = relations
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute BeforeUpdate hooks inside transaction
|
|
||||||
hookCtx = &HookContext{
|
|
||||||
Context: ctx,
|
|
||||||
Handler: h,
|
|
||||||
Schema: schema,
|
|
||||||
Entity: entity,
|
|
||||||
TableName: tableName,
|
|
||||||
Tx: tx,
|
|
||||||
Model: model,
|
|
||||||
Options: options,
|
|
||||||
ID: id,
|
|
||||||
Data: dataMap,
|
|
||||||
Writer: w,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
|
|
||||||
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use potentially modified data from hook context
|
|
||||||
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
|
|
||||||
dataMap = modifiedData
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge only non-null and non-empty values from the incoming request into the existing record
|
// Merge only non-null and non-empty values from the incoming request into the existing record
|
||||||
for key, newValue := range dataMap {
|
for key, newValue := range dataMap {
|
||||||
// Skip if the value is nil
|
// Skip if the value is nil
|
||||||
@@ -1494,6 +1536,23 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
// Fetch the updated record after the transaction commits to capture any trigger changes
|
// Fetch the updated record after the transaction commits to capture any trigger changes
|
||||||
fetchedRecord := reflect.New(reflect.TypeOf(model)).Interface()
|
fetchedRecord := reflect.New(reflect.TypeOf(model)).Interface()
|
||||||
selectQuery := h.db.NewSelect().Model(fetchedRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
selectQuery := h.db.NewSelect().Model(fetchedRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
||||||
|
|
||||||
|
// Execute BeforeScan hooks so row security is re-applied to the post-update
|
||||||
|
// re-fetch, same as it is for the initial read and the update query itself.
|
||||||
|
// Without this, the re-fetch can return a row the caller isn't authorized to see.
|
||||||
|
// The transaction has already committed by this point, so hooks must use the
|
||||||
|
// pooled connection rather than the now-dead tx.
|
||||||
|
hookCtx.Tx = h.db
|
||||||
|
hookCtx.Query = selectQuery
|
||||||
|
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
|
||||||
|
logger.Error("BeforeScan hook failed: %v", err)
|
||||||
|
h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if modifiedQuery, ok := hookCtx.Query.(common.SelectQuery); ok {
|
||||||
|
selectQuery = modifiedQuery
|
||||||
|
}
|
||||||
|
|
||||||
if err := selectQuery.ScanModel(ctx); err != nil {
|
if err := selectQuery.ScanModel(ctx); err != nil {
|
||||||
logger.Error("Failed to fetch updated record: %v", err)
|
logger.Error("Failed to fetch updated record: %v", err)
|
||||||
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Failed to fetch updated record", err)
|
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Failed to fetch updated record", err)
|
||||||
@@ -2811,7 +2870,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)
|
||||||
@@ -2895,7 +2954,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)
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ func (s *securityContext) GetUserID() (int, bool) {
|
|||||||
return security.GetUserID(s.ctx.Context)
|
return security.GetUserID(s.ctx.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserRef returns an opaque user identifier for row security lookups.
|
||||||
|
// It prefers the full *security.UserContext (so providers can read JWT claims,
|
||||||
|
// e.g. a UUID subject) and falls back to the int user ID.
|
||||||
|
func (s *securityContext) GetUserRef() (any, bool) {
|
||||||
|
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
|
||||||
|
return userCtx, true
|
||||||
|
}
|
||||||
|
userID, ok := security.GetUserID(s.ctx.Context)
|
||||||
|
return userID, ok
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetSchema() string {
|
func (s *securityContext) GetSchema() string {
|
||||||
return s.ctx.Schema
|
return s.ctx.Schema
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ func (c *CompositeSecurityProvider) GetColumnSecurity(ctx context.Context, userI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetRowSecurity delegates to the row security provider
|
// GetRowSecurity delegates to the row security provider
|
||||||
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
|
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
|
||||||
return c.rowSec.GetRowSecurity(ctx, userID, schema, table)
|
return c.rowSec.GetRowSecurity(ctx, userRef, schema, table)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional interface implementations (if wrapped providers support them)
|
// Optional interface implementations (if wrapped providers support them)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ type mockRowSec struct {
|
|||||||
supportsCache bool
|
supportsCache bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockRowSec) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
|
func (m *mockRowSec) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
|
||||||
return m.rowSec, m.err
|
return m.rowSec, m.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-8
@@ -14,6 +14,11 @@ import (
|
|||||||
type SecurityContext interface {
|
type SecurityContext interface {
|
||||||
GetContext() context.Context
|
GetContext() context.Context
|
||||||
GetUserID() (int, bool)
|
GetUserID() (int, bool)
|
||||||
|
// GetUserRef returns an opaque user identifier for row security lookups.
|
||||||
|
// Unlike GetUserID, it is not required to be an integer: implementations backed by
|
||||||
|
// non-integer identifiers (e.g. UUIDs) can return a string, or the full
|
||||||
|
// *security.UserContext so a RowSecurityProvider can read JWT claims directly.
|
||||||
|
GetUserRef() (any, bool)
|
||||||
GetSchema() string
|
GetSchema() string
|
||||||
GetEntity() string
|
GetEntity() string
|
||||||
GetModel() interface{}
|
GetModel() interface{}
|
||||||
@@ -45,8 +50,13 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
|
|||||||
// return err
|
// return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load row security rules using the provider
|
// Load row security rules using the provider. Row security uses the opaque
|
||||||
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userID, schema, tablename, false)
|
// user ref (not the int-only user ID) so non-integer user identifiers work.
|
||||||
|
userRef, refOK := secCtx.GetUserRef()
|
||||||
|
if !refOK {
|
||||||
|
userRef = userID
|
||||||
|
}
|
||||||
|
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userRef, schema, tablename, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to load row security: %v", err)
|
logger.Warn("Failed to load row security: %v", err)
|
||||||
// Don't fail the request if no security rules exist
|
// Don't fail the request if no security rules exist
|
||||||
@@ -58,25 +68,29 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
|
|||||||
|
|
||||||
// applyRowSecurity applies row-level security filters to the query (generic version)
|
// applyRowSecurity applies row-level security filters to the query (generic version)
|
||||||
func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
||||||
userID, ok := secCtx.GetUserID()
|
userRef, ok := secCtx.GetUserRef()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
userID, idOK := secCtx.GetUserID()
|
||||||
|
if !idOK {
|
||||||
return nil // No user context, skip
|
return nil // No user context, skip
|
||||||
}
|
}
|
||||||
|
userRef = userID
|
||||||
|
}
|
||||||
|
|
||||||
schema := secCtx.GetSchema()
|
schema := secCtx.GetSchema()
|
||||||
tablename := secCtx.GetEntity()
|
tablename := secCtx.GetEntity()
|
||||||
|
|
||||||
// Get row security template
|
// Get row security template
|
||||||
rowSec, err := securityList.GetRowSecurityTemplate(userID, schema, tablename)
|
rowSec, err := securityList.GetRowSecurityTemplate(userRef, schema, tablename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No row security defined, allow query to proceed
|
// No row security defined, allow query to proceed
|
||||||
logger.Debug("No row security for %s.%s@%d: %v", schema, tablename, userID, err)
|
logger.Debug("No row security for %s.%s@%v: %v", schema, tablename, userRef, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has a blocking rule
|
// Check if user has a blocking rule
|
||||||
if rowSec.HasBlock {
|
if rowSec.HasBlock {
|
||||||
logger.Warn("User %d blocked from accessing %s.%s", userID, schema, tablename)
|
logger.Warn("User %v blocked from accessing %s.%s", userRef, schema, tablename)
|
||||||
return fmt.Errorf("access denied to %s", tablename)
|
return fmt.Errorf("access denied to %s", tablename)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +126,8 @@ func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error
|
|||||||
// Generate the WHERE clause from template
|
// Generate the WHERE clause from template
|
||||||
whereClause := rowSec.GetTemplate(pkName, modelType)
|
whereClause := rowSec.GetTemplate(pkName, modelType)
|
||||||
|
|
||||||
logger.Info("Applying row security filter for user %d on %s.%s: %s",
|
logger.Info("Applying row security filter for user %v on %s.%s: %s",
|
||||||
userID, schema, tablename, whereClause)
|
userRef, schema, tablename, whereClause)
|
||||||
|
|
||||||
// Apply the WHERE clause to the query
|
// Apply the WHERE clause to the query
|
||||||
query := secCtx.GetQuery()
|
query := secCtx.GetQuery()
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ func (m *mockSecurityContext) GetUserID() (int, bool) {
|
|||||||
return m.userID, m.hasUser
|
return m.userID, m.hasUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockSecurityContext) GetUserRef() (any, bool) {
|
||||||
|
return m.userID, m.hasUser
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockSecurityContext) GetSchema() string {
|
func (m *mockSecurityContext) GetSchema() string {
|
||||||
return m.schema
|
return m.schema
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,8 +121,12 @@ type ColumnSecurityProvider interface {
|
|||||||
|
|
||||||
// RowSecurityProvider handles row-level security (filtering)
|
// RowSecurityProvider handles row-level security (filtering)
|
||||||
type RowSecurityProvider interface {
|
type RowSecurityProvider interface {
|
||||||
// GetRowSecurity loads row security rules for a user and entity
|
// GetRowSecurity loads row security rules for a user and entity.
|
||||||
GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error)
|
// userRef identifies the user and is opaque to the caller: it may be an int ID,
|
||||||
|
// a string/UUID, or the full *security.UserContext (see SecurityContext.GetUserRef),
|
||||||
|
// so providers backed by non-integer user identifiers (e.g. UUIDs) or that need
|
||||||
|
// access to JWT claims can implement row security without relying on a numeric ID.
|
||||||
|
GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecurityProvider is the main interface combining all security concerns
|
// SecurityProvider is the main interface combining all security concerns
|
||||||
|
|||||||
@@ -260,10 +260,7 @@ func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, usern
|
|||||||
// If username is provided, get user's credentials
|
// If username is provided, get user's credentials
|
||||||
var allowCredentials []PasskeyCredentialDescriptor
|
var allowCredentials []PasskeyCredentialDescriptor
|
||||||
if username != "" {
|
if username != "" {
|
||||||
var creds []struct {
|
var creds []passkeyCredential
|
||||||
ID string `json:"credential_id"`
|
|
||||||
Transports []string `json:"transports"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredsByUsername) {
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredsByUsername) {
|
||||||
_, directCreds, err := p.getCredsByUsernameDirect(ctx, username)
|
_, directCreds, err := p.getCredsByUsernameDirect(ctx, username)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func (p *DatabasePasskeyProvider) storeCredentialDirect(ctx context.Context, par
|
|||||||
var exists int
|
var exists int
|
||||||
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
||||||
if err := db.QueryRowContext(ctx, checkQuery, params.CredentialID).Scan(&exists); err == nil {
|
if err := db.QueryRowContext(ctx, checkQuery, params.CredentialID).Scan(&exists); err == nil {
|
||||||
return fmt.Errorf("Credential already exists")
|
return fmt.Errorf("credential already exists")
|
||||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ func (p *DatabasePasskeyProvider) storeCredentialDirect(ctx context.Context, par
|
|||||||
userCheckQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE id = ?`, p.tableNames.Users))
|
userCheckQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE id = ?`, p.tableNames.Users))
|
||||||
if err := db.QueryRowContext(ctx, userCheckQuery, params.UserID).Scan(&userExists); err != nil {
|
if err := db.QueryRowContext(ctx, userCheckQuery, params.UserID).Scan(&userExists); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return fmt.Errorf("User not found")
|
return fmt.Errorf("user not found")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ func (p *DatabasePasskeyProvider) getCredentialDirect(ctx context.Context, crede
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return 0, 0, fmt.Errorf("Credential not found")
|
return 0, 0, fmt.Errorf("credential not found")
|
||||||
}
|
}
|
||||||
return 0, 0, fmt.Errorf("failed to get credential: %w", err)
|
return 0, 0, fmt.Errorf("failed to get credential: %w", err)
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ func (p *DatabasePasskeyProvider) updateCounterDirect(ctx context.Context, crede
|
|||||||
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT sign_count FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT sign_count FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
||||||
if err := db.QueryRowContext(ctx, query, credentialIDB64).Scan(&oldCounter); err != nil {
|
if err := db.QueryRowContext(ctx, query, credentialIDB64).Scan(&oldCounter); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return fmt.Errorf("Credential not found")
|
return fmt.Errorf("credential not found")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func (p *DatabasePasskeyProvider) deleteCredentialDirect(ctx context.Context, us
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("Credential not found")
|
return fmt.Errorf("credential not found")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -206,24 +206,19 @@ func (p *DatabasePasskeyProvider) updateNameDirect(ctx context.Context, userID i
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("Credential not found")
|
return fmt.Errorf("credential not found")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context, username string) (int, []struct {
|
type passkeyCredential struct {
|
||||||
ID string `json:"credential_id"`
|
ID string `json:"credential_id"`
|
||||||
Transports []string `json:"transports"`
|
Transports []string `json:"transports"`
|
||||||
}, error) {
|
}
|
||||||
type credT = struct {
|
|
||||||
ID string `json:"credential_id"`
|
|
||||||
Transports []string `json:"transports"`
|
|
||||||
}
|
|
||||||
var userID int
|
|
||||||
var creds []credT
|
|
||||||
|
|
||||||
err := p.runDBOpWithReconnect(func(db *sql.DB) error {
|
func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context, username string) (userID int, creds []passkeyCredential, err error) {
|
||||||
|
err = p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
userQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE username = ? AND is_active = ?`, p.tableNames.Users))
|
userQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE username = ? AND is_active = ?`, p.tableNames.Users))
|
||||||
if err := db.QueryRowContext(ctx, userQuery, username, true).Scan(&userID); err != nil {
|
if err := db.QueryRowContext(ctx, userQuery, username, true).Scan(&userID); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -236,7 +231,7 @@ func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context,
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
creds = make([]credT, 0)
|
creds = make([]passkeyCredential, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var credID string
|
var credID string
|
||||||
var transportsJSON sql.NullString
|
var transportsJSON sql.NullString
|
||||||
@@ -247,13 +242,13 @@ func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context,
|
|||||||
if transportsJSON.Valid && transportsJSON.String != "" {
|
if transportsJSON.Valid && transportsJSON.String != "" {
|
||||||
_ = json.Unmarshal([]byte(transportsJSON.String), &transports)
|
_ = json.Unmarshal([]byte(transportsJSON.String), &transports)
|
||||||
}
|
}
|
||||||
creds = append(creds, credT{ID: credID, Transports: transports})
|
creds = append(creds, passkeyCredential{ID: credID, Transports: transports})
|
||||||
}
|
}
|
||||||
return rows.Err()
|
return rows.Err()
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return 0, nil, fmt.Errorf("User not found")
|
return 0, nil, fmt.Errorf("user not found")
|
||||||
}
|
}
|
||||||
return 0, nil, fmt.Errorf("failed to get credentials: %w", err)
|
return 0, nil, fmt.Errorf("failed to get credentials: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ type RowSecurity struct {
|
|||||||
Tablename string `json:"tablename"`
|
Tablename string `json:"tablename"`
|
||||||
Template string `json:"template"`
|
Template string `json:"template"`
|
||||||
HasBlock bool `json:"has_block"`
|
HasBlock bool `json:"has_block"`
|
||||||
UserID int `json:"user_id"`
|
// UserID is the opaque user reference the security rules were loaded for.
|
||||||
|
// It may be an int, a string/UUID, or a *UserContext, depending on what the
|
||||||
|
// RowSecurityProvider/SecurityContext.GetUserRef implementation returns.
|
||||||
|
UserID any `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string {
|
func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string {
|
||||||
@@ -42,7 +45,7 @@ func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Typ
|
|||||||
str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName)
|
str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName)
|
||||||
str = strings.ReplaceAll(str, "{TableName}", m.Tablename)
|
str = strings.ReplaceAll(str, "{TableName}", m.Tablename)
|
||||||
str = strings.ReplaceAll(str, "{SchemaName}", m.Schema)
|
str = strings.ReplaceAll(str, "{SchemaName}", m.Schema)
|
||||||
str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%d", m.UserID))
|
str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%v", m.UserID))
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +416,7 @@ func (m *SecurityList) ClearSecurity(pUserID int, pSchema, pTablename string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) {
|
func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserRef any, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) {
|
||||||
if m.provider == nil {
|
if m.provider == nil {
|
||||||
return RowSecurity{}, fmt.Errorf("security provider not set")
|
return RowSecurity{}, fmt.Errorf("security provider not set")
|
||||||
}
|
}
|
||||||
@@ -424,10 +427,10 @@ func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema
|
|||||||
if m.RowSecurity == nil {
|
if m.RowSecurity == nil {
|
||||||
m.RowSecurity = make(map[string]RowSecurity, 0)
|
m.RowSecurity = make(map[string]RowSecurity, 0)
|
||||||
}
|
}
|
||||||
secKey := fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)
|
secKey := fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef)
|
||||||
|
|
||||||
// Call the provider to load security rules
|
// Call the provider to load security rules
|
||||||
record, err := m.provider.GetRowSecurity(ctx, pUserID, pSchema, pTablename)
|
record, err := m.provider.GetRowSecurity(ctx, pUserRef, pSchema, pTablename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RowSecurity{}, fmt.Errorf("GetRowSecurity failed: %v", err)
|
return RowSecurity{}, fmt.Errorf("GetRowSecurity failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -436,7 +439,7 @@ func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema
|
|||||||
return record, nil
|
return record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename string) (RowSecurity, error) {
|
func (m *SecurityList) GetRowSecurityTemplate(pUserRef any, pSchema, pTablename string) (RowSecurity, error) {
|
||||||
defer logger.CatchPanic("GetRowSecurityTemplate")()
|
defer logger.CatchPanic("GetRowSecurityTemplate")()
|
||||||
|
|
||||||
if m.RowSecurity == nil {
|
if m.RowSecurity == nil {
|
||||||
@@ -446,7 +449,7 @@ func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename s
|
|||||||
m.RowSecurityMutex.RLock()
|
m.RowSecurityMutex.RLock()
|
||||||
defer m.RowSecurityMutex.RUnlock()
|
defer m.RowSecurityMutex.RUnlock()
|
||||||
|
|
||||||
rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)]
|
rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef)]
|
||||||
if !ok {
|
if !ok {
|
||||||
return RowSecurity{}, fmt.Errorf("no row security data")
|
return RowSecurity{}, fmt.Errorf("no row security data")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (m *mockSecurityProvider) GetColumnSecurity(ctx context.Context, userID int
|
|||||||
return m.columnSecurity, nil
|
return m.columnSecurity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
|
func (m *mockSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
|
||||||
return m.rowSecurity, nil
|
return m.rowSecurity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -907,7 +907,7 @@ func (p *DatabaseRowSecurityProvider) reconnectDB() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
|
func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
|
||||||
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.RowSecurity) {
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.RowSecurity) {
|
||||||
return RowSecurity{}, ErrDirectModeUnsupported
|
return RowSecurity{}, ErrDirectModeUnsupported
|
||||||
}
|
}
|
||||||
@@ -917,7 +917,7 @@ func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID
|
|||||||
|
|
||||||
runQuery := func() error {
|
runQuery := func() error {
|
||||||
query := fmt.Sprintf(`SELECT p_template, p_block FROM %s($1, $2, $3)`, p.sqlNames.RowSecurity)
|
query := fmt.Sprintf(`SELECT p_template, p_block FROM %s($1, $2, $3)`, p.sqlNames.RowSecurity)
|
||||||
return p.getDB().QueryRowContext(ctx, query, schema, table, userID).Scan(&template, &hasBlock)
|
return p.getDB().QueryRowContext(ctx, query, schema, table, userRef).Scan(&template, &hasBlock)
|
||||||
}
|
}
|
||||||
err := runQuery()
|
err := runQuery()
|
||||||
if isDBClosed(err) {
|
if isDBClosed(err) {
|
||||||
@@ -932,7 +932,7 @@ func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID
|
|||||||
return RowSecurity{
|
return RowSecurity{
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Tablename: table,
|
Tablename: table,
|
||||||
UserID: userID,
|
UserID: userRef,
|
||||||
Template: template,
|
Template: template,
|
||||||
HasBlock: hasBlock,
|
HasBlock: hasBlock,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -969,14 +969,14 @@ func NewConfigRowSecurityProvider(templates map[string]string, blocked map[strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
|
func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
|
||||||
key := fmt.Sprintf("%s.%s", schema, table)
|
key := fmt.Sprintf("%s.%s", schema, table)
|
||||||
|
|
||||||
if p.blocked[key] {
|
if p.blocked[key] {
|
||||||
return RowSecurity{
|
return RowSecurity{
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Tablename: table,
|
Tablename: table,
|
||||||
UserID: userID,
|
UserID: userRef,
|
||||||
HasBlock: true,
|
HasBlock: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -985,7 +985,7 @@ func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID i
|
|||||||
return RowSecurity{
|
return RowSecurity{
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Tablename: table,
|
Tablename: table,
|
||||||
UserID: userID,
|
UserID: userRef,
|
||||||
Template: template,
|
Template: template,
|
||||||
HasBlock: false,
|
HasBlock: false,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import (
|
|||||||
// than introducing a mismatch between modes.
|
// than introducing a mismatch between modes.
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errUsernameExists = errors.New("Username already exists")
|
errUsernameExists = errors.New("username already exists")
|
||||||
errEmailExists = errors.New("Email already exists")
|
errEmailExists = errors.New("email already exists")
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||||
@@ -40,7 +40,7 @@ func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginReques
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, fmt.Errorf("Invalid credentials")
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("login query failed: %w", err)
|
return nil, fmt.Errorf("login query failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -88,13 +88,13 @@ func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginReques
|
|||||||
|
|
||||||
func (a *DatabaseAuthenticator) registerDirect(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
|
func (a *DatabaseAuthenticator) registerDirect(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
|
||||||
if req.Username == "" {
|
if req.Username == "" {
|
||||||
return nil, fmt.Errorf("Username is required")
|
return nil, fmt.Errorf("username is required")
|
||||||
}
|
}
|
||||||
if req.Email == "" {
|
if req.Email == "" {
|
||||||
return nil, fmt.Errorf("Email is required")
|
return nil, fmt.Errorf("email is required")
|
||||||
}
|
}
|
||||||
if req.Password == "" {
|
if req.Password == "" {
|
||||||
return nil, fmt.Errorf("Password is required")
|
return nil, fmt.Errorf("password is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
rolesStr := strings.Join(req.Roles, ",")
|
rolesStr := strings.Join(req.Roles, ",")
|
||||||
@@ -197,7 +197,7 @@ func (a *DatabaseAuthenticator) logoutDirect(ctx context.Context, req LogoutRequ
|
|||||||
return fmt.Errorf("logout query failed: %w", err)
|
return fmt.Errorf("logout query failed: %w", err)
|
||||||
}
|
}
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("Session not found")
|
return fmt.Errorf("session not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Token != "" {
|
if req.Token != "" {
|
||||||
@@ -222,7 +222,7 @@ func (a *DatabaseAuthenticator) sessionDirect(ctx context.Context, token string)
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, fmt.Errorf("Invalid or expired session")
|
return nil, fmt.Errorf("invalid or expired session")
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("session query failed: %w", err)
|
return nil, fmt.Errorf("session query failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -262,7 +262,7 @@ func (a *DatabaseAuthenticator) refreshTokenDirect(ctx context.Context, oldToken
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, fmt.Errorf("Invalid or expired refresh token")
|
return nil, fmt.Errorf("invalid or expired refresh token")
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("refresh token query failed: %w", err)
|
return nil, fmt.Errorf("refresh token query failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (p *DatabaseTwoFactorProvider) enable2FADirect(ctx context.Context, userID
|
|||||||
if rows, err := res.RowsAffected(); err != nil {
|
if rows, err := res.RowsAffected(); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if rows == 0 {
|
} else if rows == 0 {
|
||||||
return fmt.Errorf("User not found")
|
return fmt.Errorf("user not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
|
||||||
@@ -50,7 +50,7 @@ func (p *DatabaseTwoFactorProvider) disable2FADirect(ctx context.Context, userID
|
|||||||
if rows, err := res.RowsAffected(); err != nil {
|
if rows, err := res.RowsAffected(); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if rows == 0 {
|
} else if rows == 0 {
|
||||||
return fmt.Errorf("User not found")
|
return fmt.Errorf("user not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
|
||||||
@@ -67,7 +67,7 @@ func (p *DatabaseTwoFactorProvider) get2FAStatusDirect(ctx context.Context, user
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return false, fmt.Errorf("User not found")
|
return false, fmt.Errorf("user not found")
|
||||||
}
|
}
|
||||||
return false, fmt.Errorf("get 2FA status query failed: %w", err)
|
return false, fmt.Errorf("get 2FA status query failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func (p *DatabaseTwoFactorProvider) get2FASecretDirect(ctx context.Context, user
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", fmt.Errorf("User not found")
|
return "", fmt.Errorf("user not found")
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("get 2FA secret query failed: %w", err)
|
return "", fmt.Errorf("get 2FA secret query failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ func (p *DatabaseTwoFactorProvider) regenerateBackupCodesDirect(ctx context.Cont
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return fmt.Errorf("User not found or TOTP not enabled")
|
return fmt.Errorf("user not found or TOTP not enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
|
||||||
@@ -134,7 +134,7 @@ func (p *DatabaseTwoFactorProvider) validateBackupCodeDirect(ctx context.Context
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if used {
|
if used {
|
||||||
return fmt.Errorf("Backup code already used")
|
return fmt.Errorf("backup code already used")
|
||||||
}
|
}
|
||||||
|
|
||||||
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, p.tableNames.UserTOTPBackupCodes))
|
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, p.tableNames.UserTOTPBackupCodes))
|
||||||
|
|||||||
@@ -71,6 +71,17 @@ func (s *securityContext) GetUserID() (int, bool) {
|
|||||||
return security.GetUserID(s.ctx.Context)
|
return security.GetUserID(s.ctx.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserRef returns an opaque user identifier for row security lookups.
|
||||||
|
// It prefers the full *security.UserContext (so providers can read JWT claims,
|
||||||
|
// e.g. a UUID subject) and falls back to the int user ID.
|
||||||
|
func (s *securityContext) GetUserRef() (any, bool) {
|
||||||
|
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
|
||||||
|
return userCtx, true
|
||||||
|
}
|
||||||
|
userID, ok := security.GetUserID(s.ctx.Context)
|
||||||
|
return userID, ok
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetSchema() string {
|
func (s *securityContext) GetSchema() string {
|
||||||
return s.ctx.Schema
|
return s.ctx.Schema
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user