Compare commits

...

7 Commits

Author SHA1 Message Date
d1ae4fe64e refactor(handler): unify filter operator handling for consistency
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -30m26s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -29m58s
Build , Vet Test, and Lint / Lint Code (push) Successful in -29m48s
Build , Vet Test, and Lint / Build (push) Successful in -30m4s
Tests / Integration Tests (push) Failing after -30m39s
Tests / Unit Tests (push) Successful in -30m29s
2026-03-01 13:21:38 +02:00
254102bfac refactor(auth): simplify handler type assertions for middleware
Some checks failed
Build , Vet Test, and Lint / Lint Code (push) Successful in -29m19s
Build , Vet Test, and Lint / Build (push) Successful in -29m42s
Tests / Integration Tests (push) Failing after -30m35s
Tests / Unit Tests (push) Successful in -30m17s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -30m2s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -29m31s
2026-03-01 12:08:36 +02:00
6c27419dbc refactor(auth): enhance request handling with middleware-enriched context 2026-03-01 12:06:43 +02:00
377336caf4 feat(sql): implement IN condition handling with parameterized queries 2026-03-01 09:52:32 +02:00
79720d5421 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.
2026-03-01 09:15:30 +02:00
e7ab0a20d6 Merge branch 'main' of github.com:bitechdev/ResolveSpec
Some checks failed
Tests / Integration Tests (push) Failing after -30m43s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -29m41s
Build , Vet Test, and Lint / Build (push) Successful in -29m50s
Tests / Unit Tests (push) Successful in -30m26s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -30m15s
Build , Vet Test, and Lint / Lint Code (push) Successful in -29m12s
2026-02-28 22:53:26 +02:00
e4087104a9 feat(security): add model rules enforcement for update and delete operations
- Implement BeforeUpdate and BeforeDelete hooks to enforce CanUpdate and CanDelete rules.
- Introduce new security context for websocketspec to manage security hooks.
- Enhance error handling in delete operations to provide clearer feedback.
2026-02-28 22:53:21 +02:00
26 changed files with 881 additions and 77 deletions

View File

