From 79720d54211fecc9fe4a6a113e5fd600ae1803e4 Mon Sep 17 00:00:00 2001 From: Hein Date: Sun, 1 Mar 2026 09:15:30 +0200 Subject: [PATCH] feat(security): add BeforeHandle hook for auth checks after model resolution - Implement BeforeHandle hook to enforce authentication based on model rules. - Integrate with existing security mechanisms to allow or deny access. - Update documentation to reflect new hook and its usage. --- pkg/funcspec/security_adapter.go | 26 ++++++- pkg/modelregistry/model_registry.go | 4 ++ pkg/mqttspec/README.md | 21 +++++- pkg/mqttspec/handler.go | 9 +++ pkg/mqttspec/hooks.go | 5 +- pkg/mqttspec/security_hooks.go | 108 ++++++++++++++++++++++++++++ pkg/resolvespec/README.md | 3 + pkg/resolvespec/handler.go | 20 ++++++ pkg/resolvespec/hooks.go | 7 ++ pkg/resolvespec/security_hooks.go | 12 ++++ pkg/restheadspec/README.md | 3 + pkg/restheadspec/handler.go | 35 +++++++++ pkg/restheadspec/hooks.go | 21 ++++++ pkg/restheadspec/security_hooks.go | 12 ++++ pkg/security/QUICK_REFERENCE.md | 44 ++++++++---- pkg/security/README.md | 56 +++++++++++++-- pkg/security/hooks.go | 58 +++++++++++++++ pkg/security/middleware.go | 25 +++++++ pkg/websocketspec/README.md | 17 ++++- pkg/websocketspec/handler.go | 10 +++ pkg/websocketspec/hooks.go | 18 +++++ pkg/websocketspec/security_hooks.go | 12 ++++ 22 files changed, 503 insertions(+), 23 deletions(-) create mode 100644 pkg/mqttspec/security_hooks.go diff --git a/pkg/funcspec/security_adapter.go b/pkg/funcspec/security_adapter.go index 49f7fdc..fd5233a 100644 --- a/pkg/funcspec/security_adapter.go +++ b/pkg/funcspec/security_adapter.go @@ -2,14 +2,38 @@ package funcspec import ( "context" + "fmt" + "net/http" "github.com/bitechdev/ResolveSpec/pkg/security" ) // RegisterSecurityHooks registers security hooks for funcspec handlers // Note: funcspec operates on SQL queries directly, so row-level security is not directly applicable -// We provide audit logging for data access tracking +// We provide auth enforcement and audit logging for data access tracking func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { + // Hook 0: BeforeQueryList - Auth check before list query execution + handler.Hooks().Register(BeforeQueryList, func(hookCtx *HookContext) error { + if hookCtx.UserContext == nil || hookCtx.UserContext.UserID == 0 { + hookCtx.Abort = true + hookCtx.AbortMessage = "authentication required" + hookCtx.AbortCode = http.StatusUnauthorized + return fmt.Errorf("authentication required") + } + return nil + }) + + // Hook 0: BeforeQuery - Auth check before single query execution + handler.Hooks().Register(BeforeQuery, func(hookCtx *HookContext) error { + if hookCtx.UserContext == nil || hookCtx.UserContext.UserID == 0 { + hookCtx.Abort = true + hookCtx.AbortMessage = "authentication required" + hookCtx.AbortCode = http.StatusUnauthorized + return fmt.Errorf("authentication required") + } + return nil + }) + // Hook 1: BeforeQueryList - Audit logging before query list execution handler.Hooks().Register(BeforeQueryList, func(hookCtx *HookContext) error { secCtx := newFuncSpecSecurityContext(hookCtx) diff --git a/pkg/modelregistry/model_registry.go b/pkg/modelregistry/model_registry.go index 89c54a8..d5fe1e6 100644 --- a/pkg/modelregistry/model_registry.go +++ b/pkg/modelregistry/model_registry.go @@ -10,6 +10,8 @@ import ( type ModelRules struct { CanPublicRead bool // Whether the model can be read (GET operations) CanPublicUpdate bool // Whether the model can be updated (PUT/PATCH operations) + CanPublicCreate bool // Whether the model can be created (POST operations) + CanPublicDelete bool // Whether the model can be deleted (DELETE operations) CanRead bool // Whether the model can be read (GET operations) CanUpdate bool // Whether the model can be updated (PUT/PATCH operations) CanCreate bool // Whether the model can be created (POST operations) @@ -26,6 +28,8 @@ func DefaultModelRules() ModelRules { CanDelete: true, CanPublicRead: false, CanPublicUpdate: false, + CanPublicCreate: false, + CanPublicDelete: false, SecurityDisabled: false, } } diff --git a/pkg/mqttspec/README.md b/pkg/mqttspec/README.md index 4266ed4..555546d 100644 --- a/pkg/mqttspec/README.md +++ b/pkg/mqttspec/README.md @@ -9,7 +9,7 @@ MQTTSpec is an MQTT-based database query framework that enables real-time databa - **Full CRUD Operations**: Create, Read, Update, Delete with hooks - **Real-time Subscriptions**: Subscribe to entity changes with filtering - **Database Agnostic**: GORM and Bun ORM support -- **Lifecycle Hooks**: 12 hooks for authentication, authorization, validation, and auditing +- **Lifecycle Hooks**: 13 hooks for authentication, authorization, validation, and auditing - **Multi-tenancy Support**: Built-in tenant isolation via hooks - **Thread-safe**: Proper concurrency handling throughout @@ -326,10 +326,11 @@ When any client creates/updates/deletes a user matching the subscription filters ## Lifecycle Hooks -MQTTSpec provides 12 lifecycle hooks for implementing cross-cutting concerns: +MQTTSpec provides 13 lifecycle hooks for implementing cross-cutting concerns: ### Hook Types +- `BeforeHandle` — fires after model resolution, before operation dispatch (auth checks) - `BeforeConnect` / `AfterConnect` - Connection lifecycle - `BeforeDisconnect` / `AfterDisconnect` - Disconnection lifecycle - `BeforeRead` / `AfterRead` - Read operations @@ -339,6 +340,20 @@ MQTTSpec provides 12 lifecycle hooks for implementing cross-cutting concerns: - `BeforeSubscribe` / `AfterSubscribe` - Subscription creation - `BeforeUnsubscribe` / `AfterUnsubscribe` - Subscription removal +### Security Hooks (Recommended) + +Use `RegisterSecurityHooks` for integrated auth with model-rule support: + +```go +import "github.com/bitechdev/ResolveSpec/pkg/security" + +provider := security.NewCompositeSecurityProvider(auth, colSec, rowSec) +securityList := security.NewSecurityList(provider) +mqttspec.RegisterSecurityHooks(handler, securityList) +// Registers BeforeHandle (model auth), BeforeRead (load rules), +// AfterRead (column security + audit), BeforeUpdate, BeforeDelete +``` + ### Authentication Example (JWT) ```go @@ -657,7 +672,7 @@ handler, err := mqttspec.NewHandlerWithGORM(db, | **Network Efficiency** | Better for unreliable networks | Better for low-latency | | **Best For** | IoT, mobile apps, distributed systems | Web applications, real-time dashboards | | **Message Protocol** | Same JSON structure | Same JSON structure | -| **Hooks** | Same 12 hooks | Same 12 hooks | +| **Hooks** | Same 13 hooks | Same 13 hooks | | **CRUD Operations** | Identical | Identical | | **Subscriptions** | Identical (via MQTT topics) | Identical (via app-level) | diff --git a/pkg/mqttspec/handler.go b/pkg/mqttspec/handler.go index 6154283..de4a062 100644 --- a/pkg/mqttspec/handler.go +++ b/pkg/mqttspec/handler.go @@ -284,6 +284,15 @@ func (h *Handler) handleRequest(client *Client, msg *Message) { }, } + // Execute BeforeHandle hook - auth check fires here, after model resolution + hookCtx.Operation = string(msg.Operation) + if err := h.hooks.Execute(BeforeHandle, hookCtx); err != nil { + if hookCtx.Abort { + h.sendError(client.ID, msg.ID, "unauthorized", hookCtx.AbortMessage) + } + return + } + // Route to operation handler switch msg.Operation { case OperationRead: diff --git a/pkg/mqttspec/hooks.go b/pkg/mqttspec/hooks.go index 5e20dac..26c590d 100644 --- a/pkg/mqttspec/hooks.go +++ b/pkg/mqttspec/hooks.go @@ -20,8 +20,11 @@ type ( HookRegistry = websocketspec.HookRegistry ) -// Hook type constants - all 12 lifecycle hooks +// Hook type constants - all lifecycle hooks const ( + // BeforeHandle fires after model resolution, before operation dispatch + BeforeHandle = websocketspec.BeforeHandle + // CRUD operation hooks BeforeRead = websocketspec.BeforeRead AfterRead = websocketspec.AfterRead diff --git a/pkg/mqttspec/security_hooks.go b/pkg/mqttspec/security_hooks.go new file mode 100644 index 0000000..9f9b799 --- /dev/null +++ b/pkg/mqttspec/security_hooks.go @@ -0,0 +1,108 @@ +package mqttspec + +import ( + "context" + "net/http" + + "github.com/bitechdev/ResolveSpec/pkg/logger" + "github.com/bitechdev/ResolveSpec/pkg/security" +) + +// RegisterSecurityHooks registers all security-related hooks with the MQTT handler +func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { + // Hook 0: BeforeHandle - enforce auth after model resolution + handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error { + if err := security.CheckModelAuthAllowed(newSecurityContext(hookCtx), hookCtx.Operation); err != nil { + hookCtx.Abort = true + hookCtx.AbortMessage = err.Error() + hookCtx.AbortCode = http.StatusUnauthorized + return err + } + return nil + }) + + // Hook 1: BeforeRead - Load security rules + handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { + secCtx := newSecurityContext(hookCtx) + return security.LoadSecurityRules(secCtx, securityList) + }) + + // Hook 2: 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 + handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error { + secCtx := newSecurityContext(hookCtx) + return security.LogDataAccess(secCtx) + }) + + // Hook 4: 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 + handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error { + secCtx := newSecurityContext(hookCtx) + return security.CheckModelDeleteAllowed(secCtx) + }) + + logger.Info("Security hooks registered for mqttspec handler") +} + +// securityContext adapts mqttspec.HookContext to security.SecurityContext interface +type securityContext struct { + ctx *HookContext +} + +func newSecurityContext(ctx *HookContext) security.SecurityContext { + return &securityContext{ctx: ctx} +} + +func (s *securityContext) GetContext() context.Context { + return s.ctx.Context +} + +func (s *securityContext) GetUserID() (int, bool) { + return security.GetUserID(s.ctx.Context) +} + +func (s *securityContext) GetSchema() string { + return s.ctx.Schema +} + +func (s *securityContext) GetEntity() string { + return s.ctx.Entity +} + +func (s *securityContext) GetModel() interface{} { + return s.ctx.Model +} + +// GetQuery retrieves a stored query from hook metadata +func (s *securityContext) GetQuery() interface{} { + if s.ctx.Metadata == nil { + return nil + } + return s.ctx.Metadata["query"] +} + +// SetQuery stores the query in hook metadata +func (s *securityContext) SetQuery(query interface{}) { + if s.ctx.Metadata == nil { + s.ctx.Metadata = make(map[string]interface{}) + } + s.ctx.Metadata["query"] = query +} + +func (s *securityContext) GetResult() interface{} { + return s.ctx.Result +} + +func (s *securityContext) SetResult(result interface{}) { + s.ctx.Result = result +} diff --git a/pkg/resolvespec/README.md b/pkg/resolvespec/README.md index 84c61ee..5042a78 100644 --- a/pkg/resolvespec/README.md +++ b/pkg/resolvespec/README.md @@ -644,6 +644,7 @@ handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookCon ``` **Available Hook Types**: +* `BeforeHandle` — fires after model resolution, before operation dispatch (auth checks) * `BeforeRead`, `AfterRead` * `BeforeCreate`, `AfterCreate` * `BeforeUpdate`, `AfterUpdate` @@ -654,11 +655,13 @@ handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookCon * `Handler`: Access to handler, database, and registry * `Schema`, `Entity`, `TableName`: Request info * `Model`: The registered model type +* `Operation`: Current operation string (`"read"`, `"create"`, `"update"`, `"delete"`) * `Options`: Parsed request options (filters, sorting, etc.) * `ID`: Record ID (for single-record operations) * `Data`: Request data (for create/update) * `Result`: Operation result (for after hooks) * `Writer`: Response writer (allows hooks to modify response) +* `Abort`, `AbortMessage`, `AbortCode`: Set in hook to abort with an error response ## Model Registration diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index 48c1ca6..fcf2227 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -138,6 +138,26 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s validator := common.NewColumnValidator(model) req.Options = validator.FilterRequestOptions(req.Options) + // Execute BeforeHandle hook - auth check fires here, after model resolution + beforeCtx := &HookContext{ + Context: ctx, + Handler: h, + Schema: schema, + Entity: entity, + Model: model, + Writer: w, + Request: r, + Operation: req.Operation, + } + if err := h.hooks.Execute(BeforeHandle, beforeCtx); err != nil { + code := http.StatusUnauthorized + if beforeCtx.AbortCode != 0 { + code = beforeCtx.AbortCode + } + h.sendError(w, code, "unauthorized", beforeCtx.AbortMessage, err) + return + } + switch req.Operation { case "read": h.handleRead(ctx, w, id, req.Options) diff --git a/pkg/resolvespec/hooks.go b/pkg/resolvespec/hooks.go index 8121e7c..cef0e71 100644 --- a/pkg/resolvespec/hooks.go +++ b/pkg/resolvespec/hooks.go @@ -12,6 +12,10 @@ import ( type HookType string const ( + // BeforeHandle fires after model resolution, before operation dispatch. + // Use this for auth checks that need model rules and user context simultaneously. + BeforeHandle HookType = "before_handle" + // Read operation hooks BeforeRead HookType = "before_read" AfterRead HookType = "after_read" @@ -43,6 +47,9 @@ type HookContext struct { Writer common.ResponseWriter Request common.Request + // Operation being dispatched (e.g. "read", "create", "update", "delete") + Operation string + // Operation-specific fields ID string Data interface{} // For create/update operations diff --git a/pkg/resolvespec/security_hooks.go b/pkg/resolvespec/security_hooks.go index c44e818..47b97c5 100644 --- a/pkg/resolvespec/security_hooks.go +++ b/pkg/resolvespec/security_hooks.go @@ -2,6 +2,7 @@ package resolvespec import ( "context" + "net/http" "github.com/bitechdev/ResolveSpec/pkg/common" "github.com/bitechdev/ResolveSpec/pkg/logger" @@ -10,6 +11,17 @@ import ( // RegisterSecurityHooks registers all security-related hooks with the handler func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { + // Hook 0: BeforeHandle - enforce auth after model resolution + handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error { + if err := security.CheckModelAuthAllowed(newSecurityContext(hookCtx), hookCtx.Operation); err != nil { + hookCtx.Abort = true + hookCtx.AbortMessage = err.Error() + hookCtx.AbortCode = http.StatusUnauthorized + return err + } + return nil + }) + // Hook 1: BeforeRead - Load security rules handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { secCtx := newSecurityContext(hookCtx) diff --git a/pkg/restheadspec/README.md b/pkg/restheadspec/README.md index 38da3e0..6c23ae7 100644 --- a/pkg/restheadspec/README.md +++ b/pkg/restheadspec/README.md @@ -147,6 +147,7 @@ handler.Hooks.Register(restheadspec.BeforeCreate, func(ctx *restheadspec.HookCon ``` **Available Hook Types**: +* `BeforeHandle` — fires after model resolution, before operation dispatch (auth checks) * `BeforeRead`, `AfterRead` * `BeforeCreate`, `AfterCreate` * `BeforeUpdate`, `AfterUpdate` @@ -157,11 +158,13 @@ handler.Hooks.Register(restheadspec.BeforeCreate, func(ctx *restheadspec.HookCon * `Handler`: Access to handler, database, and registry * `Schema`, `Entity`, `TableName`: Request info * `Model`: The registered model type +* `Operation`: Current operation string (`"read"`, `"create"`, `"update"`, `"delete"`) * `Options`: Parsed request options (filters, sorting, etc.) * `ID`: Record ID (for single-record operations) * `Data`: Request data (for create/update) * `Result`: Operation result (for after hooks) * `Writer`: Response writer (allows hooks to modify response) +* `Abort`, `AbortMessage`, `AbortCode`: Set in hook to abort with an error response ## Cursor Pagination diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index e1aac8f..cc84ce3 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -133,6 +133,41 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s // Add request-scoped data to context (including options) ctx = WithRequestData(ctx, schema, entity, tableName, model, modelPtr, options) + // Derive operation for auth check + var operation string + switch method { + case "GET": + operation = "read" + case "POST": + operation = "create" + case "PUT", "PATCH": + operation = "update" + case "DELETE": + operation = "delete" + default: + operation = "read" + } + + // Execute BeforeHandle hook - auth check fires here, after model resolution + beforeCtx := &HookContext{ + Context: ctx, + Handler: h, + Schema: schema, + Entity: entity, + Model: model, + Writer: w, + Request: r, + Operation: operation, + } + if err := h.hooks.Execute(BeforeHandle, beforeCtx); err != nil { + code := http.StatusUnauthorized + if beforeCtx.AbortCode != 0 { + code = beforeCtx.AbortCode + } + h.sendError(w, code, "unauthorized", beforeCtx.AbortMessage, err) + return + } + switch method { case "GET": if id != "" { diff --git a/pkg/restheadspec/hooks.go b/pkg/restheadspec/hooks.go index a8b4edd..b763389 100644 --- a/pkg/restheadspec/hooks.go +++ b/pkg/restheadspec/hooks.go @@ -12,6 +12,10 @@ import ( type HookType string const ( + // BeforeHandle fires after model resolution, before operation dispatch. + // Use this for auth checks that need model rules and user context simultaneously. + BeforeHandle HookType = "before_handle" + // Read operation hooks BeforeRead HookType = "before_read" AfterRead HookType = "after_read" @@ -42,6 +46,9 @@ type HookContext struct { Model interface{} Options ExtendedRequestOptions + // Operation being dispatched (e.g. "read", "create", "update", "delete") + Operation string + // Operation-specific fields ID string Data interface{} // For create/update operations @@ -56,6 +63,14 @@ type HookContext struct { // Response writer - allows hooks to modify response Writer common.ResponseWriter + // Request - the original HTTP request + Request common.Request + + // Allow hooks to abort the operation + Abort bool // If set to true, the operation will be aborted + AbortMessage string // Message to return if aborted + AbortCode int // HTTP status code if aborted + // Tx provides access to the database/transaction for executing additional SQL // This allows hooks to run custom queries in addition to the main Query chain Tx common.Database @@ -110,6 +125,12 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error { logger.Error("Hook %d for %s failed: %v", i+1, hookType, err) return fmt.Errorf("hook execution failed: %w", err) } + + // Check if hook requested abort + if ctx.Abort { + logger.Warn("Hook %d for %s requested abort: %s", i+1, hookType, ctx.AbortMessage) + return fmt.Errorf("operation aborted by hook: %s", ctx.AbortMessage) + } } // logger.Debug("All hooks for %s executed successfully", hookType) diff --git a/pkg/restheadspec/security_hooks.go b/pkg/restheadspec/security_hooks.go index d6c1864..05e2bf5 100644 --- a/pkg/restheadspec/security_hooks.go +++ b/pkg/restheadspec/security_hooks.go @@ -2,6 +2,7 @@ package restheadspec import ( "context" + "net/http" "github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/security" @@ -9,6 +10,17 @@ import ( // RegisterSecurityHooks registers all security-related hooks with the handler func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { + // Hook 0: BeforeHandle - enforce auth after model resolution + handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error { + if err := security.CheckModelAuthAllowed(newSecurityContext(hookCtx), hookCtx.Operation); err != nil { + hookCtx.Abort = true + hookCtx.AbortMessage = err.Error() + hookCtx.AbortCode = http.StatusUnauthorized + return err + } + return nil + }) + // Hook 1: BeforeRead - Load security rules handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { secCtx := newSecurityContext(hookCtx) diff --git a/pkg/security/QUICK_REFERENCE.md b/pkg/security/QUICK_REFERENCE.md index 28cb844..d229b6c 100644 --- a/pkg/security/QUICK_REFERENCE.md +++ b/pkg/security/QUICK_REFERENCE.md @@ -405,11 +405,16 @@ assert.Equal(t, "user_id = {UserID}", row.Template) ``` HTTP Request ↓ -NewAuthMiddleware → calls provider.Authenticate() - ↓ (adds UserContext to context) +NewOptionalAuthMiddleware → calls provider.Authenticate() + ↓ (adds UserContext or guest context; never 401) SetSecurityMiddleware → adds SecurityList to context ↓ -Handler.Handle() +Handler.Handle() → resolves model + ↓ +BeforeHandle Hook → CheckModelAuthAllowed(secCtx, operation) + ├─ SecurityDisabled → allow + ├─ CanPublicRead/Create/Update/Delete → allow unauthenticated + └─ UserID == 0 → abort 401 ↓ BeforeRead Hook → calls provider.GetColumnSecurity() + GetRowSecurity() ↓ @@ -693,15 +698,30 @@ http.Handle("/api/protected", authHandler) optionalHandler := security.NewOptionalAuthHandler(securityList, myHandler) http.Handle("/home", optionalHandler) -// Example handler -func myHandler(w http.ResponseWriter, r *http.Request) { - userCtx, _ := security.GetUserContext(r.Context()) - if userCtx.UserID == 0 { - // Guest user - } else { - // Authenticated user - } -} +// NewOptionalAuthMiddleware - For spec routes; auth enforcement deferred to BeforeHandle +apiRouter.Use(security.NewOptionalAuthMiddleware(securityList)) +apiRouter.Use(security.SetSecurityMiddleware(securityList)) +restheadspec.RegisterSecurityHooks(handler, securityList) // includes BeforeHandle +``` + +--- + +## Model-Level Access Control + +```go +// Register model with rules (pkg/modelregistry) +modelregistry.RegisterModelWithRules("public.products", &Product{}, modelregistry.ModelRules{ + SecurityDisabled: false, // skip all auth when true + CanPublicRead: true, // unauthenticated reads allowed + CanPublicCreate: false, // requires auth + CanPublicUpdate: false, // requires auth + CanPublicDelete: false, // requires auth + CanUpdate: true, // authenticated can update + CanDelete: false, // authenticated cannot delete (enforced in BeforeDelete) +}) + +// CheckModelAuthAllowed used automatically in BeforeHandle hook +// No code needed — call RegisterSecurityHooks and it's applied ``` --- diff --git a/pkg/security/README.md b/pkg/security/README.md index 31e3907..d39cd84 100644 --- a/pkg/security/README.md +++ b/pkg/security/README.md @@ -751,14 +751,25 @@ resolvespec.RegisterSecurityHooks(resolveHandler, securityList) ``` HTTP Request ↓ -NewAuthMiddleware (security package) +NewOptionalAuthMiddleware (security package) ← recommended for spec routes ├─ Calls provider.Authenticate(request) - └─ Adds UserContext to context + ├─ On success: adds authenticated UserContext to context + └─ On failure: adds guest UserContext (UserID=0) to context ↓ SetSecurityMiddleware (security package) └─ Adds SecurityList to context ↓ -Spec Handler (restheadspec/funcspec/resolvespec) +Spec Handler (restheadspec/funcspec/resolvespec/websocketspec/mqttspec) + └─ Resolves schema + entity + model from request + ↓ +BeforeHandle Hook (registered by spec via RegisterSecurityHooks) + ├─ Adapts spec's HookContext → SecurityContext + ├─ Calls security.CheckModelAuthAllowed(secCtx, operation) + │ ├─ Loads model rules from context or registry + │ ├─ SecurityDisabled → allow + │ ├─ CanPublicRead/Create/Update/Delete → allow unauthenticated + │ └─ UserID == 0 → 401 unauthorized + └─ On error: aborts with 401 ↓ BeforeRead Hook (registered by spec) ├─ Adapts spec's HookContext → SecurityContext @@ -784,7 +795,8 @@ HTTP Response (secured data) ``` **Key Points:** -- Security package is spec-agnostic and provides core logic +- `NewOptionalAuthMiddleware` never rejects — it sets guest context on auth failure; `BeforeHandle` enforces auth after model resolution +- `BeforeHandle` fires after model resolution, giving access to model rules and user context simultaneously - Each spec registers its own hooks that adapt to SecurityContext - Security rules are loaded once and cached for the request - Row security is applied to the query (database level) @@ -1002,15 +1014,49 @@ func (p *MyProvider) GetRowSecurity(ctx context.Context, userID int, schema, tab } ``` +## Model-Level Access Control + +Use `ModelRules` (from `pkg/modelregistry`) to control per-entity auth behavior: + +```go +modelregistry.RegisterModelWithRules("public.products", &Product{}, modelregistry.ModelRules{ + SecurityDisabled: false, // true = skip all auth checks + CanPublicRead: true, // unauthenticated GET allowed + CanPublicCreate: false, // requires auth + CanPublicUpdate: false, // requires auth + CanPublicDelete: false, // requires auth + CanUpdate: true, // authenticated users can update + CanDelete: false, // authenticated users cannot delete +}) +``` + +`CheckModelAuthAllowed(secCtx, operation)` applies these rules in `BeforeHandle`: +1. `SecurityDisabled` → allow all +2. `CanPublicRead/Create/Update/Delete` → allow unauthenticated for that operation +3. Guest (UserID == 0) → return 401 +4. Authenticated → allow (operation-specific `CanUpdate`/`CanDelete` checked in `BeforeUpdate`/`BeforeDelete`) + +--- + ## Middleware and Handler API ### NewAuthMiddleware -Standard middleware that authenticates all requests: +Standard middleware that authenticates all requests and returns 401 on failure: ```go router.Use(security.NewAuthMiddleware(securityList)) ``` +### NewOptionalAuthMiddleware +Middleware for spec routes — always continues; sets guest context on auth failure: + +```go +// Use with RegisterSecurityHooks — auth enforcement is deferred to BeforeHandle +apiRouter.Use(security.NewOptionalAuthMiddleware(securityList)) +apiRouter.Use(security.SetSecurityMiddleware(securityList)) +restheadspec.RegisterSecurityHooks(handler, securityList) // registers BeforeHandle +``` + Routes can skip authentication using the `SkipAuth` helper: ```go diff --git a/pkg/security/hooks.go b/pkg/security/hooks.go index ecf2bd5..f7ad683 100644 --- a/pkg/security/hooks.go +++ b/pkg/security/hooks.go @@ -275,6 +275,64 @@ func checkModelDeleteAllowed(secCtx SecurityContext) error { return nil } +// CheckModelAuthAllowed checks whether the requested operation is permitted based on +// model rules and the current user's authentication state. It is intended for use in +// a BeforeHandle hook, fired after model resolution. +// +// Logic: +// 1. Load model rules from context (set by NewModelAuthMiddleware) or fall back to registry. +// 2. SecurityDisabled → allow. +// 3. operation == "read" && CanPublicRead → allow. +// 4. operation == "create" && CanPublicCreate → allow. +// 5. operation == "update" && CanPublicUpdate → allow. +// 6. operation == "delete" && CanPublicDelete → allow. +// 7. Guest (UserID == 0) → return "authentication required". +// 8. Authenticated user → allow (operation-specific checks remain in BeforeUpdate/BeforeDelete). +func CheckModelAuthAllowed(secCtx SecurityContext, operation string) error { + rules, ok := GetModelRulesFromContext(secCtx.GetContext()) + if !ok { + schema := secCtx.GetSchema() + entity := secCtx.GetEntity() + var err error + if schema != "" { + rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity)) + } + if err != nil || schema == "" { + rules, err = modelregistry.GetModelRulesByName(entity) + } + if err != nil { + // Model not registered - fall through to auth check + userID, _ := secCtx.GetUserID() + if userID == 0 { + return fmt.Errorf("authentication required") + } + return nil + } + } + + if rules.SecurityDisabled { + return nil + } + if operation == "read" && rules.CanPublicRead { + return nil + } + if operation == "create" && rules.CanPublicCreate { + return nil + } + if operation == "update" && rules.CanPublicUpdate { + return nil + } + if operation == "delete" && rules.CanPublicDelete { + return nil + } + + userID, _ := secCtx.GetUserID() + if userID == 0 { + return fmt.Errorf("authentication required") + } + return nil +} + // CheckModelUpdateAllowed is the public wrapper for checkModelUpdateAllowed. func CheckModelUpdateAllowed(secCtx SecurityContext) error { return checkModelUpdateAllowed(secCtx) diff --git a/pkg/security/middleware.go b/pkg/security/middleware.go index ca61f03..18f7f67 100644 --- a/pkg/security/middleware.go +++ b/pkg/security/middleware.go @@ -139,6 +139,31 @@ func NewOptionalAuthHandler(securityList *SecurityList, next http.Handler) http. }) } +// NewOptionalAuthMiddleware creates authentication middleware that always continues. +// On auth failure, a guest user context is set instead of returning 401. +// Intended for spec routes where auth enforcement is deferred to a BeforeHandle hook +// after model resolution. +func NewOptionalAuthMiddleware(securityList *SecurityList) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + provider := securityList.Provider() + if provider == nil { + http.Error(w, "Security provider not configured", http.StatusInternalServerError) + return + } + + userCtx, err := provider.Authenticate(r) + if err != nil { + guestCtx := createGuestContext(r) + next.ServeHTTP(w, setUserContext(r, guestCtx)) + return + } + + next.ServeHTTP(w, setUserContext(r, userCtx)) + }) + } +} + // NewAuthMiddleware creates an authentication middleware with the given security list // This middleware extracts user authentication from the request and adds it to context // Routes can skip authentication by setting SkipAuthKey context value (use SkipAuth helper) diff --git a/pkg/websocketspec/README.md b/pkg/websocketspec/README.md index 9472cb2..92cb9a1 100644 --- a/pkg/websocketspec/README.md +++ b/pkg/websocketspec/README.md @@ -330,6 +330,7 @@ Hooks allow you to intercept and modify operations at various points in the life ### Available Hook Types +- **BeforeHandle** — fires after model resolution, before operation dispatch (auth checks) - **BeforeRead** / **AfterRead** - **BeforeCreate** / **AfterCreate** - **BeforeUpdate** / **AfterUpdate** @@ -337,6 +338,8 @@ Hooks allow you to intercept and modify operations at various points in the life - **BeforeSubscribe** / **AfterSubscribe** - **BeforeConnect** / **AfterConnect** +`HookContext` includes `Operation string` (`"read"`, `"create"`, `"update"`, `"delete"`) and `Abort bool`, `AbortMessage string`, `AbortCode int` for abort signaling. + ### Hook Example ```go @@ -599,7 +602,19 @@ asyncio.run(main()) ## Authentication -Implement authentication using hooks: +Use `RegisterSecurityHooks` for integrated auth with model-rule support: + +```go +import "github.com/bitechdev/ResolveSpec/pkg/security" + +provider := security.NewCompositeSecurityProvider(auth, colSec, rowSec) +securityList := security.NewSecurityList(provider) +websocketspec.RegisterSecurityHooks(handler, securityList) +// Registers BeforeHandle (model auth), BeforeRead (load rules), +// AfterRead (column security + audit), BeforeUpdate, BeforeDelete +``` + +Or implement custom authentication using hooks directly: ```go handler := websocketspec.NewHandlerWithGORM(db) diff --git a/pkg/websocketspec/handler.go b/pkg/websocketspec/handler.go index bb2e695..cbdedf3 100644 --- a/pkg/websocketspec/handler.go +++ b/pkg/websocketspec/handler.go @@ -177,6 +177,16 @@ func (h *Handler) handleRequest(conn *Connection, msg *Message) { Metadata: make(map[string]interface{}), } + // Execute BeforeHandle hook - auth check fires here, after model resolution + hookCtx.Operation = string(msg.Operation) + if err := h.hooks.Execute(BeforeHandle, hookCtx); err != nil { + if hookCtx.Abort { + errResp := NewErrorResponse(msg.ID, "unauthorized", hookCtx.AbortMessage) + _ = conn.SendJSON(errResp) + } + return + } + // Route to operation handler switch msg.Operation { case OperationRead: diff --git a/pkg/websocketspec/hooks.go b/pkg/websocketspec/hooks.go index fc5af17..cc2dbe7 100644 --- a/pkg/websocketspec/hooks.go +++ b/pkg/websocketspec/hooks.go @@ -2,6 +2,7 @@ package websocketspec import ( "context" + "fmt" "github.com/bitechdev/ResolveSpec/pkg/common" ) @@ -10,6 +11,10 @@ import ( type HookType string const ( + // BeforeHandle fires after model resolution, before operation dispatch. + // Use this for auth checks that need model rules and user context simultaneously. + BeforeHandle HookType = "before_handle" + // BeforeRead is called before a read operation BeforeRead HookType = "before_read" // AfterRead is called after a read operation @@ -83,6 +88,9 @@ type HookContext struct { // Options contains the parsed request options Options *common.RequestOptions + // Operation being dispatched (e.g. "read", "create", "update", "delete") + Operation string + // ID is the record ID for single-record operations ID string @@ -98,6 +106,11 @@ type HookContext struct { // Error is any error that occurred (for after hooks) Error error + // Allow hooks to abort the operation + Abort bool // If set to true, the operation will be aborted + AbortMessage string // Message to return if aborted + AbortCode int // HTTP status code if aborted + // Metadata is additional context data Metadata map[string]interface{} } @@ -171,6 +184,11 @@ func (hr *HookRegistry) Execute(hookType HookType, ctx *HookContext) error { if err := hook(ctx); err != nil { return err } + + // Check if hook requested abort + if ctx.Abort { + return fmt.Errorf("operation aborted by hook: %s", ctx.AbortMessage) + } } return nil diff --git a/pkg/websocketspec/security_hooks.go b/pkg/websocketspec/security_hooks.go index 4e57701..e0606c8 100644 --- a/pkg/websocketspec/security_hooks.go +++ b/pkg/websocketspec/security_hooks.go @@ -2,6 +2,7 @@ package websocketspec import ( "context" + "net/http" "github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/security" @@ -9,6 +10,17 @@ import ( // RegisterSecurityHooks registers all security-related hooks with the handler func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { + // Hook 0: BeforeHandle - enforce auth after model resolution + handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error { + if err := security.CheckModelAuthAllowed(newSecurityContext(hookCtx), hookCtx.Operation); err != nil { + hookCtx.Abort = true + hookCtx.AbortMessage = err.Error() + hookCtx.AbortCode = http.StatusUnauthorized + return err + } + return nil + }) + // Hook 1: BeforeRead - Load security rules handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { secCtx := newSecurityContext(hookCtx)