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)