@@ -2,6 +2,7 @@ package common
import ( import (
"fmt" "fmt"
"reflect"
"regexp" "regexp"
"strings" "strings"
@@ -925,3 +926,36 @@ func extractLeftSideOfComparison(cond string) string {
return "" return ""
} }
// FilterValueToSlice converts a filter value to []interface{} for use with IN operators.
// JSON-decoded arrays arrive as []interface{}, but typed slices (e.g. []string) also work.
// Returns a single-element slice if the value is not a slice type.
func FilterValueToSlice(v interface{}) []interface{} {
if v == nil {
return nil
}
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Slice {
result := make([]interface{}, rv.Len())
for i := 0; i < rv.Len(); i++ {
result[i] = rv.Index(i).Interface()
}
return result
}
return []interface{}{v}
}
// BuildInCondition builds a parameterized IN condition from a filter value.
// Returns the condition string (e.g. "col IN (?,?)") and the individual values as args.
// Returns ("", nil) if the value is empty or not a slice.
func BuildInCondition(column string, v interface{}) (query string, args []interface{}) {
values := FilterValueToSlice(v)
if len(values) == 0 {
return "", nil
}
placeholders := make([]string, len(values))
for i := range values {
placeholders[i] = "?"
}
return fmt.Sprintf("%s IN (%s)", column, strings.Join(placeholders, ",")), values
}

View File

@@ -2,14 +2,38 @@ package funcspec
import ( import (
"context" "context"
"fmt"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/security" "github.com/bitechdev/ResolveSpec/pkg/security"
) )
// RegisterSecurityHooks registers security hooks for funcspec handlers // RegisterSecurityHooks registers security hooks for funcspec handlers
// Note: funcspec operates on SQL queries directly, so row-level security is not directly applicable // 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) { 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 // Hook 1: BeforeQueryList - Audit logging before query list execution
handler.Hooks().Register(BeforeQueryList, func(hookCtx *HookContext) error { handler.Hooks().Register(BeforeQueryList, func(hookCtx *HookContext) error {
secCtx := newFuncSpecSecurityContext(hookCtx) secCtx := newFuncSpecSecurityContext(hookCtx)

View File

@@ -8,6 +8,10 @@ import (
// ModelRules defines the permissions and security settings for a model // ModelRules defines the permissions and security settings for a model
type ModelRules struct { 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) CanRead bool // Whether the model can be read (GET operations)
CanUpdate bool // Whether the model can be updated (PUT/PATCH operations) CanUpdate bool // Whether the model can be updated (PUT/PATCH operations)
CanCreate bool // Whether the model can be created (POST operations) CanCreate bool // Whether the model can be created (POST operations)
@@ -22,6 +26,10 @@ func DefaultModelRules() ModelRules {
CanUpdate: true, CanUpdate: true,
CanCreate: true, CanCreate: true,
CanDelete: true, CanDelete: true,
CanPublicRead: false,
CanPublicUpdate: false,
CanPublicCreate: false,
CanPublicDelete: false,
SecurityDisabled: false, SecurityDisabled: false,
} }
} }

View File

@@ -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 - **Full CRUD Operations**: Create, Read, Update, Delete with hooks
- **Real-time Subscriptions**: Subscribe to entity changes with filtering - **Real-time Subscriptions**: Subscribe to entity changes with filtering
- **Database Agnostic**: GORM and Bun ORM support - **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 - **Multi-tenancy Support**: Built-in tenant isolation via hooks
- **Thread-safe**: Proper concurrency handling throughout - **Thread-safe**: Proper concurrency handling throughout
@@ -326,10 +326,11 @@ When any client creates/updates/deletes a user matching the subscription filters
## Lifecycle Hooks ## Lifecycle Hooks
MQTTSpec provides 12 lifecycle hooks for implementing cross-cutting concerns: MQTTSpec provides 13 lifecycle hooks for implementing cross-cutting concerns:
### Hook Types ### Hook Types
- `BeforeHandle` — fires after model resolution, before operation dispatch (auth checks)
- `BeforeConnect` / `AfterConnect` - Connection lifecycle - `BeforeConnect` / `AfterConnect` - Connection lifecycle
- `BeforeDisconnect` / `AfterDisconnect` - Disconnection lifecycle - `BeforeDisconnect` / `AfterDisconnect` - Disconnection lifecycle
- `BeforeRead` / `AfterRead` - Read operations - `BeforeRead` / `AfterRead` - Read operations
@@ -339,6 +340,20 @@ MQTTSpec provides 12 lifecycle hooks for implementing cross-cutting concerns:
- `BeforeSubscribe` / `AfterSubscribe` - Subscription creation - `BeforeSubscribe` / `AfterSubscribe` - Subscription creation
- `BeforeUnsubscribe` / `AfterUnsubscribe` - Subscription removal - `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) ### Authentication Example (JWT)
```go ```go
@@ -657,7 +672,7 @@ handler, err := mqttspec.NewHandlerWithGORM(db,
| **Network Efficiency** | Better for unreliable networks | Better for low-latency | | **Network Efficiency** | Better for unreliable networks | Better for low-latency |
| **Best For** | IoT, mobile apps, distributed systems | Web applications, real-time dashboards | | **Best For** | IoT, mobile apps, distributed systems | Web applications, real-time dashboards |
| **Message Protocol** | Same JSON structure | Same JSON structure | | **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 | | **CRUD Operations** | Identical | Identical |
| **Subscriptions** | Identical (via MQTT topics) | Identical (via app-level) | | **Subscriptions** | Identical (via MQTT topics) | Identical (via app-level) |

View File

@@ -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 // Route to operation handler
switch msg.Operation { switch msg.Operation {
case OperationRead: case OperationRead:

View File

@@ -20,8 +20,11 @@ type (
HookRegistry = websocketspec.HookRegistry HookRegistry = websocketspec.HookRegistry
) )
// Hook type constants - all 12 lifecycle hooks // Hook type constants - all lifecycle hooks
const ( const (
// BeforeHandle fires after model resolution, before operation dispatch
BeforeHandle = websocketspec.BeforeHandle
// CRUD operation hooks // CRUD operation hooks
BeforeRead = websocketspec.BeforeRead BeforeRead = websocketspec.BeforeRead
AfterRead = websocketspec.AfterRead AfterRead = websocketspec.AfterRead

View File

@@ -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
}

View File

@@ -644,6 +644,7 @@ handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookCon
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Tags []Tag `json:"tags,omitempty" gorm:"many2many:post_tags"` Tags []Tag `json:"tags,omitempty" gorm:"many2many:post_tags"`
} }
// Schema.Table format // Schema.Table format
handler.registry.RegisterModel("core.users", &User{}) handler.registry.RegisterModel("core.users", &User{})
handler.registry.RegisterModel("core.posts", &Post{}) handler.registry.RegisterModel("core.posts", &Post{})
@@ -654,11 +655,13 @@ handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookCon
```go ```go
package main package main
import (
"log" "log"
"net/http" "net/http"
"github.com/bitechdev/ResolveSpec/pkg/resolvespec" "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -44,8 +44,8 @@ func TestBuildFilterCondition(t *testing.T) {
Operator: "in", Operator: "in",
Value: []string{"active", "pending"}, Value: []string{"active", "pending"},
}, },
expectedCondition: "status IN (?)", expectedCondition: "status IN (?,?)",
expectedArgsCount: 1, expectedArgsCount: 2,
}, },
{ {
name: "LIKE operator", name: "LIKE operator",

View File

@@ -138,6 +138,26 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
validator := common.NewColumnValidator(model) validator := common.NewColumnValidator(model)
req.Options = validator.FilterRequestOptions(req.Options) 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 { switch req.Operation {
case "read": case "read":
h.handleRead(ctx, w, id, req.Options) h.handleRead(ctx, w, id, req.Options)
@@ -1236,6 +1256,24 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
logger.Info("Deleting records from %s.%s", schema, entity) logger.Info("Deleting records from %s.%s", schema, entity)
// Execute BeforeDelete hooks (covers model-rule checks before any deletion)
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
ID: id,
Data: data,
Writer: w,
Tx: h.db,
}
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err)
h.sendError(w, http.StatusForbidden, "delete_forbidden", "Delete operation not allowed", err)
return
}
// Handle batch delete from request data // Handle batch delete from request data
if data != nil { if data != nil {
switch v := data.(type) { switch v := data.(type) {
@@ -1483,22 +1521,22 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr
var args []interface{} var args []interface{}
switch filter.Operator { switch filter.Operator {
case "eq": case "eq", "=":
condition = fmt.Sprintf("%s = ?", filter.Column) condition = fmt.Sprintf("%s = ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "neq": case "neq", "!=", "<>":
condition = fmt.Sprintf("%s != ?", filter.Column) condition = fmt.Sprintf("%s != ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "gt": case "gt", ">":
condition = fmt.Sprintf("%s > ?", filter.Column) condition = fmt.Sprintf("%s > ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "gte": case "gte", ">=":
condition = fmt.Sprintf("%s >= ?", filter.Column) condition = fmt.Sprintf("%s >= ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "lt": case "lt", "<":
condition = fmt.Sprintf("%s < ?", filter.Column) condition = fmt.Sprintf("%s < ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "lte": case "lte", "<=":
condition = fmt.Sprintf("%s <= ?", filter.Column) condition = fmt.Sprintf("%s <= ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "like": case "like":
@@ -1508,8 +1546,10 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr
condition = fmt.Sprintf("%s ILIKE ?", filter.Column) condition = fmt.Sprintf("%s ILIKE ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "in": case "in":
condition = fmt.Sprintf("%s IN (?)", filter.Column) condition, args = common.BuildInCondition(filter.Column, filter.Value)
args = []interface{}{filter.Value} if condition == "" {
return "", nil
}
default: default:
return "", nil return "", nil
} }
@@ -1525,22 +1565,22 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
var args []interface{} var args []interface{}
switch filter.Operator { switch filter.Operator {
case "eq": case "eq", "=":
condition = fmt.Sprintf("%s = ?", filter.Column) condition = fmt.Sprintf("%s = ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "neq": case "neq", "!=", "<>":
condition = fmt.Sprintf("%s != ?", filter.Column) condition = fmt.Sprintf("%s != ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "gt": case "gt", ">":
condition = fmt.Sprintf("%s > ?", filter.Column) condition = fmt.Sprintf("%s > ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "gte": case "gte", ">=":
condition = fmt.Sprintf("%s >= ?", filter.Column) condition = fmt.Sprintf("%s >= ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "lt": case "lt", "<":
condition = fmt.Sprintf("%s < ?", filter.Column) condition = fmt.Sprintf("%s < ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "lte": case "lte", "<=":
condition = fmt.Sprintf("%s <= ?", filter.Column) condition = fmt.Sprintf("%s <= ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "like": case "like":
@@ -1550,8 +1590,10 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
condition = fmt.Sprintf("%s ILIKE ?", filter.Column) condition = fmt.Sprintf("%s ILIKE ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "in": case "in":
condition = fmt.Sprintf("%s IN (?)", filter.Column) condition, args = common.BuildInCondition(filter.Column, filter.Value)
args = []interface{}{filter.Value} if condition == "" {
return query
}
default: default:
return query return query
} }

View File

@@ -12,6 +12,10 @@ import (
type HookType string type HookType string
const ( 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 // Read operation hooks
BeforeRead HookType = "before_read" BeforeRead HookType = "before_read"
AfterRead HookType = "after_read" AfterRead HookType = "after_read"
@@ -43,6 +47,9 @@ type HookContext struct {
Writer common.ResponseWriter Writer common.ResponseWriter
Request common.Request Request common.Request
// Operation being dispatched (e.g. "read", "create", "update", "delete")
Operation string
// Operation-specific fields // Operation-specific fields
ID string ID string
Data interface{} // For create/update operations Data interface{} // For create/update operations

View File

@@ -70,17 +70,17 @@ func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware Midd
entityWithIDPath := buildRoutePath(schema, entity) + "/{id}" entityWithIDPath := buildRoutePath(schema, entity) + "/{id}"
// Create handler functions for this specific entity // Create handler functions for this specific entity
postEntityHandler := createMuxHandler(handler, schema, entity, "") var postEntityHandler http.Handler = createMuxHandler(handler, schema, entity, "")
postEntityWithIDHandler := createMuxHandler(handler, schema, entity, "id") var postEntityWithIDHandler http.Handler = createMuxHandler(handler, schema, entity, "id")
getEntityHandler := createMuxGetHandler(handler, schema, entity, "") var getEntityHandler http.Handler = createMuxGetHandler(handler, schema, entity, "")
optionsEntityHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "POST", "OPTIONS"}) optionsEntityHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "POST", "OPTIONS"})
optionsEntityWithIDHandler := createMuxOptionsHandler(handler, schema, entity, []string{"POST", "OPTIONS"}) optionsEntityWithIDHandler := createMuxOptionsHandler(handler, schema, entity, []string{"POST", "OPTIONS"})
// Apply authentication middleware if provided // Apply authentication middleware if provided
if authMiddleware != nil { if authMiddleware != nil {
postEntityHandler = authMiddleware(postEntityHandler).(http.HandlerFunc) postEntityHandler = authMiddleware(postEntityHandler)
postEntityWithIDHandler = authMiddleware(postEntityWithIDHandler).(http.HandlerFunc) postEntityWithIDHandler = authMiddleware(postEntityWithIDHandler)
getEntityHandler = authMiddleware(getEntityHandler).(http.HandlerFunc) getEntityHandler = authMiddleware(getEntityHandler)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth // Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
} }
@@ -225,7 +225,11 @@ func wrapBunRouterHandler(handler bunrouter.HandlerFunc, authMiddleware Middlewa
return func(w http.ResponseWriter, req bunrouter.Request) error { return func(w http.ResponseWriter, req bunrouter.Request) error {
// Create an http.Handler that calls the bunrouter handler // Create an http.Handler that calls the bunrouter handler
httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = handler(w, req) // Replace the embedded *http.Request with the middleware-enriched one
// so that auth context (user ID, etc.) is visible to the handler.
enrichedReq := req
enrichedReq.Request = r
_ = handler(w, enrichedReq)
}) })
// Wrap with auth middleware and execute // Wrap with auth middleware and execute

View File

@@ -2,6 +2,7 @@ package resolvespec
import ( import (
"context" "context"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/common" "github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/logger"
@@ -10,6 +11,17 @@ import (
// RegisterSecurityHooks registers all security-related hooks with the handler // RegisterSecurityHooks registers all security-related hooks with the handler
func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { 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 // Hook 1: BeforeRead - Load security rules
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
@@ -34,6 +46,18 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
return security.LogDataAccess(secCtx) return security.LogDataAccess(secCtx)
}) })
// 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 6: 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 resolvespec handler") logger.Info("Security hooks registered for resolvespec handler")
} }

View File

@@ -147,6 +147,7 @@ handler.Hooks.Register(restheadspec.BeforeCreate, func(ctx *restheadspec.HookCon
``` ```
**Available Hook Types**: **Available Hook Types**:
* `BeforeHandle` — fires after model resolution, before operation dispatch (auth checks)
* `BeforeRead`, `AfterRead` * `BeforeRead`, `AfterRead`
* `BeforeCreate`, `AfterCreate` * `BeforeCreate`, `AfterCreate`
* `BeforeUpdate`, `AfterUpdate` * `BeforeUpdate`, `AfterUpdate`
@@ -157,11 +158,13 @@ handler.Hooks.Register(restheadspec.BeforeCreate, func(ctx *restheadspec.HookCon
* `Handler`: Access to handler, database, and registry * `Handler`: Access to handler, database, and registry
* `Schema`, `Entity`, `TableName`: Request info * `Schema`, `Entity`, `TableName`: Request info
* `Model`: The registered model type * `Model`: The registered model type
* `Operation`: Current operation string (`"read"`, `"create"`, `"update"`, `"delete"`)
* `Options`: Parsed request options (filters, sorting, etc.) * `Options`: Parsed request options (filters, sorting, etc.)
* `ID`: Record ID (for single-record operations) * `ID`: Record ID (for single-record operations)
* `Data`: Request data (for create/update) * `Data`: Request data (for create/update)
* `Result`: Operation result (for after hooks) * `Result`: Operation result (for after hooks)
* `Writer`: Response writer (allows hooks to modify response) * `Writer`: Response writer (allows hooks to modify response)
* `Abort`, `AbortMessage`, `AbortCode`: Set in hook to abort with an error response
## Cursor Pagination ## Cursor Pagination

View File

@@ -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) // Add request-scoped data to context (including options)
ctx = WithRequestData(ctx, schema, entity, tableName, model, modelPtr, 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 { switch method {
case "GET": case "GET":
if id != "" { if id != "" {
@@ -1498,8 +1533,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Warn("BeforeDelete hook failed for ID %s: %v", itemID, err) logger.Error("BeforeDelete hook failed for ID %s: %v", itemID, err)
continue return fmt.Errorf("delete not allowed for ID %s: %w", itemID, err)
} }
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID) query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
@@ -1572,8 +1607,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Warn("BeforeDelete hook failed for ID %v: %v", itemID, err) logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err)
continue return fmt.Errorf("delete not allowed for ID %v: %w", itemID, err)
} }
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID) query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
@@ -1630,8 +1665,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Warn("BeforeDelete hook failed for ID %v: %v", itemID, err) logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err)
continue return fmt.Errorf("delete not allowed for ID %v: %w", itemID, err)
} }
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID) query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
@@ -2111,7 +2146,11 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
// Column is already cast to TEXT if needed // Column is already cast to TEXT if needed
return applyWhere(fmt.Sprintf("%s ILIKE ?", qualifiedColumn), filter.Value) return applyWhere(fmt.Sprintf("%s ILIKE ?", qualifiedColumn), filter.Value)
case "in": case "in":
return applyWhere(fmt.Sprintf("%s IN (?)", qualifiedColumn), filter.Value) cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
if cond == "" {
return query
}
return applyWhere(cond, inArgs...)
case "between": case "between":
// Handle between operator - exclusive (> val1 AND < val2) // Handle between operator - exclusive (> val1 AND < val2)
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 { if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
@@ -2187,24 +2226,25 @@ func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common
// buildFilterCondition builds a single filter condition and returns the condition string and args // buildFilterCondition builds a single filter condition and returns the condition string and args
func (h *Handler) buildFilterCondition(qualifiedColumn string, filter *common.FilterOption, tableName string) (filterStr string, filterInterface []interface{}) { func (h *Handler) buildFilterCondition(qualifiedColumn string, filter *common.FilterOption, tableName string) (filterStr string, filterInterface []interface{}) {
switch strings.ToLower(filter.Operator) { switch strings.ToLower(filter.Operator) {
case "eq", "equals": case "eq", "equals", "=":
return fmt.Sprintf("%s = ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s = ?", qualifiedColumn), []interface{}{filter.Value}
case "neq", "not_equals", "ne": case "neq", "not_equals", "ne", "!=", "<>":
return fmt.Sprintf("%s != ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s != ?", qualifiedColumn), []interface{}{filter.Value}
case "gt", "greater_than": case "gt", "greater_than", ">":
return fmt.Sprintf("%s > ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s > ?", qualifiedColumn), []interface{}{filter.Value}
case "gte", "greater_than_equals", "ge": case "gte", "greater_than_equals", "ge", ">=":
return fmt.Sprintf("%s >= ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s >= ?", qualifiedColumn), []interface{}{filter.Value}
case "lt", "less_than": case "lt", "less_than", "<":
return fmt.Sprintf("%s < ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s < ?", qualifiedColumn), []interface{}{filter.Value}
case "lte", "less_than_equals", "le": case "lte", "less_than_equals", "le", "<=":
return fmt.Sprintf("%s <= ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s <= ?", qualifiedColumn), []interface{}{filter.Value}
case "like": case "like":
return fmt.Sprintf("%s LIKE ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s LIKE ?", qualifiedColumn), []interface{}{filter.Value}
case "ilike": case "ilike":
return fmt.Sprintf("%s ILIKE ?", qualifiedColumn), []interface{}{filter.Value} return fmt.Sprintf("%s ILIKE ?", qualifiedColumn), []interface{}{filter.Value}
case "in": case "in":
return fmt.Sprintf("%s IN (?)", qualifiedColumn), []interface{}{filter.Value} cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
return cond, inArgs
case "between": case "between":
// Handle between operator - exclusive (> val1 AND < val2) // Handle between operator - exclusive (> val1 AND < val2)
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 { if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {

View File

@@ -12,6 +12,10 @@ import (
type HookType string type HookType string
const ( 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 // Read operation hooks
BeforeRead HookType = "before_read" BeforeRead HookType = "before_read"
AfterRead HookType = "after_read" AfterRead HookType = "after_read"
@@ -42,6 +46,9 @@ type HookContext struct {
Model interface{} Model interface{}
Options ExtendedRequestOptions Options ExtendedRequestOptions
// Operation being dispatched (e.g. "read", "create", "update", "delete")
Operation string
// Operation-specific fields // Operation-specific fields
ID string ID string
Data interface{} // For create/update operations Data interface{} // For create/update operations
@@ -56,6 +63,14 @@ type HookContext struct {
// Response writer - allows hooks to modify response // Response writer - allows hooks to modify response
Writer common.ResponseWriter 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 // 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 // This allows hooks to run custom queries in addition to the main Query chain
Tx common.Database 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) logger.Error("Hook %d for %s failed: %v", i+1, hookType, err)
return fmt.Errorf("hook execution failed: %w", 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) // logger.Debug("All hooks for %s executed successfully", hookType)

View File

@@ -125,17 +125,17 @@ func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware Midd
metadataPath := buildRoutePath(schema, entity) + "/metadata" metadataPath := buildRoutePath(schema, entity) + "/metadata"
// Create handler functions for this specific entity // Create handler functions for this specific entity
entityHandler := createMuxHandler(handler, schema, entity, "") var entityHandler http.Handler = createMuxHandler(handler, schema, entity, "")
entityWithIDHandler := createMuxHandler(handler, schema, entity, "id") var entityWithIDHandler http.Handler = createMuxHandler(handler, schema, entity, "id")
metadataHandler := createMuxGetHandler(handler, schema, entity, "") var metadataHandler http.Handler = createMuxGetHandler(handler, schema, entity, "")
optionsEntityHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "POST", "OPTIONS"}) optionsEntityHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "POST", "OPTIONS"})
optionsEntityWithIDHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "PUT", "PATCH", "DELETE", "POST", "OPTIONS"}) optionsEntityWithIDHandler := createMuxOptionsHandler(handler, schema, entity, []string{"GET", "PUT", "PATCH", "DELETE", "POST", "OPTIONS"})
// Apply authentication middleware if provided // Apply authentication middleware if provided
if authMiddleware != nil { if authMiddleware != nil {
entityHandler = authMiddleware(entityHandler).(http.HandlerFunc) entityHandler = authMiddleware(entityHandler)
entityWithIDHandler = authMiddleware(entityWithIDHandler).(http.HandlerFunc) entityWithIDHandler = authMiddleware(entityWithIDHandler)
metadataHandler = authMiddleware(metadataHandler).(http.HandlerFunc) metadataHandler = authMiddleware(metadataHandler)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth // Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
} }
@@ -289,7 +289,11 @@ func wrapBunRouterHandler(handler bunrouter.HandlerFunc, authMiddleware Middlewa
return func(w http.ResponseWriter, req bunrouter.Request) error { return func(w http.ResponseWriter, req bunrouter.Request) error {
// Create an http.Handler that calls the bunrouter handler // Create an http.Handler that calls the bunrouter handler
httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = handler(w, req) // Replace the embedded *http.Request with the middleware-enriched one
// so that auth context (user ID, etc.) is visible to the handler.
enrichedReq := req
enrichedReq.Request = r
_ = handler(w, enrichedReq)
}) })
// Wrap with auth middleware and execute // Wrap with auth middleware and execute

View File

@@ -2,6 +2,7 @@ package restheadspec
import ( import (
"context" "context"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/security" "github.com/bitechdev/ResolveSpec/pkg/security"
@@ -9,6 +10,17 @@ import (
// RegisterSecurityHooks registers all security-related hooks with the handler // RegisterSecurityHooks registers all security-related hooks with the handler
func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { 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 // Hook 1: BeforeRead - Load security rules
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx) secCtx := newSecurityContext(hookCtx)
@@ -33,6 +45,18 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
return security.LogDataAccess(secCtx) return security.LogDataAccess(secCtx)
}) })
// 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 6: 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 restheadspec handler") logger.Info("Security hooks registered for restheadspec handler")
} }

View File

@@ -405,11 +405,16 @@ assert.Equal(t, "user_id = {UserID}", row.Template)
``` ```
HTTP Request HTTP Request
NewAuthMiddleware → calls provider.Authenticate() NewOptionalAuthMiddleware → calls provider.Authenticate()
↓ (adds UserContext to context) ↓ (adds UserContext or guest context; never 401)
SetSecurityMiddleware → adds SecurityList to context 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() BeforeRead Hook → calls provider.GetColumnSecurity() + GetRowSecurity()
@@ -693,15 +698,30 @@ http.Handle("/api/protected", authHandler)
optionalHandler := security.NewOptionalAuthHandler(securityList, myHandler) optionalHandler := security.NewOptionalAuthHandler(securityList, myHandler)
http.Handle("/home", optionalHandler) http.Handle("/home", optionalHandler)
// Example handler // NewOptionalAuthMiddleware - For spec routes; auth enforcement deferred to BeforeHandle
func myHandler(w http.ResponseWriter, r *http.Request) { apiRouter.Use(security.NewOptionalAuthMiddleware(securityList))
userCtx, _ := security.GetUserContext(r.Context()) apiRouter.Use(security.SetSecurityMiddleware(securityList))
if userCtx.UserID == 0 { restheadspec.RegisterSecurityHooks(handler, securityList) // includes BeforeHandle
// Guest user ```
} else {
// Authenticated user ---
}
} ## 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
``` ```
--- ---

View File

@@ -751,14 +751,25 @@ resolvespec.RegisterSecurityHooks(resolveHandler, securityList)
``` ```
HTTP Request HTTP Request
NewAuthMiddleware (security package) NewOptionalAuthMiddleware (security package) ← recommended for spec routes
├─ Calls provider.Authenticate(request) ├─ 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) SetSecurityMiddleware (security package)
└─ Adds SecurityList to context └─ 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) BeforeRead Hook (registered by spec)
├─ Adapts spec's HookContext → SecurityContext ├─ Adapts spec's HookContext → SecurityContext
@@ -784,7 +795,8 @@ HTTP Response (secured data)
``` ```
**Key Points:** **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 - Each spec registers its own hooks that adapt to SecurityContext
- Security rules are loaded once and cached for the request - Security rules are loaded once and cached for the request
- Row security is applied to the query (database level) - 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 ## Middleware and Handler API
### NewAuthMiddleware ### NewAuthMiddleware
Standard middleware that authenticates all requests: Standard middleware that authenticates all requests and returns 401 on failure:
```go ```go
router.Use(security.NewAuthMiddleware(securityList)) 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: Routes can skip authentication using the `SkipAuth` helper:
```go ```go

