feat(security): exclude hidden/masked columns from create and update payloads

This commit is contained in:
2026-09-30 23:48:09 +02:00
parent a65ca5f5ce
commit b35399fdfa
13 changed files with 456 additions and 4 deletions
+6
View File
@@ -226,6 +226,12 @@ type ColumnSecurityProvider interface {
}
```
Write side (`RegisterSecurityHooks`, all specs except funcspec):
- Columns with a `hide` or `mask` rule (single-element `Path`) are removed from create/update payloads in `BeforeCreate`/`BeforeUpdate`; the write is not rejected.
- Match is case-insensitive on the rule path vs payload key, model field/JSON name or `gorm` column.
- Rules are preloaded in `BeforeHandle` (outside the tx); the hook only reads the cache and fails closed if rules were not loaded.
- Not covered: nested child records, nested `Path` (JSON sub-values), funcspec.
#### 3. RowSecurityProvider
Manages row-level security (WHERE clause filtering):
+17 -4
View File
@@ -245,13 +245,26 @@ func LoadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
// cache for read operations. Call it from a BeforeHandle hook, i.e. before the
// handler opens its transaction, so the provider queries do not need a second
// pooled connection while the transaction holds one. Later LoadSecurityRules
// calls in the same request are then cache hits. Non-read operations and
// models with security disabled are skipped.
// calls in the same request are then cache hits. Reads load column and row
// rules; create/update load the column rules that ApplyWriteColumnSecurity
// reads. Other operations and models with security disabled are skipped.
func PreloadSecurityRules(secCtx SecurityContext, securityList *SecurityList, operation string) error {
if operation != "read" || IsModelSecurityDisabled(secCtx) {
if IsModelSecurityDisabled(secCtx) {
return nil
}
return loadSecurityRules(secCtx, securityList)
switch {
case operation == "read":
return loadSecurityRules(secCtx, securityList)
case isWriteOperation(operation):
userID, ok := secCtx.GetUserID()
if !ok {
return nil
}
if err := securityList.LoadColumnSecurity(secCtx.GetContext(), userID, secCtx.GetSchema(), secCtx.GetEntity(), false); err != nil {
logger.Warn("Failed to load column security: %v", err)
}
}
return nil
}
// ApplyRowSecurity is a public wrapper for applyRowSecurity that accepts a SecurityContext
+159
View File
@@ -0,0 +1,159 @@
package security
import (
"fmt"
"reflect"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
// WriteDataContext is implemented by security contexts that expose the
// create/update payload of the operation in flight.
type WriteDataContext interface {
GetData() interface{}
SetData(interface{})
}
// isWriteOperation reports whether the operation writes columns.
func isWriteOperation(operation string) bool {
return operation == "create" || operation == "update"
}
// cachedColumnRules returns the cached column rules for the user/table and
// whether they were loaded. It never calls the provider, so it is safe inside
// a transaction.
func (m *SecurityList) cachedColumnRules(userID int, schema, table string) ([]ColumnSecurity, bool) {
m.ColumnSecurityMutex.RLock()
defer m.ColumnSecurityMutex.RUnlock()
rules, ok := m.ColumnSecurity[fmt.Sprintf("%s.%s@%d", schema, table, userID)]
return rules, ok && rules != nil
}
// blockedWriteColumns returns the lower-cased top-level column names that a
// "hide" or "mask" rule removes from write payloads.
func blockedWriteColumns(rules []ColumnSecurity) map[string]struct{} {
blocked := make(map[string]struct{})
for i := range rules {
r := &rules[i]
if !strings.EqualFold(r.Accesstype, "hide") && !strings.EqualFold(r.Accesstype, "mask") {
continue
}
if len(r.Path) != 1 {
continue // nested paths address JSON sub-values, not columns
}
blocked[strings.ToLower(r.Path[0])] = struct{}{}
}
return blocked
}
// modelColumnAliases maps each lower-cased field/column/JSON name of the model
// to all of its lower-cased names, so a rule on "name" also blocks its column.
func modelColumnAliases(model interface{}) map[string][]string {
aliases := make(map[string][]string)
if model == nil {
return aliases
}
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer || v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
if v.Kind() == reflect.Pointer && v.IsNil() {
v = reflect.New(v.Type().Elem())
}
if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
v = reflect.New(v.Type().Elem()).Elem()
continue
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return aliases
}
for _, c := range reflection.GetModelColumnDetail(v) {
names := []string{strings.ToLower(c.Name), strings.ToLower(c.SQLName)}
for _, n := range names {
if n != "" {
aliases[n] = names
}
}
}
return aliases
}
// stripBlocked removes blocked keys from one payload map in place.
func stripBlocked(m map[string]interface{}, blocked map[string]struct{}, aliases map[string][]string) []string {
var dropped []string
for key := range m {
lk := strings.ToLower(key)
hit := false
if _, ok := blocked[lk]; ok {
hit = true
} else {
for _, a := range aliases[lk] {
if _, ok := blocked[a]; ok {
hit = true
break
}
}
}
if hit {
delete(m, key)
dropped = append(dropped, key)
}
}
return dropped
}
// stripPayload strips blocked keys from a map, []map or []interface{} payload.
func stripPayload(data interface{}, blocked map[string]struct{}, aliases map[string][]string) (dropped []string) {
switch d := data.(type) {
case map[string]interface{}:
dropped = stripBlocked(d, blocked, aliases)
case []map[string]interface{}:
for _, m := range d {
dropped = append(dropped, stripBlocked(m, blocked, aliases)...)
}
case []interface{}:
for _, e := range d {
dropped = append(dropped, stripPayload(e, blocked, aliases)...)
}
}
return dropped
}
// ApplyWriteColumnSecurity removes columns the user may not see (column
// security "hide" or "mask") from the create/update payload in place, so a
// hidden or masked column can never be written. It only reads the rules cache
// (see PreloadSecurityRules) and never queries the provider, so it is safe
// inside the transaction. Models with security disabled are skipped. Without
// a loaded rule set for a known user it fails closed.
func ApplyWriteColumnSecurity(secCtx SecurityContext, securityList *SecurityList) error {
userID, ok := secCtx.GetUserID()
if !ok || securityList == nil || IsModelSecurityDisabled(secCtx) {
return nil
}
dc, ok := secCtx.(WriteDataContext)
if !ok {
return fmt.Errorf("column security: write payload not accessible for %s.%s", secCtx.GetSchema(), secCtx.GetEntity())
}
data := dc.GetData()
if data == nil {
return nil
}
rules, loaded := securityList.cachedColumnRules(userID, secCtx.GetSchema(), secCtx.GetEntity())
if !loaded {
return fmt.Errorf("column security rules not loaded for %s.%s", secCtx.GetSchema(), secCtx.GetEntity())
}
blocked := blockedWriteColumns(rules)
if len(blocked) == 0 {
return nil
}
dropped := stripPayload(data, blocked, modelColumnAliases(secCtx.GetModel()))
if len(dropped) > 0 {
logger.Warn("Column security: dropped write to hidden/masked columns %v on %s.%s (user %d)",
dropped, secCtx.GetSchema(), secCtx.GetEntity(), userID)
}
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package security
import (
"context"
"reflect"
"testing"
)
type wsCtx struct {
data interface{}
model interface{}
user bool
}
func (c *wsCtx) GetContext() context.Context {
if c.user {
return context.WithValue(context.Background(), UserIDKey, 7)
}
return context.Background()
}
func (c *wsCtx) GetUserID() (int, bool) { return 7, c.user }
func (c *wsCtx) GetUserRef() (any, bool) { return 7, c.user }
func (c *wsCtx) GetSchema() string { return "public" }
func (c *wsCtx) GetEntity() string { return "items" }
func (c *wsCtx) GetModel() interface{} { return c.model }
func (c *wsCtx) GetQuery() interface{} { return nil }
func (c *wsCtx) SetQuery(interface{}) {}
func (c *wsCtx) GetResult() interface{} { return nil }
func (c *wsCtx) SetResult(interface{}) {}
func (c *wsCtx) GetData() interface{} { return c.data }
func (c *wsCtx) SetData(d interface{}) { c.data = d }
type wsModel struct {
ID int `json:"id" bun:"id,pk"`
Name string `json:"name" bun:"name"`
Email string `json:"email" gorm:"column:email_addr"`
Other string `json:"other" bun:"other"`
}
func wsList(rules ...ColumnSecurity) *SecurityList {
l := &SecurityList{ColumnSecurity: map[string][]ColumnSecurity{"public.items@7": rules}}
if rules == nil {
l.ColumnSecurity["public.items@7"] = []ColumnSecurity{}
}
return l
}
func TestApplyWriteColumnSecurityStripsHiddenAndMasked(t *testing.T) {
list := wsList(
ColumnSecurity{Path: []string{"Name"}, Accesstype: "hide"},
ColumnSecurity{Path: []string{"email_addr"}, Accesstype: "mask"},
ColumnSecurity{Path: []string{"other"}, Accesstype: "allow"},
ColumnSecurity{Path: []string{"id", "sub"}, Accesstype: "hide"},
)
tests := map[string]struct{ in, want interface{} }{
"map": {
map[string]interface{}{"id": 1, "name": "a", "email": "e", "other": "o"},
map[string]interface{}{"id": 1, "other": "o"},
},
"slice": {
[]interface{}{map[string]interface{}{"NAME": "a", "id": 1}, map[string]interface{}{"email_addr": "e"}},
[]interface{}{map[string]interface{}{"id": 1}, map[string]interface{}{}},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
c := &wsCtx{data: tc.in, model: &wsModel{}, user: true}
if err := ApplyWriteColumnSecurity(c, list); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(c.data, tc.want) {
t.Fatalf("got %v want %v", c.data, tc.want)
}
})
}
}
func TestApplyWriteColumnSecurityNoRulesKeepsPayload(t *testing.T) {
c := &wsCtx{data: map[string]interface{}{"name": "a"}, model: &wsModel{}, user: true}
if err := ApplyWriteColumnSecurity(c, wsList()); err != nil {
t.Fatal(err)
}
if _, ok := c.data.(map[string]interface{})["name"]; !ok {
t.Fatal("payload changed without rules")
}
}
func TestApplyWriteColumnSecurityFailsClosedWhenRulesNotLoaded(t *testing.T) {
c := &wsCtx{data: map[string]interface{}{"name": "a"}, model: &wsModel{}, user: true}
if err := ApplyWriteColumnSecurity(c, &SecurityList{}); err == nil {
t.Fatal("expected an error when rules are not loaded")
}
}
func TestApplyWriteColumnSecuritySkipsWithoutUser(t *testing.T) {
c := &wsCtx{data: map[string]interface{}{"name": "a"}, model: &wsModel{}}
if err := ApplyWriteColumnSecurity(c, &SecurityList{}); err != nil {
t.Fatal(err)
}
}