mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-05 21:06:28 +00:00
feat(security): exclude hidden/masked columns from create and update payloads
This commit is contained in:
@@ -119,3 +119,4 @@
|
|||||||
- RLS GUC set in `OnTxBegin` is visible to read, create, update, delete queries and hooks.
|
- RLS GUC set in `OnTxBegin` is visible to read, create, update, delete queries and hooks.
|
||||||
- No `Tx: h.db` / `hookCtx.Tx = h.db` left in spec handlers.
|
- No `Tx: h.db` / `hookCtx.Tx = h.db` left in spec handlers.
|
||||||
- OPEN: websocketspec `BeforeDisconnect`/`AfterDisconnect` are defined but never executed (connection lifecycle, not DB). Allowlisted in `TestEveryDefinedHookHasACallSite`; wire them to remove the entry.
|
- OPEN: websocketspec `BeforeDisconnect`/`AfterDisconnect` are defined but never executed (connection lifecycle, not DB). Allowlisted in `TestEveryDefinedHookHasACallSite`; wire them to remove the entry.
|
||||||
|
- DONE: column-level hide/mask columns are dropped from create/update payloads (`security.ApplyWriteColumnSecurity`); rules preloaded in `BeforeHandle` for create/update. resolvemcp update now runs `BeforeHandle`.
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// BeforeHandle: preload column rules for writes before the handler opens its
|
||||||
|
// transaction; the write hooks below only read the cache.
|
||||||
|
handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error {
|
||||||
|
return security.PreloadSecurityRules(newSecurityContext(hookCtx), securityList, hookCtx.Operation)
|
||||||
|
})
|
||||||
|
|
||||||
|
// BeforeCreate/BeforeUpdate: drop columns hidden or masked for the user from the
|
||||||
|
// write payload, so they cannot be inserted or updated.
|
||||||
|
handler.Hooks().Register(BeforeCreate, func(hookCtx *HookContext) error {
|
||||||
|
return security.ApplyWriteColumnSecurity(newSecurityContext(hookCtx), securityList)
|
||||||
|
})
|
||||||
|
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
|
||||||
|
return security.ApplyWriteColumnSecurity(newSecurityContext(hookCtx), securityList)
|
||||||
|
})
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -122,6 +137,14 @@ func (s *securityContext) SetQuery(query interface{}) {
|
|||||||
s.ctx.Metadata["query"] = query
|
s.ctx.Metadata["query"] = query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) GetData() interface{} {
|
||||||
|
return s.ctx.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) SetData(data interface{}) {
|
||||||
|
s.ctx.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetResult() interface{} {
|
func (s *securityContext) GetResult() interface{} {
|
||||||
return s.ctx.Result
|
return s.ctx.Result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -596,6 +596,9 @@ func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string,
|
|||||||
Data: updates,
|
Data: updates,
|
||||||
Tx: h.db,
|
Tx: h.db,
|
||||||
}
|
}
|
||||||
|
if err := h.hooks.Execute(BeforeHandle, hookCtx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var updateResult interface{}
|
var updateResult interface{}
|
||||||
err = h.runInTx(ctx, hookCtx, func(tx common.Database) error {
|
err = h.runInTx(ctx, hookCtx, func(tx common.Database) error {
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// BeforeHandle: preload column rules for writes before the handler opens its
|
||||||
|
// transaction; the write hooks below only read the cache.
|
||||||
|
handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error {
|
||||||
|
return security.PreloadSecurityRules(newSecurityContext(hookCtx), securityList, hookCtx.Operation)
|
||||||
|
})
|
||||||
|
|
||||||
|
// BeforeCreate/BeforeUpdate: drop columns hidden or masked for the user from the
|
||||||
|
// write payload, so they cannot be inserted or updated.
|
||||||
|
handler.Hooks().Register(BeforeCreate, func(hookCtx *HookContext) error {
|
||||||
|
return security.ApplyWriteColumnSecurity(newSecurityContext(hookCtx), securityList)
|
||||||
|
})
|
||||||
|
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
|
||||||
|
return security.ApplyWriteColumnSecurity(newSecurityContext(hookCtx), securityList)
|
||||||
|
})
|
||||||
|
|
||||||
// BeforeRead (1st): load RLS + CLS rules from the provider into SecurityList.
|
// BeforeRead (1st): load RLS + CLS rules from the provider into SecurityList.
|
||||||
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
|
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
|
||||||
return security.LoadSecurityRules(newSecurityContext(hookCtx), securityList)
|
return security.LoadSecurityRules(newSecurityContext(hookCtx), securityList)
|
||||||
@@ -123,6 +138,14 @@ func (s *securityContext) SetQuery(query interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) GetData() interface{} {
|
||||||
|
return s.ctx.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) SetData(data interface{}) {
|
||||||
|
s.ctx.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetResult() interface{} {
|
func (s *securityContext) GetResult() interface{} {
|
||||||
return s.ctx.Result
|
return s.ctx.Result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,6 +293,10 @@ func TestOnTxBeginErrorRollsBackEveryOperation(t *testing.T) {
|
|||||||
|
|
||||||
type stubProvider struct{ security.SecurityProvider }
|
type stubProvider struct{ security.SecurityProvider }
|
||||||
|
|
||||||
|
func (stubProvider) GetColumnSecurity(context.Context, int, string, string) ([]security.ColumnSecurity, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestSecurityHooksStampTxSettingsOnEveryTransaction(t *testing.T) {
|
func TestSecurityHooksStampTxSettingsOnEveryTransaction(t *testing.T) {
|
||||||
h, mock, ctx := newTxHarness(t)
|
h, mock, ctx := newTxHarness(t)
|
||||||
list, err := security.NewSecurityList(stubProvider{})
|
list, err := security.NewSecurityList(stubProvider{})
|
||||||
@@ -304,6 +308,7 @@ func TestSecurityHooksStampTxSettingsOnEveryTransaction(t *testing.T) {
|
|||||||
})
|
})
|
||||||
RegisterSecurityHooks(h, list)
|
RegisterSecurityHooks(h, list)
|
||||||
ctx = context.WithValue(ctx, security.UserContextKey, &security.UserContext{UserID: 7, UserName: "u"})
|
ctx = context.WithValue(ctx, security.UserContextKey, &security.UserContext{UserID: 7, UserName: "u"})
|
||||||
|
ctx = context.WithValue(ctx, security.UserIDKey, 7)
|
||||||
|
|
||||||
// Update opens two transactions; each must be stamped before any other SQL.
|
// Update opens two transactions; each must be stamped before any other SQL.
|
||||||
cols := []string{"id", "name"}
|
cols := []string{"id", "name"}
|
||||||
|
|||||||
@@ -421,3 +421,65 @@ func TestAfterDeleteErrorRollsBackDelete(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Columns hidden or masked for the user cannot be written. Handlers are called
|
||||||
|
// directly here, so the BeforeHandle preload is done by hand.
|
||||||
|
func TestColumnSecurityDropsHiddenColumnOnWrite(t *testing.T) {
|
||||||
|
setup := func(t *testing.T) (*Handler, sqlmock.Sqlmock, context.Context, common.ResponseWriter, *httptest.ResponseRecorder) {
|
||||||
|
h, mock, _ := newDeleteHarness(t)
|
||||||
|
list, err := security.NewSecurityList(columnSecProvider{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
RegisterSecurityHooks(h, list)
|
||||||
|
|
||||||
|
ctx, w, rec := opCtx(t)
|
||||||
|
ctx = context.WithValue(ctx, security.UserContextKey, &security.UserContext{UserID: 7, UserName: "u"})
|
||||||
|
ctx = context.WithValue(ctx, security.UserIDKey, 7)
|
||||||
|
pre := newSecurityContext(&HookContext{Context: ctx, Schema: "public", Entity: "items", Model: &delItem{}})
|
||||||
|
for _, op := range []string{"create", "update"} {
|
||||||
|
if err := security.PreloadSecurityRules(pre, list, op); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h, mock, ctx, w, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("create", func(t *testing.T) {
|
||||||
|
h, mock, ctx, w, rec := setup(t)
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery(`INSERT`).WithArgs(7).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
|
||||||
|
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, ""))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{"id": 7, "name": "secret"}
|
||||||
|
h.handleCreate(ctx, w, payload, common.RequestOptions{})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d body %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if _, ok := payload["name"]; ok {
|
||||||
|
t.Fatalf("hidden column must be dropped from the insert payload: %v", payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("update", func(t *testing.T) {
|
||||||
|
h, mock, ctx, w, rec := setup(t)
|
||||||
|
cols := []string{"id", "name"}
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows(cols).AddRow(7, "a"))
|
||||||
|
mock.ExpectExec(`UPDATE`).WithArgs(float64(7), "a", "7").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows(cols).AddRow(7, "a"))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{"name": "secret"}
|
||||||
|
h.handleUpdate(ctx, w, "7", nil, payload, common.RequestOptions{})
|
||||||
|
if _, ok := payload["name"]; ok {
|
||||||
|
t.Fatalf("hidden column must be dropped from the update payload: %v (status %d %s)", payload, rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("update must keep the stored value: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
|
|||||||
return security.PreloadSecurityRules(newSecurityContext(hookCtx), securityList, hookCtx.Operation)
|
return security.PreloadSecurityRules(newSecurityContext(hookCtx), securityList, hookCtx.Operation)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// BeforeCreate/BeforeUpdate: drop columns hidden or masked for the user from the
|
||||||
|
// write payload, so they cannot be inserted or updated.
|
||||||
|
handler.Hooks().Register(BeforeCreate, func(hookCtx *HookContext) error {
|
||||||
|
return security.ApplyWriteColumnSecurity(newSecurityContext(hookCtx), securityList)
|
||||||
|
})
|
||||||
|
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
|
||||||
|
return security.ApplyWriteColumnSecurity(newSecurityContext(hookCtx), securityList)
|
||||||
|
})
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -133,6 +142,14 @@ func (s *securityContext) SetQuery(query interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) GetData() interface{} {
|
||||||
|
return s.ctx.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) SetData(data interface{}) {
|
||||||
|
s.ctx.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetResult() interface{} {
|
func (s *securityContext) GetResult() interface{} {
|
||||||
return s.ctx.Result
|
return s.ctx.Result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
|
|||||||
return security.PreloadSecurityRules(newSecurityContext(hookCtx), securityList, hookCtx.Operation)
|
return security.PreloadSecurityRules(newSecurityContext(hookCtx), securityList, hookCtx.Operation)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// BeforeCreate/BeforeUpdate: drop columns hidden or masked for the user from the
|
||||||
|
// write payload, so they cannot be inserted or updated.
|
||||||
|
handler.Hooks().Register(BeforeCreate, func(hookCtx *HookContext) error {
|
||||||
|
return security.ApplyWriteColumnSecurity(newSecurityContext(hookCtx), securityList)
|
||||||
|
})
|
||||||
|
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
|
||||||
|
return security.ApplyWriteColumnSecurity(newSecurityContext(hookCtx), securityList)
|
||||||
|
})
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -120,6 +129,14 @@ func (s *securityContext) SetQuery(query interface{}) {
|
|||||||
s.ctx.Query = query
|
s.ctx.Query = query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) GetData() interface{} {
|
||||||
|
return s.ctx.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) SetData(data interface{}) {
|
||||||
|
s.ctx.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetResult() interface{} {
|
func (s *securityContext) GetResult() interface{} {
|
||||||
return s.ctx.Result
|
return s.ctx.Result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
#### 3. RowSecurityProvider
|
||||||
Manages row-level security (WHERE clause filtering):
|
Manages row-level security (WHERE clause filtering):
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -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
|
// 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
|
// handler opens its transaction, so the provider queries do not need a second
|
||||||
// pooled connection while the transaction holds one. Later LoadSecurityRules
|
// pooled connection while the transaction holds one. Later LoadSecurityRules
|
||||||
// calls in the same request are then cache hits. Non-read operations and
|
// calls in the same request are then cache hits. Reads load column and row
|
||||||
// models with security disabled are skipped.
|
// 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 {
|
func PreloadSecurityRules(secCtx SecurityContext, securityList *SecurityList, operation string) error {
|
||||||
if operation != "read" || IsModelSecurityDisabled(secCtx) {
|
if IsModelSecurityDisabled(secCtx) {
|
||||||
return nil
|
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
|
// ApplyRowSecurity is a public wrapper for applyRowSecurity that accepts a SecurityContext
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,21 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// BeforeHandle: preload column rules for writes before the handler opens its
|
||||||
|
// transaction; the write hooks below only read the cache.
|
||||||
|
handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error {
|
||||||
|
return security.PreloadSecurityRules(newSecurityContext(hookCtx), securityList, hookCtx.Operation)
|
||||||
|
})
|
||||||
|
|
||||||
|
// BeforeCreate/BeforeUpdate: drop columns hidden or masked for the user from the
|
||||||
|
// write payload, so they cannot be inserted or updated.
|
||||||
|
handler.Hooks().Register(BeforeCreate, func(hookCtx *HookContext) error {
|
||||||
|
return security.ApplyWriteColumnSecurity(newSecurityContext(hookCtx), securityList)
|
||||||
|
})
|
||||||
|
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
|
||||||
|
return security.ApplyWriteColumnSecurity(newSecurityContext(hookCtx), securityList)
|
||||||
|
})
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -122,6 +137,14 @@ func (s *securityContext) SetQuery(query interface{}) {
|
|||||||
s.ctx.Metadata["query"] = query
|
s.ctx.Metadata["query"] = query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) GetData() interface{} {
|
||||||
|
return s.ctx.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) SetData(data interface{}) {
|
||||||
|
s.ctx.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetResult() interface{} {
|
func (s *securityContext) GetResult() interface{} {
|
||||||
return s.ctx.Result
|
return s.ctx.Result
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user