View File

@@ -6,6 +6,7 @@ import (
"reflect" "reflect"
"github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
) )
// SecurityContext is a generic interface that any spec can implement to integrate with security features // SecurityContext is a generic interface that any spec can implement to integrate with security features
@@ -226,6 +227,122 @@ func ApplyColumnSecurity(secCtx SecurityContext, securityList *SecurityList) err
return applyColumnSecurity(secCtx, securityList) return applyColumnSecurity(secCtx, securityList)
} }
// checkModelUpdateAllowed returns an error if CanUpdate is false for the model.
// Rules are read from context (set by NewModelAuthMiddleware) with a fallback to the model registry.
func checkModelUpdateAllowed(secCtx SecurityContext) 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 {
return nil // model not registered, allow by default
}
}
if !rules.CanUpdate {
return fmt.Errorf("update not allowed for %s", secCtx.GetEntity())
}
return nil
}
// checkModelDeleteAllowed returns an error if CanDelete is false for the model.
// Rules are read from context (set by NewModelAuthMiddleware) with a fallback to the model registry.
func checkModelDeleteAllowed(secCtx SecurityContext) 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 {
return nil // model not registered, allow by default
}
}
if !rules.CanDelete {
return fmt.Errorf("delete not allowed for %s", secCtx.GetEntity())
}
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)
}
// CheckModelDeleteAllowed is the public wrapper for checkModelDeleteAllowed.
func CheckModelDeleteAllowed(secCtx SecurityContext) error {
return checkModelDeleteAllowed(secCtx)
}
// Helper functions // Helper functions
func contains(s, substr string) bool { func contains(s, substr string) bool {

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"net/http" "net/http"
"strconv" "strconv"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
) )
// contextKey is a custom type for context keys to avoid collisions // contextKey is a custom type for context keys to avoid collisions
@@ -23,6 +25,7 @@ const (
UserMetaKey contextKey = "user_meta" UserMetaKey contextKey = "user_meta"
SkipAuthKey contextKey = "skip_auth" SkipAuthKey contextKey = "skip_auth"
OptionalAuthKey contextKey = "optional_auth" OptionalAuthKey contextKey = "optional_auth"
ModelRulesKey contextKey = "model_rules"
) )
// SkipAuth returns a context with skip auth flag set to true // SkipAuth returns a context with skip auth flag set to true
@@ -136,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 // NewAuthMiddleware creates an authentication middleware with the given security list
// This middleware extracts user authentication from the request and adds it to context // 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) // Routes can skip authentication by setting SkipAuthKey context value (use SkipAuth helper)
@@ -182,6 +210,68 @@ func NewAuthMiddleware(securityList *SecurityList) func(http.Handler) http.Handl
} }
} }
// NewModelAuthMiddleware creates authentication middleware that respects ModelRules for the given model name.
// It first checks if ModelRules are set for the model:
// - If SecurityDisabled is true, authentication is skipped and a guest context is set.
// - Otherwise, all checks from NewAuthMiddleware apply (SkipAuthKey, provider check, OptionalAuthKey, Authenticate).
//
// If the model is not found in any registry, the middleware falls back to standard NewAuthMiddleware behaviour.
func NewModelAuthMiddleware(securityList *SecurityList, modelName string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check ModelRules first
if rules, err := modelregistry.GetModelRulesByName(modelName); err == nil {
// Store rules in context for downstream use (e.g., security hooks)
r = r.WithContext(context.WithValue(r.Context(), ModelRulesKey, rules))
if rules.SecurityDisabled {
guestCtx := createGuestContext(r)
next.ServeHTTP(w, setUserContext(r, guestCtx))
return
}
isRead := r.Method == http.MethodGet || r.Method == http.MethodHead
isUpdate := r.Method == http.MethodPut || r.Method == http.MethodPatch
if (isRead && rules.CanPublicRead) || (isUpdate && rules.CanPublicUpdate) {
guestCtx := createGuestContext(r)
next.ServeHTTP(w, setUserContext(r, guestCtx))
return
}
}
// Check if this route should skip authentication
if skip, ok := r.Context().Value(SkipAuthKey).(bool); ok && skip {
guestCtx := createGuestContext(r)
next.ServeHTTP(w, setUserContext(r, guestCtx))
return
}
// Get the security provider
provider := securityList.Provider()
if provider == nil {
http.Error(w, "Security provider not configured", http.StatusInternalServerError)
return
}
// Check if this route has optional authentication
optional, _ := r.Context().Value(OptionalAuthKey).(bool)
// Try to authenticate
userCtx, err := provider.Authenticate(r)
if err != nil {
if optional {
guestCtx := createGuestContext(r)
next.ServeHTTP(w, setUserContext(r, guestCtx))
return
}
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
return
}
next.ServeHTTP(w, setUserContext(r, userCtx))
})
}
}
// SetSecurityMiddleware adds security context to requests // SetSecurityMiddleware adds security context to requests
// This middleware should be applied after AuthMiddleware // This middleware should be applied after AuthMiddleware
func SetSecurityMiddleware(securityList *SecurityList) func(http.Handler) http.Handler { func SetSecurityMiddleware(securityList *SecurityList) func(http.Handler) http.Handler {
@@ -366,6 +456,12 @@ func GetUserMeta(ctx context.Context) (map[string]any, bool) {
return meta, ok return meta, ok
} }
// GetModelRulesFromContext extracts ModelRules stored by NewModelAuthMiddleware
func GetModelRulesFromContext(ctx context.Context) (modelregistry.ModelRules, bool) {
rules, ok := ctx.Value(ModelRulesKey).(modelregistry.ModelRules)
return rules, ok
}
// // Handler adapters for resolvespec/restheadspec compatibility // // Handler adapters for resolvespec/restheadspec compatibility
// // These functions allow using NewAuthHandler and NewOptionalAuthHandler with custom handler abstractions // // These functions allow using NewAuthHandler and NewOptionalAuthHandler with custom handler abstractions

View File

@@ -330,6 +330,7 @@ Hooks allow you to intercept and modify operations at various points in the life
### Available Hook Types ### Available Hook Types
- **BeforeHandle** — fires after model resolution, before operation dispatch (auth checks)
- **BeforeRead** / **AfterRead** - **BeforeRead** / **AfterRead**
- **BeforeCreate** / **AfterCreate** - **BeforeCreate** / **AfterCreate**
- **BeforeUpdate** / **AfterUpdate** - **BeforeUpdate** / **AfterUpdate**
@@ -337,6 +338,8 @@ Hooks allow you to intercept and modify operations at various points in the life
- **BeforeSubscribe** / **AfterSubscribe** - **BeforeSubscribe** / **AfterSubscribe**
- **BeforeConnect** / **AfterConnect** - **BeforeConnect** / **AfterConnect**
`HookContext` includes `Operation string` (`"read"`, `"create"`, `"update"`, `"delete"`) and `Abort bool`, `AbortMessage string`, `AbortCode int` for abort signaling.
### Hook Example ### Hook Example
```go ```go
@@ -599,7 +602,19 @@ asyncio.run(main())
## Authentication ## 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 ```go
handler := websocketspec.NewHandlerWithGORM(db) handler := websocketspec.NewHandlerWithGORM(db)

View File

@@ -177,6 +177,16 @@ func (h *Handler) handleRequest(conn *Connection, msg *Message) {
Metadata: make(map[string]interface{}), 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 // Route to operation handler
switch msg.Operation { switch msg.Operation {
case OperationRead: case OperationRead:
@@ -618,7 +628,10 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName) countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
if hookCtx.Options != nil { if hookCtx.Options != nil {
for _, filter := range hookCtx.Options.Filters { for _, filter := range hookCtx.Options.Filters {
countQuery = countQuery.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) cond, args := h.buildFilterCondition(filter)
if cond != "" {
countQuery = countQuery.Where(cond, args...)
}
} }
} }
count, _ := countQuery.Count(hookCtx.Context) count, _ := countQuery.Count(hookCtx.Context)
@@ -790,14 +803,12 @@ func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.Fi
// buildFilterCondition builds a filter condition and returns it with args // buildFilterCondition builds a filter condition and returns it with args
func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) { func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) {
var condition string if strings.EqualFold(filter.Operator, "in") {
var args []interface{} cond, args := common.BuildInCondition(filter.Column, filter.Value)
return cond, args
}
operatorSQL := h.getOperatorSQL(filter.Operator) operatorSQL := h.getOperatorSQL(filter.Operator)
condition = fmt.Sprintf("%s %s ?", filter.Column, operatorSQL) return fmt.Sprintf("%s %s ?", filter.Column, operatorSQL), []interface{}{filter.Value}
args = []interface{}{filter.Value}
return condition, args
} }
// setRowNumbersOnRecords sets the RowNumber field on each record if it exists // setRowNumbersOnRecords sets the RowNumber field on each record if it exists

