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.
This commit is contained in:
2026-02-28 22:53:21 +02:00
parent ea4a4371ba
commit e4087104a9
9 changed files with 280 additions and 8 deletions

View File

@@ -394,12 +394,12 @@ func (p *PgSQLSelectQuery) buildSQL() string {
// LIMIT clause
if p.limit > 0 {
sb.WriteString(fmt.Sprintf(" LIMIT %d", p.limit))
fmt.Fprintf(&sb, " LIMIT %d", p.limit)
}
// OFFSET clause
if p.offset > 0 {
sb.WriteString(fmt.Sprintf(" OFFSET %d", p.offset))
fmt.Fprintf(&sb, " OFFSET %d", p.offset)
}
return sb.String()

View File

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

View File

@@ -1236,6 +1236,24 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
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
if data != nil {
switch v := data.(type) {

View File

@@ -34,6 +34,18 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
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")
}

View File

@@ -1498,8 +1498,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
}
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Warn("BeforeDelete hook failed for ID %s: %v", itemID, err)
continue
logger.Error("BeforeDelete hook failed for ID %s: %v", itemID, err)
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)
@@ -1572,8 +1572,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
}
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Warn("BeforeDelete hook failed for ID %v: %v", itemID, err)
continue
logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err)
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)
@@ -1630,8 +1630,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
}
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
logger.Warn("BeforeDelete hook failed for ID %v: %v", itemID, err)
continue
logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err)
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)

View File

@@ -33,6 +33,18 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
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")
}

View File

@@ -6,6 +6,7 @@ import (
"reflect"
"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
@@ -226,6 +227,64 @@ func ApplyColumnSecurity(secCtx SecurityContext, securityList *SecurityList) err
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
}
// 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
func contains(s, substr string) bool {

View File

@@ -4,6 +4,8 @@ import (
"context"
"net/http"
"strconv"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
)
// contextKey is a custom type for context keys to avoid collisions
@@ -23,6 +25,7 @@ const (
UserMetaKey contextKey = "user_meta"
SkipAuthKey contextKey = "skip_auth"
OptionalAuthKey contextKey = "optional_auth"
ModelRulesKey contextKey = "model_rules"
)
// SkipAuth returns a context with skip auth flag set to true
@@ -182,6 +185,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
// This middleware should be applied after AuthMiddleware
func SetSecurityMiddleware(securityList *SecurityList) func(http.Handler) http.Handler {
@@ -366,6 +431,12 @@ func GetUserMeta(ctx context.Context) (map[string]any, bool) {
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
// // These functions allow using NewAuthHandler and NewOptionalAuthHandler with custom handler abstractions

View File

@@ -0,0 +1,96 @@
package websocketspec
import (
"context"
"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 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
}