Compare commits

...
5 Commits
Author SHA1 Message Date
warkanum d6c5740f9c fix(handler): add operation type to hook context
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after 1s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 1s
Build , Vet Test, and Lint / Lint Code (push) Failing after 1s
Build , Vet Test, and Lint / Build (push) Failing after 1s
Tests / Unit Tests (push) Failing after 0s
Tests / Integration Tests (push) Failing after 10s
2026-09-20 16:12:44 +02:00
warkanum 817b781c88 fix(security): skip loading security rules if disabled 2026-09-20 15:52:25 +02:00
warkanum 87eaa9e18c fix(security): skip row security enforcement for specific operations
* Add ShouldSkipRowSecurity function to determine when to bypass row security
* Update ApplyRowSecurity to utilize operation context for enforcement
2026-09-20 15:51:02 +02:00
Hein 4f6878099b fix(quickproxy): reject Exclude entries outside their rule's URLPrefix
Tests / Unit Tests (push) Failing after 5s
Tests / Integration Tests (push) Failing after 23s
Build , Vet Test, and Lint / Build (push) Successful in 52s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 55s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 56s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m4s
An Exclude entry only ever matches requests that already fall under
its rule's URLPrefix, so one written without that prefix (e.g.
"/health" on a rule for "/api") silently never triggered. Validate
that each Exclude entry itself starts with the rule's URLPrefix,
failing NewService instead of accepting a no-op config.
2026-09-17 11:40:49 +02:00
Hein 0d8b136b91 feat(quickproxy): support per-rule Exclude path prefixes
Rule.Exclude lists path prefixes that should never be proxied by that
rule, even though they fall under its URLPrefix. A request matching an
Exclude prefix is treated as a non-match for that rule: matching
continues against other configured rules, falling back to the
caller-supplied handler if none apply. Lets a catch-all "/" rule proxy
everything except carved-out paths like "/health".
2026-09-17 11:38:02 +02:00
6 changed files with 321 additions and 125 deletions
+113 -102
View File
@@ -306,15 +306,16 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: id,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "read",
Options: options,
ID: id,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
@@ -722,15 +723,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
var nestedResult *common.ProcessResult
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -769,15 +771,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
var responseData interface{} = v
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -851,15 +854,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -898,15 +902,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -982,15 +987,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
for _, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -1035,15 +1041,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -1166,16 +1173,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// they must run before the existence-check select so that select is
// also subject to RLS on this connection/transaction.
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: urlID,
Data: updates,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "update",
Options: options,
ID: urlID,
Data: updates,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
@@ -1387,16 +1395,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: itemIDStr,
Data: item,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "update",
Options: options,
ID: itemIDStr,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
@@ -1543,16 +1552,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: itemIDStr,
Data: itemMap,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "update",
Options: options,
ID: itemIDStr,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
@@ -1648,15 +1658,16 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
// 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,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "delete",
ID: id,
Data: data,
Writer: w,
Tx: h.db,
}
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err)
+10
View File
@@ -25,12 +25,18 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
// Hook 1: BeforeRead - Load security rules
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
if security.IsModelSecurityDisabled(secCtx) {
return nil
}
return security.LoadSecurityRules(secCtx, securityList)
})
// Hook 2: BeforeScan - Apply row-level security filters
handler.Hooks().Register(BeforeScan, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
if security.ShouldSkipRowSecurity(secCtx, hookCtx.Operation) {
return nil
}
return security.ApplyRowSecurity(secCtx, securityList)
})
@@ -97,6 +103,10 @@ func (s *securityContext) GetEntity() string {
return s.ctx.Entity
}
func (s *securityContext) GetOperation() string {
return s.ctx.Operation
}
func (s *securityContext) GetModel() interface{} {
return s.ctx.Model
}
+8
View File
@@ -379,6 +379,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
Entity: entity,
TableName: tableName,
Model: model,
Operation: "read",
Options: options,
ID: id,
Writer: w,
@@ -1236,6 +1237,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Entity: entity,
TableName: tableName,
Model: model,
Operation: "create",
Options: options,
Data: data,
Writer: w,
@@ -1335,6 +1337,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Entity: entity,
TableName: tableName,
Model: model,
Operation: "create",
Options: options,
Data: modelValue,
Writer: w,
@@ -1489,6 +1492,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
TableName: tableName,
Tx: tx,
Model: model,
Operation: "update",
Options: options,
ID: id,
Data: dataMap,
@@ -1686,6 +1690,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: itemID,
Writer: w,
Tx: tx,
@@ -1760,6 +1765,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: itemIDStr,
Writer: w,
Tx: tx,
@@ -1818,6 +1824,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: itemIDStr,
Writer: w,
Tx: tx,
@@ -1902,6 +1909,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: id,
Writer: w,
Tx: h.db,
+59 -17
View File
@@ -232,9 +232,37 @@ func LoadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
// ApplyRowSecurity is a public wrapper for applyRowSecurity that accepts a SecurityContext
// This allows other packages to apply row-level security using the generic interface
func ApplyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
// Spec adapters that expose the dispatched operation can enforce the same
// model-rule bypass even when ApplyRowSecurity is called directly.
if operationCtx, ok := secCtx.(interface{ GetOperation() string }); ok &&
ShouldSkipRowSecurity(secCtx, operationCtx.GetOperation()) {
return nil
}
return applyRowSecurity(secCtx, securityList)
}
// ShouldSkipRowSecurity reports whether row-security enforcement should be
// skipped for the operation. It uses the same model-rule resolution as
// CheckModelAuthAllowed so the model registry remains the single source of
// truth for security behavior.
func ShouldSkipRowSecurity(secCtx SecurityContext, operation string) bool {
rules, ok := resolveModelRules(secCtx)
if !ok {
return false
}
return rules.SecurityDisabled || (operation == "read" && rules.CanPublicRead)
}
// IsModelSecurityDisabled reports whether all model-level security processing
// is disabled for the model. This is distinct from ShouldSkipRowSecurity:
// CanPublicRead skips row filtering for reads but must still allow other read
// security, such as column masking, to be loaded.
func IsModelSecurityDisabled(secCtx SecurityContext) bool {
rules, ok := resolveModelRules(secCtx)
return ok && rules.SecurityDisabled
}
// ApplyColumnSecurity is a public wrapper for applyColumnSecurity that accepts a SecurityContext
// This allows other packages to apply column-level security using the generic interface
func ApplyColumnSecurity(secCtx SecurityContext, securityList *SecurityList) error {
@@ -303,25 +331,14 @@ func checkModelDeleteAllowed(secCtx SecurityContext) error {
// 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())
rules, ok := resolveModelRules(secCtx)
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
// Model not registered - fall through to auth check
userID, _ := secCtx.GetUserID()
if userID == 0 {
return fmt.Errorf("authentication required")
}
return nil
}
if rules.SecurityDisabled {
@@ -347,6 +364,31 @@ func CheckModelAuthAllowed(secCtx SecurityContext, operation string) error {
return nil
}
// resolveModelRules returns model rules from the request context first, then
// falls back to the schema-qualified and unqualified registry names.
func resolveModelRules(secCtx SecurityContext) (modelregistry.ModelRules, bool) {
if rules, ok := GetModelRulesFromContext(secCtx.GetContext()); ok {
return rules, true
}
schema := secCtx.GetSchema()
entity := secCtx.GetEntity()
var err error
if schema != "" {
var rules modelregistry.ModelRules
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
if err == nil {
return rules, true
}
}
rules, err := modelregistry.GetModelRulesByName(entity)
if err != nil {
return modelregistry.ModelRules{}, false
}
return rules, true
}
// CheckModelUpdateAllowed is the public wrapper for checkModelUpdateAllowed.
func CheckModelUpdateAllowed(secCtx SecurityContext) error {
return checkModelUpdateAllowed(secCtx)
+44 -6
View File
@@ -26,6 +26,17 @@ type Rule struct {
// The incoming request path and query are forwarded unchanged; only the
// scheme and host are rewritten to Target's.
Target string
// Exclude is a list of URL path prefixes that this rule should not
// proxy, even though they fall under URLPrefix. Each entry is a full
// path from root and must itself start with URLPrefix (e.g. rule
// URLPrefix "/api" excluding a subpath must use "/api/health", not
// "/health"). A request matching an Exclude prefix is treated as if
// this rule didn't match at all: matching continues against any other
// configured rule, falling back if none match. This is typically used
// to carve out paths (e.g. "/health") from a catch-all "/" rule so
// they're served by the fallback handler instead of being proxied.
Exclude []string
}
// DefaultTimeout is the dial and response-header timeout applied to
@@ -50,8 +61,19 @@ func WithTimeout(d time.Duration) Option {
// compiledRule pairs a Rule with its ready-to-use reverse proxy.
type compiledRule struct {
prefix string
proxy *httputil.ReverseProxy
prefix string
excludes []string
proxy *httputil.ReverseProxy
}
// excluded reports whether path falls under one of the rule's Exclude prefixes.
func (r *compiledRule) excluded(path string) bool {
for _, ex := range r.excludes {
if strings.HasPrefix(path, ex) {
return true
}
}
return false
}
// Service holds a compiled set of proxy rules and performs longest-prefix
@@ -98,9 +120,19 @@ func NewService(rules []Rule, opts ...Option) (*Service, error) {
return nil, fmt.Errorf("quickproxy: invalid target %q for prefix %q", r.Target, r.URLPrefix)
}
for _, ex := range r.Exclude {
if !strings.HasPrefix(ex, "/") {
return nil, fmt.Errorf("quickproxy: exclude prefix %q for rule %q must start with /", ex, r.URLPrefix)
}
if !strings.HasPrefix(ex, r.URLPrefix) {
return nil, fmt.Errorf("quickproxy: exclude prefix %q for rule %q must itself start with the rule's URLPrefix", ex, r.URLPrefix)
}
}
compiled = append(compiled, compiledRule{
prefix: r.URLPrefix,
proxy: newReverseProxy(target, cfg.timeout),
prefix: r.URLPrefix,
excludes: r.Exclude,
proxy: newReverseProxy(target, cfg.timeout),
})
}
@@ -174,11 +206,17 @@ func (s *Service) Handler(fallback http.Handler) http.Handler {
}
// match returns the longest-prefix rule matching path, or nil if none match.
// A rule whose Exclude covers path is skipped, and matching continues
// against the next-longest-prefix rule.
func (s *Service) match(path string) *compiledRule {
for i := range s.rules {
if strings.HasPrefix(path, s.rules[i].prefix) {
return &s.rules[i]
if !strings.HasPrefix(path, s.rules[i].prefix) {
continue
}
if s.rules[i].excluded(path) {
continue
}
return &s.rules[i]
}
return nil
}
+87
View File
@@ -23,7 +23,19 @@ func TestNewService_Validation(t *testing.T) {
{URLPrefix: "/api", Target: "http://localhost:1"},
{URLPrefix: "/api", Target: "http://localhost:2"},
}, true},
{"bad exclude prefix", []Rule{
{URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"health"}},
}, true},
{"exclude outside rule's URLPrefix", []Rule{
{URLPrefix: "/api", Target: "http://localhost:1", Exclude: []string{"/health"}},
}, true},
{"valid", []Rule{{URLPrefix: "/api", Target: "http://localhost:1"}}, false},
{"valid with exclude", []Rule{
{URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"/health"}},
}, false},
{"valid with nested exclude", []Rule{
{URLPrefix: "/api", Target: "http://localhost:1", Exclude: []string{"/api/health"}},
}, false},
}
for _, tt := range tests {
@@ -186,6 +198,81 @@ func TestHandler_LongestPrefixMatch(t *testing.T) {
}
}
func TestHandler_ExcludeFallsBackToFallback(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("upstream:" + r.URL.Path))
}))
defer upstream.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/", Target: upstream.URL, Exclude: []string{"/health"}},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
req = httptest.NewRequest(http.MethodGet, "/health/live", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
req = httptest.NewRequest(http.MethodGet, "/other", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "upstream:/other" {
t.Fatalf("body = %q, want upstream:/other", got)
}
}
func TestHandler_ExcludeFallsThroughToNextRule(t *testing.T) {
specific := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("specific"))
}))
defer specific.Close()
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("general"))
}))
defer general.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/api", Target: general.URL},
{URLPrefix: "/api/v1", Target: specific.URL, Exclude: []string{"/api/v1/health"}},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
for path, want := range map[string]string{
"/api/v1/thing": "specific",
"/api/v1/health": "general",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != want {
t.Errorf("path %s: body = %q, want %q", path, got, want)
}
}
}
func TestHandler_NoMatchFallsBack(t *testing.T) {
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: "http://127.0.0.1:1"}})
if err != nil {