diff --git a/audit/single_tran.md b/audit/single_tran.md index 13bb6a6..300b84a 100644 --- a/audit/single_tran.md +++ b/audit/single_tran.md @@ -119,3 +119,4 @@ - 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. - 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`. diff --git a/pkg/mqttspec/security_hooks.go b/pkg/mqttspec/security_hooks.go index bcdec15..587a5c7 100644 --- a/pkg/mqttspec/security_hooks.go +++ b/pkg/mqttspec/security_hooks.go @@ -27,6 +27,21 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList 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 handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { secCtx := newSecurityContext(hookCtx) @@ -122,6 +137,14 @@ func (s *securityContext) SetQuery(query interface{}) { 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{} { return s.ctx.Result } diff --git a/pkg/resolvemcp/handler.go b/pkg/resolvemcp/handler.go index 5723466..4feb95a 100644 --- a/pkg/resolvemcp/handler.go +++ b/pkg/resolvemcp/handler.go @@ -596,6 +596,9 @@ func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string, Data: updates, Tx: h.db, } + if err := h.hooks.Execute(BeforeHandle, hookCtx); err != nil { + return nil, err + } var updateResult interface{} err = h.runInTx(ctx, hookCtx, func(tx common.Database) error { diff --git a/pkg/resolvemcp/security_hooks.go b/pkg/resolvemcp/security_hooks.go index f7d8bd4..a26b109 100644 --- a/pkg/resolvemcp/security_hooks.go +++ b/pkg/resolvemcp/security_hooks.go @@ -36,6 +36,21 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList 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. handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { 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{} { return s.ctx.Result } diff --git a/pkg/resolvemcp/tx_test.go b/pkg/resolvemcp/tx_test.go index 3bff822..2279306 100644 --- a/pkg/resolvemcp/tx_test.go +++ b/pkg/resolvemcp/tx_test.go @@ -293,6 +293,10 @@ func TestOnTxBeginErrorRollsBackEveryOperation(t *testing.T) { type stubProvider struct{ security.SecurityProvider } +func (stubProvider) GetColumnSecurity(context.Context, int, string, string) ([]security.ColumnSecurity, error) { + return nil, nil +} + func TestSecurityHooksStampTxSettingsOnEveryTransaction(t *testing.T) { h, mock, ctx := newTxHarness(t) list, err := security.NewSecurityList(stubProvider{}) @@ -304,6 +308,7 @@ func TestSecurityHooksStampTxSettingsOnEveryTransaction(t *testing.T) { }) RegisterSecurityHooks(h, list) 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. cols := []string{"id", "name"} diff --git a/pkg/resolvespec/ops_tx_test.go b/pkg/resolvespec/ops_tx_test.go index b5da745..456cc4f 100644 --- a/pkg/resolvespec/ops_tx_test.go +++ b/pkg/resolvespec/ops_tx_test.go @@ -421,3 +421,65 @@ func TestAfterDeleteErrorRollsBackDelete(t *testing.T) { 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) + } + }) +} diff --git a/pkg/resolvespec/security_hooks.go b/pkg/resolvespec/security_hooks.go index 5db28a5..ae2d0cb 100644 --- a/pkg/resolvespec/security_hooks.go +++ b/pkg/resolvespec/security_hooks.go @@ -34,6 +34,15 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList 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 handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { 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{} { return s.ctx.Result } diff --git a/pkg/restheadspec/security_hooks.go b/pkg/restheadspec/security_hooks.go index e1c18f8..ea722f5 100644 --- a/pkg/restheadspec/security_hooks.go +++ b/pkg/restheadspec/security_hooks.go @@ -33,6 +33,15 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList 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 handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { secCtx := newSecurityContext(hookCtx) @@ -120,6 +129,14 @@ func (s *securityContext) SetQuery(query interface{}) { 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{} { return s.ctx.Result } diff --git a/pkg/security/README.md b/pkg/security/README.md index 7f11ace..1a51538 100644 --- a/pkg/security/README.md +++ b/pkg/security/README.md @@ -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): diff --git a/pkg/security/hooks.go b/pkg/security/hooks.go index 23809d1..697d769 100644 --- a/pkg/security/hooks.go +++ b/pkg/security/hooks.go @@ -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 diff --git a/pkg/security/writesecurity.go b/pkg/security/writesecurity.go new file mode 100644 index 0000000..44f48a7 --- /dev/null +++ b/pkg/security/writesecurity.go @@ -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 +} diff --git a/pkg/security/writesecurity_test.go b/pkg/security/writesecurity_test.go new file mode 100644 index 0000000..b3f5511 --- /dev/null +++ b/pkg/security/writesecurity_test.go @@ -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) + } +} diff --git a/pkg/websocketspec/security_hooks.go b/pkg/websocketspec/security_hooks.go index 41d5c21..061aab2 100644 --- a/pkg/websocketspec/security_hooks.go +++ b/pkg/websocketspec/security_hooks.go @@ -27,6 +27,21 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList 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 handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { secCtx := newSecurityContext(hookCtx) @@ -122,6 +137,14 @@ func (s *securityContext) SetQuery(query interface{}) { 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{} { return s.ctx.Result }