feat(hooks): add BeforeOp hook and fix BeforeScan row-security gap
Tests / Unit Tests (push) Failing after 11s
Tests / Integration Tests (push) Failing after 15s
Build , Vet Test, and Lint / Build (push) Successful in 1m45s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m0s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m0s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m4s

Adds a BeforeOp HookType across resolvespec, restheadspec, websocketspec,
mqttspec, and funcspec that fires before every SQL operation (read,
create, update, delete, scan/query) via a new ExecuteBeforeOp helper.

Also closes a row-level-security gap: resolvespec registered a BeforeScan
hook for ApplyRowSecurity but never fired it, and websocketspec/mqttspec
had no BeforeScan hook point at all, so row security was never applied
to their queries. BeforeScan now fires right before the actual scan in
all three, with the (possibly hook-modified) query used for execution.
This commit is contained in:
2026-07-25 11:39:31 +02:00
parent 873e8925d4
commit 52d3dca1fa
12 changed files with 191 additions and 43 deletions
+4 -4
View File
@@ -197,7 +197,7 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
hookCtx.Tx = tx hookCtx.Tx = tx
// Execute BeforeQueryList hook (inside transaction) // Execute BeforeQueryList hook (inside transaction)
if err := h.hooks.Execute(BeforeQueryList, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeQueryList, hookCtx); err != nil {
logger.Error("BeforeQueryList hook failed: %v", err) logger.Error("BeforeQueryList hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err return err
@@ -261,7 +261,7 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
// Execute BeforeSQLExec hook // Execute BeforeSQLExec hook
hookCtx.SQLQuery = sqlquery hookCtx.SQLQuery = sqlquery
if err := h.hooks.Execute(BeforeSQLExec, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeSQLExec, hookCtx); err != nil {
logger.Error("BeforeSQLExec hook failed: %v", err) logger.Error("BeforeSQLExec hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err return err
@@ -563,7 +563,7 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
hookCtx.Tx = tx hookCtx.Tx = tx
// Execute BeforeQuery hook (inside transaction) // Execute BeforeQuery hook (inside transaction)
if err := h.hooks.Execute(BeforeQuery, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeQuery, hookCtx); err != nil {
logger.Error("BeforeQuery hook failed: %v", err) logger.Error("BeforeQuery hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err return err
@@ -582,7 +582,7 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
sqlquery = hookCtx.SQLQuery sqlquery = hookCtx.SQLQuery
// Execute BeforeSQLExec hook // Execute BeforeSQLExec hook
if err := h.hooks.Execute(BeforeSQLExec, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeSQLExec, hookCtx); err != nil {
logger.Error("BeforeSQLExec hook failed: %v", err) logger.Error("BeforeSQLExec hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err return err
+14
View File
@@ -28,6 +28,10 @@ const (
// Response hooks (before response is sent) // Response hooks (before response is sent)
BeforeResponse HookType = "before_response" BeforeResponse HookType = "before_response"
// BeforeOp fires immediately before every SQL operation (query, query list, SQL exec).
// It fires at each individual SQL-operation hook point, so it runs once per statement executed.
BeforeOp HookType = "before_op"
) )
// HookContext contains all the data available to a hook // HookContext contains all the data available to a hook
@@ -130,6 +134,16 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
return nil return nil
} }
// ExecuteBeforeOp executes the BeforeOp hook followed by the given SQL-operation hook
// (BeforeQuery, BeforeQueryList, or BeforeSQLExec). BeforeOp always runs first so it can
// observe/veto every SQL operation regardless of type.
func (r *HookRegistry) ExecuteBeforeOp(hookType HookType, ctx *HookContext) error {
if err := r.Execute(BeforeOp, ctx); err != nil {
return err
}
return r.Execute(hookType, ctx)
}
// Clear removes all hooks for the specified type // Clear removes all hooks for the specified type
func (r *HookRegistry) Clear(hookType HookType) { func (r *HookRegistry) Clear(hookType HookType) {
delete(r.hooks, hookType) delete(r.hooks, hookType)
+28 -4
View File
@@ -313,7 +313,7 @@ func (h *Handler) handleRequest(client *Client, msg *Message) {
// handleRead processes a read operation // handleRead processes a read operation
func (h *Handler) handleRead(client *Client, msg *Message, hookCtx *HookContext) { func (h *Handler) handleRead(client *Client, msg *Message, hookCtx *HookContext) {
// Execute before hook // Execute before hook
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
logger.Error("[MQTTSpec] BeforeRead hook failed: %v", err) logger.Error("[MQTTSpec] BeforeRead hook failed: %v", err)
h.sendError(client.ID, msg.ID, "hook_error", err.Error()) h.sendError(client.ID, msg.ID, "hook_error", err.Error())
return return
@@ -356,7 +356,7 @@ func (h *Handler) handleRead(client *Client, msg *Message, hookCtx *HookContext)
// handleCreate processes a create operation // handleCreate processes a create operation
func (h *Handler) handleCreate(client *Client, msg *Message, hookCtx *HookContext) { func (h *Handler) handleCreate(client *Client, msg *Message, hookCtx *HookContext) {
// Execute before hook // Execute before hook
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
logger.Error("[MQTTSpec] BeforeCreate hook failed: %v", err) logger.Error("[MQTTSpec] BeforeCreate hook failed: %v", err)
h.sendError(client.ID, msg.ID, "hook_error", err.Error()) h.sendError(client.ID, msg.ID, "hook_error", err.Error())
return return
@@ -390,7 +390,7 @@ func (h *Handler) handleCreate(client *Client, msg *Message, hookCtx *HookContex
// handleUpdate processes an update operation // handleUpdate processes an update operation
func (h *Handler) handleUpdate(client *Client, msg *Message, hookCtx *HookContext) { func (h *Handler) handleUpdate(client *Client, msg *Message, hookCtx *HookContext) {
// Execute before hook // Execute before hook
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
logger.Error("[MQTTSpec] BeforeUpdate hook failed: %v", err) logger.Error("[MQTTSpec] BeforeUpdate hook failed: %v", err)
h.sendError(client.ID, msg.ID, "hook_error", err.Error()) h.sendError(client.ID, msg.ID, "hook_error", err.Error())
return return
@@ -424,7 +424,7 @@ func (h *Handler) handleUpdate(client *Client, msg *Message, hookCtx *HookContex
// handleDelete processes a delete operation // handleDelete processes a delete operation
func (h *Handler) handleDelete(client *Client, msg *Message, hookCtx *HookContext) { func (h *Handler) handleDelete(client *Client, msg *Message, hookCtx *HookContext) {
// Execute before hook // Execute before hook
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("[MQTTSpec] BeforeDelete hook failed: %v", err) logger.Error("[MQTTSpec] BeforeDelete hook failed: %v", err)
h.sendError(client.ID, msg.ID, "hook_error", err.Error()) h.sendError(client.ID, msg.ID, "hook_error", err.Error())
return return
@@ -686,6 +686,18 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
} }
} }
// Execute BeforeScan hooks - pass query so hooks (e.g. row-level security) can modify it
if hookCtx.Metadata == nil {
hookCtx.Metadata = make(map[string]interface{})
}
hookCtx.Metadata["query"] = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return nil, fmt.Errorf("BeforeScan hook failed: %w", err)
}
if modifiedQuery, ok := hookCtx.Metadata["query"].(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query // Execute query
if err := query.ScanModel(hookCtx.Context); err != nil { if err := query.ScanModel(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to read record: %w", err) return nil, fmt.Errorf("failed to read record: %w", err)
@@ -738,6 +750,18 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
} }
} }
// Execute BeforeScan hooks - pass query so hooks (e.g. row-level security) can modify it
if hookCtx.Metadata == nil {
hookCtx.Metadata = make(map[string]interface{})
}
hookCtx.Metadata["query"] = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return nil, nil, fmt.Errorf("BeforeScan hook failed: %w", err)
}
if modifiedQuery, ok := hookCtx.Metadata["query"].(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query // Execute query
if err := query.ScanModel(hookCtx.Context); err != nil { if err := query.ScanModel(hookCtx.Context); err != nil {
return nil, nil, fmt.Errorf("failed to read records: %w", err) return nil, nil, fmt.Errorf("failed to read records: %w", err)
+4
View File
@@ -34,6 +34,7 @@ const (
AfterUpdate = websocketspec.AfterUpdate AfterUpdate = websocketspec.AfterUpdate
BeforeDelete = websocketspec.BeforeDelete BeforeDelete = websocketspec.BeforeDelete
AfterDelete = websocketspec.AfterDelete AfterDelete = websocketspec.AfterDelete
BeforeScan = websocketspec.BeforeScan
// Subscription hooks // Subscription hooks
BeforeSubscribe = websocketspec.BeforeSubscribe BeforeSubscribe = websocketspec.BeforeSubscribe
@@ -46,6 +47,9 @@ const (
AfterConnect = websocketspec.AfterConnect AfterConnect = websocketspec.AfterConnect
BeforeDisconnect = websocketspec.BeforeDisconnect BeforeDisconnect = websocketspec.BeforeDisconnect
AfterDisconnect = websocketspec.AfterDisconnect AfterDisconnect = websocketspec.AfterDisconnect
// BeforeOp fires immediately before every SQL operation (read, create, update, delete)
BeforeOp = websocketspec.BeforeOp
) )
// NewHookRegistry creates a new hook registry // NewHookRegistry creates a new hook registry
+10 -4
View File
@@ -27,25 +27,31 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
return security.LoadSecurityRules(secCtx, securityList) return security.LoadSecurityRules(secCtx, securityList)
}) })
// Hook 2: AfterRead - Apply column-level security (masking) // Hook 2: BeforeScan - Apply row-level security filters
handler.Hooks().Register(BeforeScan, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.ApplyRowSecurity(secCtx, securityList)
})
// Hook 3: AfterRead - Apply column-level security (masking)
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error { handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
return security.ApplyColumnSecurity(secCtx, securityList) return security.ApplyColumnSecurity(secCtx, securityList)
}) })
// Hook 3 (Optional): Audit logging // Hook 4 (Optional): Audit logging
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error { handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
return security.LogDataAccess(secCtx) return security.LogDataAccess(secCtx)
}) })
// Hook 4: BeforeUpdate - enforce CanUpdate rule from context/registry // Hook 5: BeforeUpdate - enforce CanUpdate rule from context/registry
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error { handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
return security.CheckModelUpdateAllowed(secCtx) return security.CheckModelUpdateAllowed(secCtx)
}) })
// Hook 5: BeforeDelete - enforce CanDelete rule from context/registry // Hook 6: BeforeDelete - enforce CanDelete rule from context/registry
handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error { handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
return security.CheckModelDeleteAllowed(secCtx) return security.CheckModelDeleteAllowed(secCtx)
+31 -11
View File
@@ -288,7 +288,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
Writer: w, Writer: w,
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed" statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
return err return err
} }
@@ -538,6 +538,16 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
pkName := reflection.GetPrimaryKeyName(singleResult) pkName := reflection.GetPrimaryKeyName(singleResult)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID) query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
// Execute BeforeScan hooks - pass query chain so hooks (e.g. row-level security) can modify it
hookCtx.Query = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err
}
query = hookCtx.Query
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)
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query" statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
@@ -546,6 +556,16 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
result = singleResult result = singleResult
} else { } else {
logger.Debug("Querying multiple records") logger.Debug("Querying multiple records")
// Execute BeforeScan hooks - pass query chain so hooks (e.g. row-level security) can modify it
hookCtx.Query = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err
}
query = hookCtx.Query
// 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)
@@ -635,7 +655,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Writer: w, Writer: w,
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err) return fmt.Errorf("BeforeCreate hook failed: %w", err)
} }
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok { if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
@@ -682,7 +702,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Writer: w, Writer: w,
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err) return fmt.Errorf("BeforeCreate hook failed: %w", err)
} }
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok { if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
@@ -764,7 +784,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Writer: w, Writer: w,
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err) return fmt.Errorf("BeforeCreate hook failed: %w", err)
} }
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok { if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
@@ -811,7 +831,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Writer: w, Writer: w,
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err) return fmt.Errorf("BeforeCreate hook failed: %w", err)
} }
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok { if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
@@ -895,7 +915,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Writer: w, Writer: w,
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err) return fmt.Errorf("BeforeCreate hook failed: %w", err)
} }
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok { if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
@@ -948,7 +968,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Writer: w, Writer: w,
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err) return fmt.Errorf("BeforeCreate hook failed: %w", err)
} }
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok { if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
@@ -1081,7 +1101,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed: %w", err) return fmt.Errorf("BeforeUpdate hook failed: %w", err)
} }
@@ -1302,7 +1322,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed for ID %v: %w", itemID, err) return fmt.Errorf("BeforeUpdate hook failed for ID %v: %w", itemID, err)
} }
@@ -1458,7 +1478,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed for ID %v: %w", itemID, err) return fmt.Errorf("BeforeUpdate hook failed for ID %v: %w", itemID, err)
} }
@@ -1561,7 +1581,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Writer: w, Writer: w,
Tx: h.db, Tx: h.db,
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err) logger.Error("BeforeDelete hook failed: %v", err)
h.sendError(w, http.StatusForbidden, "delete_forbidden", "Delete operation not allowed", err) h.sendError(w, http.StatusForbidden, "delete_forbidden", "Delete operation not allowed", err)
return return
+15
View File
@@ -34,6 +34,11 @@ const (
// Scan/Execute operation hooks (for query building) // Scan/Execute operation hooks (for query building)
BeforeScan HookType = "before_scan" BeforeScan HookType = "before_scan"
// BeforeOp fires immediately before every SQL operation (read, create, update, delete, scan).
// Unlike BeforeHandle, which fires once before operation dispatch, BeforeOp fires at each
// individual SQL-operation hook point, so it runs once per statement executed.
BeforeOp HookType = "before_op"
) )
// HookContext contains all the data available to a hook // HookContext contains all the data available to a hook
@@ -128,6 +133,16 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
return nil return nil
} }
// ExecuteBeforeOp executes the BeforeOp hook followed by the given SQL-operation hook
// (BeforeRead, BeforeCreate, BeforeUpdate, BeforeDelete, or BeforeScan). BeforeOp always
// runs first so it can observe/veto every SQL operation regardless of type.
func (r *HookRegistry) ExecuteBeforeOp(hookType HookType, ctx *HookContext) error {
if err := r.Execute(BeforeOp, ctx); err != nil {
return err
}
return r.Execute(hookType, ctx)
}
// Clear removes all hooks for the specified type // Clear removes all hooks for the specified type
func (r *HookRegistry) Clear(hookType HookType) { func (r *HookRegistry) Clear(hookType HookType) {
delete(r.hooks, hookType) delete(r.hooks, hookType)
+12 -12
View File
@@ -372,7 +372,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error { txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx.Tx = tx hookCtx.Tx = tx
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed" statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err return err
} }
@@ -792,7 +792,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Execute BeforeScan hooks - pass query chain so hooks can modify it // Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query hookCtx.Query = query
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err) logger.Error("BeforeScan hook failed: %v", err)
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed" statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err return err
@@ -1184,7 +1184,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
results := make([]interface{}, 0) results := make([]interface{}, 0)
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error { txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx.Tx = tx hookCtx.Tx = tx
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed" statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err return err
} }
@@ -1269,7 +1269,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Query: query, Query: query,
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeScan, itemHookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeScan, itemHookCtx); err != nil {
return fmt.Errorf("BeforeScan hook failed for item %d: %w", i, err) return fmt.Errorf("BeforeScan hook failed for item %d: %w", i, err)
} }
@@ -1423,7 +1423,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
Writer: w, Writer: w,
} }
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed: %w", err) return fmt.Errorf("BeforeUpdate hook failed: %w", err)
} }
@@ -1500,7 +1500,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Execute BeforeScan hooks - pass query chain so hooks can modify it // Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query hookCtx.Query = query
hookCtx.Tx = tx hookCtx.Tx = tx
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return fmt.Errorf("BeforeScan hook failed: %w", err) return fmt.Errorf("BeforeScan hook failed: %w", err)
} }
@@ -1544,7 +1544,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// pooled connection rather than the now-dead tx. // pooled connection rather than the now-dead tx.
hookCtx.Tx = h.db hookCtx.Tx = h.db
hookCtx.Query = selectQuery hookCtx.Query = selectQuery
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err) logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err) h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return return
@@ -1619,7 +1619,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed for ID %s: %v", itemID, err) logger.Error("BeforeDelete hook failed for ID %s: %v", itemID, err)
return fmt.Errorf("delete not allowed for ID %s: %w", itemID, err) return fmt.Errorf("delete not allowed for ID %s: %w", itemID, err)
} }
@@ -1693,7 +1693,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err) logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err)
return fmt.Errorf("delete not allowed for ID %v: %w", itemID, err) return fmt.Errorf("delete not allowed for ID %v: %w", itemID, err)
} }
@@ -1751,7 +1751,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Tx: tx, Tx: tx,
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err) logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err)
return fmt.Errorf("delete not allowed for ID %v: %w", itemID, err) return fmt.Errorf("delete not allowed for ID %v: %w", itemID, err)
} }
@@ -1836,7 +1836,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Data: recordToDelete, Data: recordToDelete,
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err) logger.Error("BeforeDelete hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return return
@@ -1847,7 +1847,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
// Execute BeforeScan hooks - pass query chain so hooks can modify it // Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query hookCtx.Query = query
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(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) h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return return
+15
View File
@@ -34,6 +34,11 @@ const (
// Scan/Execute operation hooks // Scan/Execute operation hooks
BeforeScan HookType = "before_scan" BeforeScan HookType = "before_scan"
// BeforeOp fires immediately before every SQL operation (read, create, update, delete, scan).
// Unlike BeforeHandle, which fires once before operation dispatch, BeforeOp fires at each
// individual SQL-operation hook point, so it runs once per statement executed.
BeforeOp HookType = "before_op"
) )
// HookContext contains all the data available to a hook // HookContext contains all the data available to a hook
@@ -137,6 +142,16 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
return nil return nil
} }
// ExecuteBeforeOp executes the BeforeOp hook followed by the given SQL-operation hook
// (BeforeRead, BeforeCreate, BeforeUpdate, BeforeDelete, or BeforeScan). BeforeOp always
// runs first so it can observe/veto every SQL operation regardless of type.
func (r *HookRegistry) ExecuteBeforeOp(hookType HookType, ctx *HookContext) error {
if err := r.Execute(BeforeOp, ctx); err != nil {
return err
}
return r.Execute(hookType, ctx)
}
// Clear removes all hooks for the specified type // Clear removes all hooks for the specified type
func (r *HookRegistry) Clear(hookType HookType) { func (r *HookRegistry) Clear(hookType HookType) {
delete(r.hooks, hookType) delete(r.hooks, hookType)
+28 -4
View File
@@ -221,7 +221,7 @@ func (h *Handler) handleRequest(conn *Connection, msg *Message) {
// handleRead processes a read operation // handleRead processes a read operation
func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContext) { func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook // Execute before hook
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeRead hook failed: %v", err) logger.Error("[WebSocketSpec] BeforeRead hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error()) errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp) _ = conn.SendJSON(errResp)
@@ -273,7 +273,7 @@ func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContex
// handleCreate processes a create operation // handleCreate processes a create operation
func (h *Handler) handleCreate(conn *Connection, msg *Message, hookCtx *HookContext) { func (h *Handler) handleCreate(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook // Execute before hook
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeCreate hook failed: %v", err) logger.Error("[WebSocketSpec] BeforeCreate hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error()) errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp) _ = conn.SendJSON(errResp)
@@ -311,7 +311,7 @@ func (h *Handler) handleCreate(conn *Connection, msg *Message, hookCtx *HookCont
// handleUpdate processes an update operation // handleUpdate processes an update operation
func (h *Handler) handleUpdate(conn *Connection, msg *Message, hookCtx *HookContext) { func (h *Handler) handleUpdate(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook // Execute before hook
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeUpdate hook failed: %v", err) logger.Error("[WebSocketSpec] BeforeUpdate hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error()) errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp) _ = conn.SendJSON(errResp)
@@ -349,7 +349,7 @@ func (h *Handler) handleUpdate(conn *Connection, msg *Message, hookCtx *HookCont
// handleDelete processes a delete operation // handleDelete processes a delete operation
func (h *Handler) handleDelete(conn *Connection, msg *Message, hookCtx *HookContext) { func (h *Handler) handleDelete(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook // Execute before hook
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeDelete hook failed: %v", err) logger.Error("[WebSocketSpec] BeforeDelete hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error()) errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp) _ = conn.SendJSON(errResp)
@@ -574,6 +574,18 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
} }
} }
// Execute BeforeScan hooks - pass query so hooks (e.g. row-level security) can modify it
if hookCtx.Metadata == nil {
hookCtx.Metadata = make(map[string]interface{})
}
hookCtx.Metadata["query"] = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return nil, fmt.Errorf("BeforeScan hook failed: %w", err)
}
if modifiedQuery, ok := hookCtx.Metadata["query"].(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query // Execute query
if err := query.ScanModel(hookCtx.Context); err != nil { if err := query.ScanModel(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to read record: %w", err) return nil, fmt.Errorf("failed to read record: %w", err)
@@ -624,6 +636,18 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
} }
} }
// Execute BeforeScan hooks - pass query so hooks (e.g. row-level security) can modify it
if hookCtx.Metadata == nil {
hookCtx.Metadata = make(map[string]interface{})
}
hookCtx.Metadata["query"] = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return nil, nil, fmt.Errorf("BeforeScan hook failed: %w", err)
}
if modifiedQuery, ok := hookCtx.Metadata["query"].(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query // Execute query
if err := query.ScanModel(hookCtx.Context); err != nil { if err := query.ScanModel(hookCtx.Context); err != nil {
return nil, nil, fmt.Errorf("failed to read records: %w", err) return nil, nil, fmt.Errorf("failed to read records: %w", err)
+20
View File
@@ -35,6 +35,11 @@ const (
// AfterDelete is called after a delete operation // AfterDelete is called after a delete operation
AfterDelete HookType = "after_delete" AfterDelete HookType = "after_delete"
// BeforeScan is called right before a read query is executed against the database,
// after all filters/sort/pagination have been applied. Use this for row-level
// security that needs to modify the query (stored in HookContext.Metadata["query"]).
BeforeScan HookType = "before_scan"
// BeforeSubscribe is called before creating a subscription // BeforeSubscribe is called before creating a subscription
BeforeSubscribe HookType = "before_subscribe" BeforeSubscribe HookType = "before_subscribe"
// AfterSubscribe is called after creating a subscription // AfterSubscribe is called after creating a subscription
@@ -54,6 +59,11 @@ const (
BeforeDisconnect HookType = "before_disconnect" BeforeDisconnect HookType = "before_disconnect"
// AfterDisconnect is called after a connection is closed // AfterDisconnect is called after a connection is closed
AfterDisconnect HookType = "after_disconnect" AfterDisconnect HookType = "after_disconnect"
// BeforeOp fires immediately before every SQL operation (read, create, update, delete).
// Unlike BeforeHandle, which fires once before operation dispatch, BeforeOp fires at each
// individual SQL-operation hook point, so it runs once per statement executed.
BeforeOp HookType = "before_op"
) )
// HookContext contains context information for hook execution // HookContext contains context information for hook execution
@@ -197,6 +207,16 @@ func (hr *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
return nil return nil
} }
// ExecuteBeforeOp executes the BeforeOp hook followed by the given SQL-operation hook
// (BeforeRead, BeforeCreate, BeforeUpdate, or BeforeDelete). BeforeOp always runs first
// so it can observe/veto every SQL operation regardless of type.
func (hr *HookRegistry) ExecuteBeforeOp(hookType HookType, ctx *HookContext) error {
if err := hr.Execute(BeforeOp, ctx); err != nil {
return err
}
return hr.Execute(hookType, ctx)
}
// HasHooks checks if any hooks are registered for a hook type // HasHooks checks if any hooks are registered for a hook type
func (hr *HookRegistry) HasHooks(hookType HookType) bool { func (hr *HookRegistry) HasHooks(hookType HookType) bool {
hooks, exists := hr.hooks[hookType] hooks, exists := hr.hooks[hookType]
+10 -4
View File
@@ -27,25 +27,31 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
return security.LoadSecurityRules(secCtx, securityList) return security.LoadSecurityRules(secCtx, securityList)
}) })
// Hook 2: AfterRead - Apply column-level security (masking) // Hook 2: BeforeScan - Apply row-level security filters
handler.Hooks().Register(BeforeScan, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.ApplyRowSecurity(secCtx, securityList)
})
// Hook 3: AfterRead - Apply column-level security (masking)
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error { handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
return security.ApplyColumnSecurity(secCtx, securityList) return security.ApplyColumnSecurity(secCtx, securityList)
}) })
// Hook 3 (Optional): Audit logging // Hook 4 (Optional): Audit logging
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error { handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
return security.LogDataAccess(secCtx) return security.LogDataAccess(secCtx)
}) })
// Hook 4: BeforeUpdate - enforce CanUpdate rule from context/registry // Hook 5: BeforeUpdate - enforce CanUpdate rule from context/registry
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error { handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
return security.CheckModelUpdateAllowed(secCtx) return security.CheckModelUpdateAllowed(secCtx)
}) })
// Hook 5: BeforeDelete - enforce CanDelete rule from context/registry // Hook 6: BeforeDelete - enforce CanDelete rule from context/registry
handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error { handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
return security.CheckModelDeleteAllowed(secCtx) return security.CheckModelDeleteAllowed(secCtx)