From 52d3dca1fa52da083bd3d05a227a86cad7aac5fa Mon Sep 17 00:00:00 2001 From: Hein Date: Sat, 25 Jul 2026 11:39:31 +0200 Subject: [PATCH] feat(hooks): add BeforeOp hook and fix BeforeScan row-security gap 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. --- pkg/funcspec/function_api.go | 8 +++--- pkg/funcspec/hooks.go | 14 ++++++++++ pkg/mqttspec/handler.go | 32 +++++++++++++++++++--- pkg/mqttspec/hooks.go | 4 +++ pkg/mqttspec/security_hooks.go | 14 +++++++--- pkg/resolvespec/handler.go | 42 +++++++++++++++++++++-------- pkg/resolvespec/hooks.go | 15 +++++++++++ pkg/restheadspec/handler.go | 24 ++++++++--------- pkg/restheadspec/hooks.go | 15 +++++++++++ pkg/websocketspec/handler.go | 32 +++++++++++++++++++--- pkg/websocketspec/hooks.go | 20 ++++++++++++++ pkg/websocketspec/security_hooks.go | 14 +++++++--- 12 files changed, 191 insertions(+), 43 deletions(-) diff --git a/pkg/funcspec/function_api.go b/pkg/funcspec/function_api.go index 0773e63..1c5f6ec 100644 --- a/pkg/funcspec/function_api.go +++ b/pkg/funcspec/function_api.go @@ -197,7 +197,7 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun hookCtx.Tx = tx // 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) sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) return err @@ -261,7 +261,7 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun // Execute BeforeSQLExec hook 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) sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) return err @@ -563,7 +563,7 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp hookCtx.Tx = tx // 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) sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) return err @@ -582,7 +582,7 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp sqlquery = hookCtx.SQLQuery // 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) sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) return err diff --git a/pkg/funcspec/hooks.go b/pkg/funcspec/hooks.go index d04d19e..e417b6c 100644 --- a/pkg/funcspec/hooks.go +++ b/pkg/funcspec/hooks.go @@ -28,6 +28,10 @@ const ( // Response hooks (before response is sent) 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 @@ -130,6 +134,16 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error { 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 func (r *HookRegistry) Clear(hookType HookType) { delete(r.hooks, hookType) diff --git a/pkg/mqttspec/handler.go b/pkg/mqttspec/handler.go index 7cdf7ef..284d876 100644 --- a/pkg/mqttspec/handler.go +++ b/pkg/mqttspec/handler.go @@ -313,7 +313,7 @@ func (h *Handler) handleRequest(client *Client, msg *Message) { // handleRead processes a read operation func (h *Handler) handleRead(client *Client, msg *Message, hookCtx *HookContext) { // 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) h.sendError(client.ID, msg.ID, "hook_error", err.Error()) return @@ -356,7 +356,7 @@ func (h *Handler) handleRead(client *Client, msg *Message, hookCtx *HookContext) // handleCreate processes a create operation func (h *Handler) handleCreate(client *Client, msg *Message, hookCtx *HookContext) { // 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) h.sendError(client.ID, msg.ID, "hook_error", err.Error()) return @@ -390,7 +390,7 @@ func (h *Handler) handleCreate(client *Client, msg *Message, hookCtx *HookContex // handleUpdate processes an update operation func (h *Handler) handleUpdate(client *Client, msg *Message, hookCtx *HookContext) { // 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) h.sendError(client.ID, msg.ID, "hook_error", err.Error()) return @@ -424,7 +424,7 @@ func (h *Handler) handleUpdate(client *Client, msg *Message, hookCtx *HookContex // handleDelete processes a delete operation func (h *Handler) handleDelete(client *Client, msg *Message, hookCtx *HookContext) { // 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) h.sendError(client.ID, msg.ID, "hook_error", err.Error()) 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 if err := query.ScanModel(hookCtx.Context); err != nil { 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 if err := query.ScanModel(hookCtx.Context); err != nil { return nil, nil, fmt.Errorf("failed to read records: %w", err) diff --git a/pkg/mqttspec/hooks.go b/pkg/mqttspec/hooks.go index 26c590d..205be3c 100644 --- a/pkg/mqttspec/hooks.go +++ b/pkg/mqttspec/hooks.go @@ -34,6 +34,7 @@ const ( AfterUpdate = websocketspec.AfterUpdate BeforeDelete = websocketspec.BeforeDelete AfterDelete = websocketspec.AfterDelete + BeforeScan = websocketspec.BeforeScan // Subscription hooks BeforeSubscribe = websocketspec.BeforeSubscribe @@ -46,6 +47,9 @@ const ( AfterConnect = websocketspec.AfterConnect BeforeDisconnect = websocketspec.BeforeDisconnect AfterDisconnect = websocketspec.AfterDisconnect + + // BeforeOp fires immediately before every SQL operation (read, create, update, delete) + BeforeOp = websocketspec.BeforeOp ) // NewHookRegistry creates a new hook registry diff --git a/pkg/mqttspec/security_hooks.go b/pkg/mqttspec/security_hooks.go index a7731d2..a92462c 100644 --- a/pkg/mqttspec/security_hooks.go +++ b/pkg/mqttspec/security_hooks.go @@ -27,25 +27,31 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.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 { secCtx := newSecurityContext(hookCtx) return security.ApplyColumnSecurity(secCtx, securityList) }) - // Hook 3 (Optional): Audit logging + // Hook 4 (Optional): Audit logging handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error { secCtx := newSecurityContext(hookCtx) 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 { secCtx := newSecurityContext(hookCtx) 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 { secCtx := newSecurityContext(hookCtx) return security.CheckModelDeleteAllowed(secCtx) diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index ccb1038..a4cd531 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -288,7 +288,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st Writer: w, 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" return err } @@ -538,6 +538,16 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st pkName := reflection.GetPrimaryKeyName(singleResult) 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 { logger.Error("Error querying record: %v", err) 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 } else { 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 if err := query.Scan(ctx, modelPtr); err != nil { logger.Error("Error querying records: %v", err) @@ -635,7 +655,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat Writer: w, 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) } 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, 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) } 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, 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) } 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, 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) } 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, 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) } 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, 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) } 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, } - 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) } @@ -1302,7 +1322,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url 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) } @@ -1458,7 +1478,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url 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) } @@ -1561,7 +1581,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id Writer: w, 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) h.sendError(w, http.StatusForbidden, "delete_forbidden", "Delete operation not allowed", err) return diff --git a/pkg/resolvespec/hooks.go b/pkg/resolvespec/hooks.go index cef0e71..8dceb0f 100644 --- a/pkg/resolvespec/hooks.go +++ b/pkg/resolvespec/hooks.go @@ -34,6 +34,11 @@ const ( // Scan/Execute operation hooks (for query building) 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 @@ -128,6 +133,16 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error { 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 func (r *HookRegistry) Clear(hookType HookType) { delete(r.hooks, hookType) diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index 331e306..22d6bdf 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -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 { 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" 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 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) statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed" return err @@ -1184,7 +1184,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat results := make([]interface{}, 0) txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error { hookCtx.Tx = tx - if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { + if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil { statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed" return err } @@ -1269,7 +1269,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat Query: query, 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) } @@ -1423,7 +1423,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id 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) } @@ -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 hookCtx.Query = query 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) } @@ -1544,7 +1544,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id // pooled connection rather than the now-dead tx. hookCtx.Tx = h.db 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) h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err) return @@ -1619,7 +1619,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id 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) 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, } - 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) 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, } - 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) 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, } - 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) h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) 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 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) h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) return diff --git a/pkg/restheadspec/hooks.go b/pkg/restheadspec/hooks.go index b763389..6fe87b7 100644 --- a/pkg/restheadspec/hooks.go +++ b/pkg/restheadspec/hooks.go @@ -34,6 +34,11 @@ const ( // Scan/Execute operation hooks 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 @@ -137,6 +142,16 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error { 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 func (r *HookRegistry) Clear(hookType HookType) { delete(r.hooks, hookType) diff --git a/pkg/websocketspec/handler.go b/pkg/websocketspec/handler.go index 4f7c3fe..9c4abf9 100644 --- a/pkg/websocketspec/handler.go +++ b/pkg/websocketspec/handler.go @@ -221,7 +221,7 @@ func (h *Handler) handleRequest(conn *Connection, msg *Message) { // handleRead processes a read operation func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContext) { // 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) errResp := NewErrorResponse(msg.ID, "hook_error", err.Error()) _ = conn.SendJSON(errResp) @@ -273,7 +273,7 @@ func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContex // handleCreate processes a create operation func (h *Handler) handleCreate(conn *Connection, msg *Message, hookCtx *HookContext) { // 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) errResp := NewErrorResponse(msg.ID, "hook_error", err.Error()) _ = conn.SendJSON(errResp) @@ -311,7 +311,7 @@ func (h *Handler) handleCreate(conn *Connection, msg *Message, hookCtx *HookCont // handleUpdate processes an update operation func (h *Handler) handleUpdate(conn *Connection, msg *Message, hookCtx *HookContext) { // 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) errResp := NewErrorResponse(msg.ID, "hook_error", err.Error()) _ = conn.SendJSON(errResp) @@ -349,7 +349,7 @@ func (h *Handler) handleUpdate(conn *Connection, msg *Message, hookCtx *HookCont // handleDelete processes a delete operation func (h *Handler) handleDelete(conn *Connection, msg *Message, hookCtx *HookContext) { // 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) errResp := NewErrorResponse(msg.ID, "hook_error", err.Error()) _ = 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 if err := query.ScanModel(hookCtx.Context); err != nil { 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 if err := query.ScanModel(hookCtx.Context); err != nil { return nil, nil, fmt.Errorf("failed to read records: %w", err) diff --git a/pkg/websocketspec/hooks.go b/pkg/websocketspec/hooks.go index 25012a8..171470d 100644 --- a/pkg/websocketspec/hooks.go +++ b/pkg/websocketspec/hooks.go @@ -35,6 +35,11 @@ const ( // AfterDelete is called after a delete operation 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 HookType = "before_subscribe" // AfterSubscribe is called after creating a subscription @@ -54,6 +59,11 @@ const ( BeforeDisconnect HookType = "before_disconnect" // AfterDisconnect is called after a connection is closed 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 @@ -197,6 +207,16 @@ func (hr *HookRegistry) Execute(hookType HookType, ctx *HookContext) error { 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 func (hr *HookRegistry) HasHooks(hookType HookType) bool { hooks, exists := hr.hooks[hookType] diff --git a/pkg/websocketspec/security_hooks.go b/pkg/websocketspec/security_hooks.go index 99b0f76..f5596a0 100644 --- a/pkg/websocketspec/security_hooks.go +++ b/pkg/websocketspec/security_hooks.go @@ -27,25 +27,31 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.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 { secCtx := newSecurityContext(hookCtx) return security.ApplyColumnSecurity(secCtx, securityList) }) - // Hook 3 (Optional): Audit logging + // Hook 4 (Optional): Audit logging handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error { secCtx := newSecurityContext(hookCtx) 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 { secCtx := newSecurityContext(hookCtx) 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 { secCtx := newSecurityContext(hookCtx) return security.CheckModelDeleteAllowed(secCtx)