Compare commits

...

3 Commits

Author SHA1 Message Date
Hein 47708fc87a fix(security): run RLS-scoping hooks in the same transaction as their queries
Tests / Unit Tests (push) Failing after 12s
Tests / Integration Tests (push) Failing after 16s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 51s
Build , Vet Test, and Lint / Build (push) Successful in 1m24s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m31s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m4s
BeforeRead/BeforeCreate hooks (used to set session-scoped RLS GUCs) were
firing against the pooled db handle while the actual queries ran as
separate calls to the same pool. Under connection pooling these could
land on different physical connections, silently bypassing row-level
security on creates and reads. handleUpdate already did this correctly;
handleRead/handleCreate in both resolvespec and restheadspec now wrap
hook execution and queries in a single RunInTransaction call.
2026-07-24 12:40:27 +02:00
Hein a85e572732 fix(hooks): reset Tx to pooled connection for post-commit hook calls
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m9s
Tests / Unit Tests (push) Failing after 2m40s
Tests / Integration Tests (push) Failing after 2m47s
Build , Vet Test, and Lint / Build (push) Successful in 5m56s
Build , Vet Test, and Lint / Lint Code (push) Failing after 6m37s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 6m48s
BeforeScan (restheadspec handleUpdate) and BeforeResponse (funcspec
list/single query handlers) fire after RunInTransaction commits, but
hookCtx.Tx still pointed at the now-dead transaction. Any hook that
executed a query against Tx (e.g. setUserViaContext) failed with
"sql: transaction has already been committed or rolled back".
2026-07-20 13:45:13 +02:00
Hein 598fd687f6 feat(security): add GetUserRef method for opaque user identifiers
Tests / Integration Tests (push) Failing after 1s
Tests / Unit Tests (push) Failing after 21s
Build , Vet Test, and Lint / Build (push) Successful in 50s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 54s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m6s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m21s
2026-07-10 13:47:32 +02:00
21 changed files with 1082 additions and 799 deletions
+8 -2
View File
@@ -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))
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.Total = total
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
@@ -631,7 +634,10 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
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
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
logger.Error("BeforeResponse hook failed: %v", err)
+10
View File
@@ -71,6 +71,16 @@ func (f *funcSpecSecurityContext) GetUserID() (int, bool) {
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 {
// funcspec doesn't have a schema concept, extract from SQL query or use default
return "public"
+11
View File
@@ -71,6 +71,17 @@ func (s *securityContext) GetUserID() (int, bool) {
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 {
return s.ctx.Schema
}
+11
View File
@@ -84,6 +84,17 @@ func (s *securityContext) GetUserID() (int, bool) {
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 {
return s.ctx.Schema
}
+442 -303
View File
@@ -259,278 +259,315 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
sliceType := reflect.SliceOf(reflect.PointerTo(modelType))
modelPtr := reflect.New(sliceType).Interface()
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
// Bun's Model() accepts both single pointers and slice pointers
query := h.db.NewSelect().Model(modelPtr)
// 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
)
// Only set Table() if the model doesn't provide a table name via the underlying type
// Create a temporary instance to check for TableNameProvider
tempInstance := reflect.New(modelType).Interface()
if provider, ok := tempInstance.(common.TableNameProvider); !ok || provider.TableName() == "" {
query = query.Table(tableName)
}
if len(options.Columns) == 0 && (len(options.ComputedColumns) > 0) {
logger.Debug("Populating options.Columns with all model columns since computed columns are additions")
options.Columns = reflection.GetSQLModelColumns(model)
}
// Apply column selection
if len(options.Columns) > 0 {
logger.Debug("Selecting columns: %v", options.Columns)
for _, col := range options.Columns {
query = query.Column(reflection.ExtractSourceColumn(col))
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 len(options.ComputedColumns) > 0 {
for _, cu := range options.ComputedColumns {
logger.Debug("Applying computed column: %s", cu.Name)
query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", cu.Expression, cu.Name))
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
return err
}
}
options = hookCtx.Options
// Apply preloading
if len(options.Preload) > 0 {
var err error
query, err = h.applyPreloads(model, query, options.Preload)
if err != nil {
logger.Error("Failed to apply preloads: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_preload", "Failed to apply preloads", err)
return
}
}
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
// Bun's Model() accepts both single pointers and slice pointers
query := tx.NewSelect().Model(modelPtr)
// Apply filters with proper grouping for OR logic
query = h.applyFilters(query, options.Filters)
// Apply custom operators
for _, customOp := range options.CustomOperators {
logger.Debug("Applying custom operator: %s - %s", customOp.Name, customOp.SQL)
query = query.Where(customOp.SQL)
}
// Apply sorting
for _, sort := range options.Sort {
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
logger.Debug("Applying sort: %s %s", sort.Column, direction)
query = query.Order(fmt.Sprintf("%s %s", sort.Column, direction))
}
// Apply cursor-based pagination
if len(options.CursorForward) > 0 || len(options.CursorBackward) > 0 {
logger.Debug("Applying cursor pagination")
// Get primary key name
pkName := reflection.GetPrimaryKeyName(model)
// Extract model columns for validation
modelColumns := reflection.GetModelColumns(model)
// Default sort to primary key when none provided
if len(options.Sort) == 0 {
options.Sort = []common.SortOption{{Column: pkName, Direction: "ASC"}}
// Only set Table() if the model doesn't provide a table name via the underlying type
// Create a temporary instance to check for TableNameProvider
tempInstance := reflect.New(modelType).Interface()
if provider, ok := tempInstance.(common.TableNameProvider); !ok || provider.TableName() == "" {
query = query.Table(tableName)
}
// Get cursor filter SQL (expandJoins is empty for resolvespec — no custom SQL join support yet)
cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options, nil)
if err != nil {
logger.Error("Error building cursor filter: %v", err)
h.sendError(w, http.StatusBadRequest, "cursor_error", "Invalid cursor pagination", err)
return
if len(options.Columns) == 0 && (len(options.ComputedColumns) > 0) {
logger.Debug("Populating options.Columns with all model columns since computed columns are additions")
options.Columns = reflection.GetSQLModelColumns(model)
}
// Apply cursor filter to query
if cursorFilter != "" {
logger.Debug("Applying cursor filter: %s", cursorFilter)
sanitizedCursor := common.SanitizeWhereClause(cursorFilter, reflection.ExtractTableNameOnly(tableName), &options)
// Ensure outer parentheses to prevent OR logic from escaping
sanitizedCursor = common.EnsureOuterParentheses(sanitizedCursor)
if sanitizedCursor != "" {
query = query.Where(sanitizedCursor)
// Apply column selection
if len(options.Columns) > 0 {
logger.Debug("Selecting columns: %v", options.Columns)
for _, col := range options.Columns {
query = query.Column(reflection.ExtractSourceColumn(col))
}
}
}
// Get total count before pagination
var total int
// Try to get from cache first
// Use extended cache key if cursors are present
var cacheKeyHash string
if len(options.CursorForward) > 0 || len(options.CursorBackward) > 0 {
cacheKeyHash = buildExtendedQueryCacheKey(
tableName,
options.Filters,
options.Sort,
"", // No custom SQL WHERE in resolvespec
"", // No custom SQL OR in resolvespec
options.CursorForward,
options.CursorBackward,
)
} else {
cacheKeyHash = buildQueryCacheKey(
tableName,
options.Filters,
options.Sort,
"", // No custom SQL WHERE in resolvespec
"", // No custom SQL OR in resolvespec
)
}
cacheKey := getQueryTotalCacheKey(cacheKeyHash)
// Try to retrieve from cache
var cachedTotal cachedTotal
err := cache.GetDefaultCache().Get(ctx, cacheKey, &cachedTotal)
if err == nil {
total = cachedTotal.Total
logger.Debug("Total records (from cache): %d", total)
} else {
// Cache miss - execute count query
logger.Debug("Cache miss for query total")
count, err := query.Count(ctx)
if err != nil {
logger.Error("Error counting records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error counting records", err)
return
}
total = count
logger.Debug("Total records (from query): %d", total)
// Store in cache with schema and table tags
cacheTTL := time.Minute * 2 // Default 2 minutes TTL
if err := setQueryTotalCache(ctx, cacheKey, total, schema, tableName, cacheTTL); err != nil {
logger.Warn("Failed to cache query total: %v", err)
// Don't fail the request if caching fails
} else {
logger.Debug("Cached query total with key: %s", cacheKey)
}
}
// Handle FetchRowNumber if requested
var rowNumber *int64
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber)
pkName := reflection.GetPrimaryKeyName(model)
// Build ROW_NUMBER window function SQL
rowNumberSQL := "ROW_NUMBER() OVER ("
if len(options.Sort) > 0 {
rowNumberSQL += "ORDER BY "
for i, sort := range options.Sort {
if i > 0 {
rowNumberSQL += ", "
}
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
rowNumberSQL += fmt.Sprintf("%s %s", sort.Column, direction)
if len(options.ComputedColumns) > 0 {
for _, cu := range options.ComputedColumns {
logger.Debug("Applying computed column: %s", cu.Name)
query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", cu.Expression, cu.Name))
}
}
rowNumberSQL += ")"
// Create a query to fetch the row number using a subquery approach
// We'll select the PK and row_number, then filter by the target ID
type RowNumResult struct {
RowNum int64 `bun:"row_num"`
// Apply preloading
if len(options.Preload) > 0 {
var err error
query, err = h.applyPreloads(model, query, options.Preload)
if err != nil {
logger.Error("Failed to apply preloads: %v", err)
statusCode, errCode, errMsg = http.StatusBadRequest, "invalid_preload", "Failed to apply preloads"
return err
}
}
rowNumQuery := h.db.NewSelect().Table(tableName).
ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)).
Column(pkName)
// Apply the same filters as the main query
for _, filter := range options.Filters {
rowNumQuery = h.applyFilter(rowNumQuery, filter)
}
// Apply filters with proper grouping for OR logic
query = h.applyFilters(query, options.Filters)
// Apply custom operators
for _, customOp := range options.CustomOperators {
rowNumQuery = rowNumQuery.Where(customOp.SQL)
logger.Debug("Applying custom operator: %s - %s", customOp.Name, customOp.SQL)
query = query.Where(customOp.SQL)
}
// Filter for the specific ID we want the row number for
rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber)
// Execute query to get row number
var result RowNumResult
if err := rowNumQuery.Scan(ctx, &result); err != nil {
if err == sql.ErrNoRows {
// Build filter description for error message
filterInfo := fmt.Sprintf("filters: %d", len(options.Filters))
if len(options.CustomOperators) > 0 {
customOps := make([]string, 0, len(options.CustomOperators))
for _, op := range options.CustomOperators {
customOps = append(customOps, op.SQL)
}
filterInfo += fmt.Sprintf(", custom operators: [%s]", strings.Join(customOps, "; "))
}
logger.Warn("No row found for primary key %s=%s with %s", pkName, *options.FetchRowNumber, filterInfo)
} else {
logger.Warn("Error fetching row number: %v", err)
// Apply sorting
for _, sort := range options.Sort {
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
logger.Debug("Applying sort: %s %s", sort.Column, direction)
query = query.Order(fmt.Sprintf("%s %s", sort.Column, direction))
}
// Apply cursor-based pagination
if len(options.CursorForward) > 0 || len(options.CursorBackward) > 0 {
logger.Debug("Applying cursor pagination")
// Get primary key name
pkName := reflection.GetPrimaryKeyName(model)
// Extract model columns for validation
modelColumns := reflection.GetModelColumns(model)
// Default sort to primary key when none provided
if len(options.Sort) == 0 {
options.Sort = []common.SortOption{{Column: pkName, Direction: "ASC"}}
}
// Get cursor filter SQL (expandJoins is empty for resolvespec — no custom SQL join support yet)
cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options, nil)
if err != nil {
logger.Error("Error building cursor filter: %v", err)
statusCode, errCode, errMsg = http.StatusBadRequest, "cursor_error", "Invalid cursor pagination"
return err
}
// Apply cursor filter to query
if cursorFilter != "" {
logger.Debug("Applying cursor filter: %s", cursorFilter)
sanitizedCursor := common.SanitizeWhereClause(cursorFilter, reflection.ExtractTableNameOnly(tableName), &options)
// Ensure outer parentheses to prevent OR logic from escaping
sanitizedCursor = common.EnsureOuterParentheses(sanitizedCursor)
if sanitizedCursor != "" {
query = query.Where(sanitizedCursor)
}
}
}
// Try to get from cache first
// Use extended cache key if cursors are present
var cacheKeyHash string
if len(options.CursorForward) > 0 || len(options.CursorBackward) > 0 {
cacheKeyHash = buildExtendedQueryCacheKey(
tableName,
options.Filters,
options.Sort,
"", // No custom SQL WHERE in resolvespec
"", // No custom SQL OR in resolvespec
options.CursorForward,
options.CursorBackward,
)
} else {
rowNumber = &result.RowNum
logger.Debug("Found row number: %d", *rowNumber)
cacheKeyHash = buildQueryCacheKey(
tableName,
options.Filters,
options.Sort,
"", // No custom SQL WHERE in resolvespec
"", // No custom SQL OR in resolvespec
)
}
}
cacheKey := getQueryTotalCacheKey(cacheKeyHash)
// Apply pagination (skip if FetchRowNumber is set - we want only that record)
if options.FetchRowNumber == nil || *options.FetchRowNumber == "" {
if options.Limit != nil && *options.Limit > 0 {
logger.Debug("Applying limit: %d", *options.Limit)
query = query.Limit(*options.Limit)
}
if options.Offset != nil && *options.Offset > 0 {
logger.Debug("Applying offset: %d", *options.Offset)
query = query.Offset(*options.Offset)
}
}
// Execute query
var result interface{}
if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") {
// Single record query - either by URL ID or FetchRowNumber
var targetID string
if id != "" {
targetID = id
logger.Debug("Querying single record with URL ID: %s", id)
// Try to retrieve from cache
var cachedTotal cachedTotal
if err := cache.GetDefaultCache().Get(ctx, cacheKey, &cachedTotal); err == nil {
total = cachedTotal.Total
logger.Debug("Total records (from cache): %d", total)
} else {
targetID = *options.FetchRowNumber
logger.Debug("Querying single record with FetchRowNumber ID: %s", targetID)
// Cache miss - execute count query
logger.Debug("Cache miss for query total")
count, err := query.Count(ctx)
if err != nil {
logger.Error("Error counting records: %v", err)
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error counting records"
return err
}
total = count
logger.Debug("Total records (from query): %d", total)
// Store in cache with schema and table tags
cacheTTL := time.Minute * 2 // Default 2 minutes TTL
if err := setQueryTotalCache(ctx, cacheKey, total, schema, tableName, cacheTTL); err != nil {
logger.Warn("Failed to cache query total: %v", err)
// Don't fail the request if caching fails
} else {
logger.Debug("Cached query total with key: %s", cacheKey)
}
}
// For single record, create a new pointer to the struct type
singleResult := reflect.New(modelType).Interface()
pkName := reflection.GetPrimaryKeyName(singleResult)
// Handle FetchRowNumber if requested
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber)
pkName := reflection.GetPrimaryKeyName(model)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := query.Scan(ctx, singleResult); err != nil {
logger.Error("Error querying record: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
return
// Build ROW_NUMBER window function SQL
rowNumberSQL := "ROW_NUMBER() OVER ("
if len(options.Sort) > 0 {
rowNumberSQL += "ORDER BY "
for i, sort := range options.Sort {
if i > 0 {
rowNumberSQL += ", "
}
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
rowNumberSQL += fmt.Sprintf("%s %s", sort.Column, direction)
}
}
rowNumberSQL += ")"
// Create a query to fetch the row number using a subquery approach
// We'll select the PK and row_number, then filter by the target ID
type RowNumResult struct {
RowNum int64 `bun:"row_num"`
}
rowNumQuery := tx.NewSelect().Table(tableName).
ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)).
Column(pkName)
// Apply the same filters as the main query
for _, filter := range options.Filters {
rowNumQuery = h.applyFilter(rowNumQuery, filter)
}
// Apply custom operators
for _, customOp := range options.CustomOperators {
rowNumQuery = rowNumQuery.Where(customOp.SQL)
}
// Filter for the specific ID we want the row number for
rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber)
// Execute query to get row number
var rnResult RowNumResult
if err := rowNumQuery.Scan(ctx, &rnResult); err != nil {
if err == sql.ErrNoRows {
// Build filter description for error message
filterInfo := fmt.Sprintf("filters: %d", len(options.Filters))
if len(options.CustomOperators) > 0 {
customOps := make([]string, 0, len(options.CustomOperators))
for _, op := range options.CustomOperators {
customOps = append(customOps, op.SQL)
}
filterInfo += fmt.Sprintf(", custom operators: [%s]", strings.Join(customOps, "; "))
}
logger.Warn("No row found for primary key %s=%s with %s", pkName, *options.FetchRowNumber, filterInfo)
} else {
logger.Warn("Error fetching row number: %v", err)
}
} else {
rowNumber = &rnResult.RowNum
logger.Debug("Found row number: %d", *rowNumber)
}
}
result = singleResult
} else {
logger.Debug("Querying multiple records")
// Use the modelPtr already created and set on the query
if err := query.Scan(ctx, modelPtr); err != nil {
logger.Error("Error querying records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
return
// Apply pagination (skip if FetchRowNumber is set - we want only that record)
if options.FetchRowNumber == nil || *options.FetchRowNumber == "" {
if options.Limit != nil && *options.Limit > 0 {
logger.Debug("Applying limit: %d", *options.Limit)
query = query.Limit(*options.Limit)
}
if options.Offset != nil && *options.Offset > 0 {
logger.Debug("Applying offset: %d", *options.Offset)
query = query.Offset(*options.Offset)
}
}
result = reflect.ValueOf(modelPtr).Elem().Interface()
// Execute query
if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") {
// Single record query - either by URL ID or FetchRowNumber
var targetID string
if id != "" {
targetID = id
logger.Debug("Querying single record with URL ID: %s", id)
} else {
targetID = *options.FetchRowNumber
logger.Debug("Querying single record with FetchRowNumber ID: %s", targetID)
}
// For single record, create a new pointer to the struct type
singleResult := reflect.New(modelType).Interface()
pkName := reflection.GetPrimaryKeyName(singleResult)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := query.Scan(ctx, singleResult); err != nil {
logger.Error("Error querying record: %v", err)
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return err
}
result = singleResult
} else {
logger.Debug("Querying multiple records")
// Use the modelPtr already created and set on the query
if err := query.Scan(ctx, modelPtr); err != nil {
logger.Error("Error querying records: %v", err)
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return err
}
result = reflect.ValueOf(modelPtr).Elem().Interface()
}
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
}
logger.Info("Successfully retrieved records")
// Build metadata
limit := 0
offset := 0
count := int64(total)
// 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
if h.shouldUseNestedProcessor(v, model) {
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 {
logger.Error("Error in nested create: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record with nested data", err)
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
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
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
}
// Standard processing without nested relations
pkName := reflection.GetPrimaryKeyName(model)
query := h.db.NewInsert().Table(tableName)
for key, value := range v {
query = query.Value(key, common.ConvertSliceForBun(value))
}
var responseData interface{} = v
if pkName == "" {
// No PK on model — insert and return input as-is.
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Error creating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
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,
}
logger.Info("Successfully created record, rows affected: %d", result.RowsAffected())
} else {
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 {
query = query.Value(key, common.ConvertSliceForBun(value))
}
if pkName == "" {
// No PK on model — insert and return input as-is.
result, err := query.Exec(ctx)
if err != nil {
return err
}
logger.Info("Successfully created record, rows affected: %d", result.RowsAffected())
return nil
}
var insertedID interface{}
if err := query.Returning(pkName).Scan(ctx, &insertedID); err != nil {
logger.Error("Error creating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
return err
}
logger.Info("Successfully created record with %s: %v", pkName, insertedID)
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).
ScanModel(ctx); fetchErr == nil {
responseData = mergeWithInput(fetchedRecord, v)
} else {
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
cacheTags := buildCacheTags(schema, tableName)
@@ -663,6 +753,24 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}()
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)
if err != nil {
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
pkName := reflection.GetPrimaryKeyName(model)
modelElemType := reflection.GetPointerElement(reflect.TypeOf(model))
originals := make([]map[string]interface{}, 0, len(v))
insertedIDs := make([]interface{}, 0, len(v))
responseItems := make([]interface{}, 0, len(v))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
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)
for key, value := range item {
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 {
return err
}
originals = append(originals, item)
insertedIDs = append(insertedIDs, nil)
responseItems = append(responseItems, item)
continue
}
var returnedID interface{}
if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil {
return err
}
originals = append(originals, item)
insertedIDs = append(insertedIDs, returnedID)
fetchedRecord := reflect.New(modelElemType).Interface()
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
})
@@ -725,23 +856,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
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)
case []interface{}:
@@ -770,6 +884,24 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
for _, item := range v {
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)
if err != nil {
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
pkName := reflection.GetPrimaryKeyName(model)
modelElemType := reflection.GetPointerElement(reflect.TypeOf(model))
originals := make([]map[string]interface{}, 0, len(v))
insertedIDs := make([]interface{}, 0, len(v))
responseItems := make([]interface{}, 0, len(v))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
itemMap, ok := item.(map[string]interface{})
if !ok {
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)
for key, value := range itemMap {
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 {
return err
}
originals = append(originals, itemMap)
insertedIDs = append(insertedIDs, nil)
responseItems = append(responseItems, itemMap)
continue
}
var returnedID interface{}
if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil {
return err
}
originals = append(originals, itemMap)
insertedIDs = append(insertedIDs, returnedID)
fetchedRecord := reflect.New(modelElemType).Interface()
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
})
@@ -837,23 +993,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
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)
default:
+11
View File
@@ -78,6 +78,17 @@ func (s *securityContext) GetUserID() (int, bool) {
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 {
return s.ctx.Schema
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -77,6 +77,17 @@ func (s *securityContext) GetUserID() (int, bool) {
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 {
return s.ctx.Schema
}
+2 -2
View File
@@ -74,8 +74,8 @@ func (c *CompositeSecurityProvider) GetColumnSecurity(ctx context.Context, userI
}
// GetRowSecurity delegates to the row security provider
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
return c.rowSec.GetRowSecurity(ctx, userID, schema, table)
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
return c.rowSec.GetRowSecurity(ctx, userRef, schema, table)
}
// Optional interface implementations (if wrapped providers support them)
+1 -1
View File
@@ -79,7 +79,7 @@ type mockRowSec struct {
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
}
+23 -9
View File
@@ -14,6 +14,11 @@ import (
type SecurityContext interface {
GetContext() context.Context
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
GetEntity() string
GetModel() interface{}
@@ -45,8 +50,13 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
// return err
}
// Load row security rules using the provider
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userID, schema, tablename, false)
// Load row security rules using the provider. Row security uses the opaque
// 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 {
logger.Warn("Failed to load row security: %v", err)
// 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)
func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
userID, ok := secCtx.GetUserID()
userRef, ok := secCtx.GetUserRef()
if !ok {
return nil // No user context, skip
userID, idOK := secCtx.GetUserID()
if !idOK {
return nil // No user context, skip
}
userRef = userID
}
schema := secCtx.GetSchema()
tablename := secCtx.GetEntity()
// Get row security template
rowSec, err := securityList.GetRowSecurityTemplate(userID, schema, tablename)
rowSec, err := securityList.GetRowSecurityTemplate(userRef, schema, tablename)
if err != nil {
// 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
}
// Check if user has a blocking rule
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)
}
@@ -112,8 +126,8 @@ func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error
// Generate the WHERE clause from template
whereClause := rowSec.GetTemplate(pkName, modelType)
logger.Info("Applying row security filter for user %d on %s.%s: %s",
userID, schema, tablename, whereClause)
logger.Info("Applying row security filter for user %v on %s.%s: %s",
userRef, schema, tablename, whereClause)
// Apply the WHERE clause to the query
query := secCtx.GetQuery()
+4
View File
@@ -26,6 +26,10 @@ func (m *mockSecurityContext) GetUserID() (int, bool) {
return m.userID, m.hasUser
}
func (m *mockSecurityContext) GetUserRef() (any, bool) {
return m.userID, m.hasUser
}
func (m *mockSecurityContext) GetSchema() string {
return m.schema
}
+6 -2
View File
@@ -121,8 +121,12 @@ type ColumnSecurityProvider interface {
// RowSecurityProvider handles row-level security (filtering)
type RowSecurityProvider interface {
// GetRowSecurity loads row security rules for a user and entity
GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error)
// GetRowSecurity loads row security rules for a user and entity.
// 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
+1 -4
View File
@@ -260,10 +260,7 @@ func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, usern
// If username is provided, get user's credentials
var allowCredentials []PasskeyCredentialDescriptor
if username != "" {
var creds []struct {
ID string `json:"credential_id"`
Transports []string `json:"transports"`
}
var creds []passkeyCredential
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredsByUsername) {
_, directCreds, err := p.getCredsByUsernameDirect(ctx, username)
+13 -18
View File
@@ -39,7 +39,7 @@ func (p *DatabasePasskeyProvider) storeCredentialDirect(ctx context.Context, par
var exists int
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 {
return fmt.Errorf("Credential already exists")
return fmt.Errorf("credential already exists")
} else if !errors.Is(err, sql.ErrNoRows) {
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))
if err := db.QueryRowContext(ctx, userCheckQuery, params.UserID).Scan(&userExists); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("User not found")
return fmt.Errorf("user not found")
}
return err
}
@@ -78,7 +78,7 @@ func (p *DatabasePasskeyProvider) getCredentialDirect(ctx context.Context, crede
})
if err != nil {
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)
}
@@ -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))
if err := db.QueryRowContext(ctx, query, credentialIDB64).Scan(&oldCounter); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("Credential not found")
return fmt.Errorf("credential not found")
}
return err
}
@@ -188,7 +188,7 @@ func (p *DatabasePasskeyProvider) deleteCredentialDirect(ctx context.Context, us
return err
}
if rows == 0 {
return fmt.Errorf("Credential not found")
return fmt.Errorf("credential not found")
}
return nil
})
@@ -206,24 +206,19 @@ func (p *DatabasePasskeyProvider) updateNameDirect(ctx context.Context, userID i
return err
}
if rows == 0 {
return fmt.Errorf("Credential not found")
return fmt.Errorf("credential not found")
}
return nil
})
}
func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context, username string) (int, []struct {
type passkeyCredential struct {
ID string `json:"credential_id"`
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))
if err := db.QueryRowContext(ctx, userQuery, username, true).Scan(&userID); err != nil {
return err
@@ -236,7 +231,7 @@ func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context,
}
defer rows.Close()
creds = make([]credT, 0)
creds = make([]passkeyCredential, 0)
for rows.Next() {
var credID string
var transportsJSON sql.NullString
@@ -247,13 +242,13 @@ func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context,
if transportsJSON.Valid && transportsJSON.String != "" {
_ = 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()
})
if err != nil {
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)
}
+10 -7
View File
@@ -34,7 +34,10 @@ type RowSecurity struct {
Tablename string `json:"tablename"`
Template string `json:"template"`
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 {
@@ -42,7 +45,7 @@ func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Typ
str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName)
str = strings.ReplaceAll(str, "{TableName}", m.Tablename)
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
}
@@ -413,7 +416,7 @@ func (m *SecurityList) ClearSecurity(pUserID int, pSchema, pTablename string) er
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 {
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 {
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
record, err := m.provider.GetRowSecurity(ctx, pUserID, pSchema, pTablename)
record, err := m.provider.GetRowSecurity(ctx, pUserRef, pSchema, pTablename)
if err != nil {
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
}
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")()
if m.RowSecurity == nil {
@@ -446,7 +449,7 @@ func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename s
m.RowSecurityMutex.RLock()
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 {
return RowSecurity{}, fmt.Errorf("no row security data")
}
+1 -1
View File
@@ -44,7 +44,7 @@ func (m *mockSecurityProvider) GetColumnSecurity(ctx context.Context, userID int
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
}
+6 -6
View File
@@ -907,7 +907,7 @@ func (p *DatabaseRowSecurityProvider) reconnectDB() error {
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) {
return RowSecurity{}, ErrDirectModeUnsupported
}
@@ -917,7 +917,7 @@ func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID
runQuery := func() error {
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()
if isDBClosed(err) {
@@ -932,7 +932,7 @@ func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID
return RowSecurity{
Schema: schema,
Tablename: table,
UserID: userID,
UserID: userRef,
Template: template,
HasBlock: hasBlock,
}, 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)
if p.blocked[key] {
return RowSecurity{
Schema: schema,
Tablename: table,
UserID: userID,
UserID: userRef,
HasBlock: true,
}, nil
}
@@ -985,7 +985,7 @@ func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID i
return RowSecurity{
Schema: schema,
Tablename: table,
UserID: userID,
UserID: userRef,
Template: template,
HasBlock: false,
}, nil
+9 -9
View File
@@ -23,8 +23,8 @@ import (
// than introducing a mismatch between modes.
var (
errUsernameExists = errors.New("Username already exists")
errEmailExists = errors.New("Email already exists")
errUsernameExists = errors.New("username already exists")
errEmailExists = errors.New("email already exists")
)
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 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)
}
@@ -88,13 +88,13 @@ func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginReques
func (a *DatabaseAuthenticator) registerDirect(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
if req.Username == "" {
return nil, fmt.Errorf("Username is required")
return nil, fmt.Errorf("username is required")
}
if req.Email == "" {
return nil, fmt.Errorf("Email is required")
return nil, fmt.Errorf("email is required")
}
if req.Password == "" {
return nil, fmt.Errorf("Password is required")
return nil, fmt.Errorf("password is required")
}
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)
}
if rows == 0 {
return fmt.Errorf("Session not found")
return fmt.Errorf("session not found")
}
if req.Token != "" {
@@ -222,7 +222,7 @@ func (a *DatabaseAuthenticator) sessionDirect(ctx context.Context, token string)
})
if err != nil {
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)
}
@@ -262,7 +262,7 @@ func (a *DatabaseAuthenticator) refreshTokenDirect(ctx context.Context, oldToken
})
if err != nil {
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)
}
@@ -22,7 +22,7 @@ func (p *DatabaseTwoFactorProvider) enable2FADirect(ctx context.Context, userID
if rows, err := res.RowsAffected(); err != nil {
return err
} 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))
@@ -50,7 +50,7 @@ func (p *DatabaseTwoFactorProvider) disable2FADirect(ctx context.Context, userID
if rows, err := res.RowsAffected(); err != nil {
return err
} 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))
@@ -67,7 +67,7 @@ func (p *DatabaseTwoFactorProvider) get2FAStatusDirect(ctx context.Context, user
})
if err != nil {
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)
}
@@ -83,7 +83,7 @@ func (p *DatabaseTwoFactorProvider) get2FASecretDirect(ctx context.Context, user
})
if err != nil {
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)
}
@@ -101,7 +101,7 @@ func (p *DatabaseTwoFactorProvider) regenerateBackupCodesDirect(ctx context.Cont
return err
}
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))
@@ -134,7 +134,7 @@ func (p *DatabaseTwoFactorProvider) validateBackupCodeDirect(ctx context.Context
return err
}
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))
+11
View File
@@ -71,6 +71,17 @@ func (s *securityContext) GetUserID() (int, bool) {
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 {
return s.ctx.Schema
}