View File

@@ -2,6 +2,7 @@ package websocketspec
import ( import (
"context" "context"
"fmt"
"github.com/bitechdev/ResolveSpec/pkg/common" "github.com/bitechdev/ResolveSpec/pkg/common"
) )
@@ -10,6 +11,10 @@ import (
type HookType string type HookType string
const ( 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 is called before a read operation
BeforeRead HookType = "before_read" BeforeRead HookType = "before_read"
// AfterRead is called after a read operation // AfterRead is called after a read operation
@@ -83,6 +88,9 @@ type HookContext struct {
// Options contains the parsed request options // Options contains the parsed request options
Options *common.RequestOptions Options *common.RequestOptions
// Operation being dispatched (e.g. "read", "create", "update", "delete")
Operation string
// ID is the record ID for single-record operations // ID is the record ID for single-record operations
ID string ID string
@@ -98,6 +106,11 @@ type HookContext struct {
// Error is any error that occurred (for after hooks) // Error is any error that occurred (for after hooks)
Error error 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 is additional context data
Metadata map[string]interface{} Metadata map[string]interface{}
} }
@@ -171,6 +184,11 @@ func (hr *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
if err := hook(ctx); err != nil { if err := hook(ctx); err != nil {
return err return err
} }
// Check if hook requested abort
if ctx.Abort {
return fmt.Errorf("operation aborted by hook: %s", ctx.AbortMessage)
}
} }
return nil return nil

View File

@@ -0,0 +1,108 @@
package websocketspec
import (
"context"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
// 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)
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 websocketspec handler")
}
// securityContext adapts websocketspec.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 (websocketspec has no Query field)
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
}