diff --git a/pkg/common/adapters/database/pgsql.go b/pkg/common/adapters/database/pgsql.go index 18bccfc..87f3631 100644 --- a/pkg/common/adapters/database/pgsql.go +++ b/pkg/common/adapters/database/pgsql.go @@ -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() diff --git a/pkg/modelregistry/model_registry.go b/pkg/modelregistry/model_registry.go index db0611b..89c54a8 100644 --- a/pkg/modelregistry/model_registry.go +++ b/pkg/modelregistry/model_registry.go @@ -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, } } diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index 4511331..48c1ca6 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -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) { diff --git a/pkg/resolvespec/security_hooks.go b/pkg/resolvespec/security_hooks.go index 629c8e3..c44e818 100644 --- a/pkg/resolvespec/security_hooks.go +++ b/pkg/resolvespec/security_hooks.go @@ -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") } diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index 58be0a2..e1aac8f 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -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) diff --git a/pkg/restheadspec/security_hooks.go b/pkg/restheadspec/security_hooks.go index 62b5663..d6c1864 100644 --- a/pkg/restheadspec/security_hooks.go +++ b/pkg/restheadspec/security_hooks.go @@ -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") } diff --git a/pkg/security/hooks.go b/pkg/security/hooks.go index a30e062..ecf2bd5 100644 --- a/pkg/security/hooks.go +++ b/pkg/security/hooks.go @@ -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 { diff --git a/pkg/security/middleware.go b/pkg/security/middleware.go index c2f1fdb..ca61f03 100644 --- a/pkg/security/middleware.go +++ b/pkg/security/middleware.go @@ -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 diff --git a/pkg/websocketspec/security_hooks.go b/pkg/websocketspec/security_hooks.go new file mode 100644 index 0000000..4e57701 --- /dev/null +++ b/pkg/websocketspec/security_hooks.go @@ -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 +}