From 65892c034ba4d65a953bf7b4ec5d22fd369e0592 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Wed, 15 Jul 2026 06:38:48 +0000 Subject: [PATCH] feat: implement permissions management with WASM backend and in-memory support --- backends.go | 22 +++++ context.go | 8 +- dispatch.go | 1 + native_backends.go | 13 ++- native_protocol.go | 1 + plugin.go | 1 + plugintest/backends.go | 156 +++++++++++++++++++++++++----------- plugintest/backends_test.go | 86 ++++++++++++++++++++ plugintest/plugintest.go | 21 +++-- testing.go | 4 +- wasm_backends.go | 14 ++++ wasm_imports.go | 6 ++ 12 files changed, 270 insertions(+), 63 deletions(-) create mode 100644 plugintest/backends_test.go diff --git a/backends.go b/backends.go index 89c1a55..19e0d61 100644 --- a/backends.go +++ b/backends.go @@ -88,3 +88,25 @@ type Config struct { // Get returns the value of a config key. Returns ("", false) when not set. func (c *Config) Get(key string) (string, bool) { return c.backend.Get(key) } + +// ── Permissions ─────────────────────────────────────────────────────────────── + +// PermissionBackend is the interface implemented by the WASM host runtime and +// test stubs to check the current caller's effective permissions. +type PermissionBackend interface { + Check(permission string) bool +} + +// Permissions checks the current caller's effective permissions: the same +// built-in permissions, legacy role permissions, and plugin-declared custom +// permissions evaluated by the requirePermissions route middleware, plus any +// custom permission granted to the caller's project/global role. Use it from +// route handlers to enforce authorization finer-grained than the single +// all-or-nothing scope declared on the route's manifest entry — e.g. "is the +// caller the record's owner OR do they hold time_logging.manage_all". +type Permissions struct { + backend PermissionBackend +} + +// Check reports whether the current caller holds the given permission key. +func (p *Permissions) Check(permission string) bool { return p.backend.Check(permission) } diff --git a/context.go b/context.go index a961840..6a31daa 100644 --- a/context.go +++ b/context.go @@ -12,6 +12,7 @@ type Context struct { kv *KV log *Logger cfg *Config + perm *Permissions } // routeKey uniquely identifies a registered route by HTTP method + path. @@ -51,6 +52,10 @@ func (c *Context) Log() *Logger { return c.log } // Config returns a read-only helper for plugin configuration values. func (c *Context) Config() *Config { return c.cfg } +// Permissions returns a helper for checking the current caller's effective +// permissions, including any custom permission the plugin declared. +func (c *Context) Permissions() *Permissions { return c.perm } + // RouteHandler is the function signature for HTTP route handlers. type RouteHandler func(req *Request, res *Response) @@ -60,7 +65,7 @@ type EventHandler func(evt *Event) // newContext constructs a Context backed by the provided implementations. // Called by the WASM runtime (with host-function backends) and by // [plugintest] (with in-memory backends). -func newContext(db DBBackend, kv KVBackend, log LogBackend, cfg ConfigBackend) *Context { +func newContext(db DBBackend, kv KVBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context { return &Context{ routes: make(map[routeKey]RouteHandler), events: make(map[string]EventHandler), @@ -68,6 +73,7 @@ func newContext(db DBBackend, kv KVBackend, log LogBackend, cfg ConfigBackend) * kv: &KV{backend: kv}, log: &Logger{backend: log}, cfg: &Config{backend: cfg}, + perm: &Permissions{backend: perm}, } } diff --git a/dispatch.go b/dispatch.go index 5f17695..5e77e0a 100644 --- a/dispatch.go +++ b/dispatch.go @@ -25,6 +25,7 @@ func (d *dispatcher) init() error { newWASMKVBackend(), newWASMLogBackend(), newWASMConfigBackend(), + newWASMPermissionBackend(), ) if err := d.plugin.Init(d.ctx); err != nil { return err diff --git a/native_backends.go b/native_backends.go index 8b2254c..a496cce 100644 --- a/native_backends.go +++ b/native_backends.go @@ -14,10 +14,11 @@ var errNotWASM = errors.New("plugin: this function is only available in a WASM m // ── Stubs ───────────────────────────────────────────────────────────────────── -func newWASMDBBackend() DBBackend { return &stubDBBackend{} } -func newWASMKVBackend() KVBackend { return &stubKVBackend{} } -func newWASMLogBackend() LogBackend { return &stubLogBackend{} } -func newWASMConfigBackend() ConfigBackend { return &stubConfigBackend{} } +func newWASMDBBackend() DBBackend { return &stubDBBackend{} } +func newWASMKVBackend() KVBackend { return &stubKVBackend{} } +func newWASMLogBackend() LogBackend { return &stubLogBackend{} } +func newWASMConfigBackend() ConfigBackend { return &stubConfigBackend{} } +func newWASMPermissionBackend() PermissionBackend { return &stubPermissionBackend{} } type stubDBBackend struct{} @@ -42,6 +43,10 @@ type stubConfigBackend struct{} func (b *stubConfigBackend) Get(_ string) (string, bool) { return "", false } +type stubPermissionBackend struct{} + +func (b *stubPermissionBackend) Check(_ string) bool { return false } + // EmitEvent is a no-op outside WASM. func EmitEvent(_ string, _ any) {} diff --git a/native_protocol.go b/native_protocol.go index 89376d2..9581a0a 100644 --- a/native_protocol.go +++ b/native_protocol.go @@ -6,6 +6,7 @@ import "encoding/json" // hostResponse mirrors the shape expected by the paca API host runtime when // deserialising the HandleRequest return value. +// //nolint:unused // used by dispatch.go in native builds type hostResponse struct { Status int `json:"status"` diff --git a/plugin.go b/plugin.go index b993652..22dc2f8 100644 --- a/plugin.go +++ b/plugin.go @@ -12,6 +12,7 @@ type Plugin interface { } // globalDispatcher is the singleton created by [Run]. +// //nolint:unused // used by wasm_exports.go in WASM builds var globalDispatcher *dispatcher diff --git a/plugintest/backends.go b/plugintest/backends.go index 920c06b..f879cec 100644 --- a/plugintest/backends.go +++ b/plugintest/backends.go @@ -82,7 +82,7 @@ func (db *InMemoryDB) Query(sql string, params []any) (*plugin.DBQueryResult, er } func (db *InMemoryDB) querySelect(sql string, params []any) (*plugin.DBQueryResult, error) { - tableName, whereCol, whereParam, err := parseSimpleSelect(sql) + tableName, conditions, err := parseTableAndConditions(sql) if err != nil { return nil, err } @@ -92,10 +92,12 @@ func (db *InMemoryDB) querySelect(sql string, params []any) (*plugin.DBQueryResu } result := &plugin.DBQueryResult{Columns: t.columns} - colIdx := colIndex(t.columns, whereCol) - for _, row := range t.rows { - if whereCol == "" || (colIdx >= 0 && colIdx < len(row) && fmt.Sprint(row[colIdx]) == fmt.Sprint(params[whereParam-1])) { + matches, err := matchesConditions(t.columns, row, conditions, params) + if err != nil { + return nil, err + } + if matches { rowCopy := make([]any, len(row)) copy(rowCopy, row) result.Rows = append(result.Rows, rowCopy) @@ -195,7 +197,7 @@ func (db *InMemoryDB) insertRow(sql string, params []any) (*table, []any, error) } func (db *InMemoryDB) execDelete(sql string, params []any) (int64, error) { - tableName, whereCol, whereParam, err := parseSimpleWhere(sql) + tableName, conditions, err := parseTableAndConditions(sql) if err != nil { return 0, err } @@ -203,16 +205,14 @@ func (db *InMemoryDB) execDelete(sql string, params []any) (int64, error) { if !ok { return 0, nil } - if whereCol == "" { - count := int64(len(t.rows)) - t.rows = nil - return count, nil - } - colIdx := colIndex(t.columns, whereCol) var kept [][]any var deleted int64 for _, row := range t.rows { - if colIdx >= 0 && colIdx < len(row) && fmt.Sprint(row[colIdx]) == fmt.Sprint(params[whereParam-1]) { + matches, matchErr := matchesConditions(t.columns, row, conditions, params) + if matchErr != nil { + return 0, matchErr + } + if matches { deleted++ } else { kept = append(kept, row) @@ -235,7 +235,7 @@ func (db *InMemoryDB) execUpdate(sql string, params []any) (int64, error) { updated := int64(0) for _, row := range t.rows { - matches, matchErr := rowMatchesConditions(t.columns, row, conditions, params) + matches, matchErr := matchesConditions(t.columns, row, conditions, params) if matchErr != nil { return 0, matchErr } @@ -262,47 +262,39 @@ type colParam struct { paramIdx int } -// parseSimpleSelect extracts table name and optional WHERE col = $N. -// Only supports: SELECT ... FROM [WHERE = $N] -func parseSimpleSelect(sql string) (table, whereCol string, whereParam int, err error) { - upper := strings.ToUpper(sql) - fromIdx := strings.Index(upper, " FROM ") - if fromIdx < 0 { - return "", "", 0, fmt.Errorf("parseSimpleSelect: no FROM in %q", sql) - } - rest := strings.TrimSpace(sql[fromIdx+6:]) - whereIdx := strings.Index(strings.ToUpper(rest), " WHERE ") - if whereIdx < 0 { - return strings.Fields(rest)[0], "", 0, nil - } - table = strings.Fields(rest[:whereIdx])[0] - wherePart := strings.TrimSpace(rest[whereIdx+7:]) - col, param, e := parseColParam(wherePart) - return table, col, param, e +// condition is a single WHERE-clause predicate: either "= $N" or +// "IS [NOT] NULL". Shared by SELECT, UPDATE, and DELETE so a multi-part +// WHERE (e.g. "id = $1 AND project_id = $2 AND deleted_at IS NULL") is +// evaluated in full instead of silently checking only its first clause. +type condition struct { + column string + paramIdx int // 1-based index into params; 0 for isNull/isNotNull conditions. + isNull bool + isNotNull bool } -// parseSimpleWhere extracts table name and WHERE condition for DELETE. -func parseSimpleWhere(sql string) (tableName, whereCol string, whereParam int, err error) { +// parseTableAndConditions extracts the table name and WHERE conditions from a +// "... FROM
[WHERE [AND ...]]" statement — the shape +// shared by SELECT and DELETE FROM. +func parseTableAndConditions(sql string) (table string, conditions []condition, err error) { upper := strings.ToUpper(sql) - // DELETE FROM
WHERE = $N fromIdx := strings.Index(upper, " FROM ") if fromIdx < 0 { - return "", "", 0, fmt.Errorf("parseSimpleWhere: no FROM in %q", sql) + return "", nil, fmt.Errorf("parseTableAndConditions: no FROM in %q", sql) } rest := strings.TrimSpace(sql[fromIdx+6:]) whereIdx := strings.Index(strings.ToUpper(rest), " WHERE ") if whereIdx < 0 { - return strings.Fields(rest)[0], "", 0, nil + return strings.Fields(rest)[0], nil, nil } - tableName = strings.Fields(rest[:whereIdx])[0] - wherePart := strings.TrimSpace(rest[whereIdx+7:]) - col, wp, e := parseColParam(wherePart) - return tableName, col, wp, e + table = strings.Fields(rest[:whereIdx])[0] + conditions, err = parseConditions(strings.TrimSpace(rest[whereIdx+7:])) + return table, conditions, err } // parseSimpleUpdate extracts the table name, SET assignments, and WHERE conditions. -// Only supports: UPDATE
SET = $N[, = $N ...] WHERE = $N [AND = $N ...] -func parseSimpleUpdate(sql string) (tableName string, assignments []colParam, conditions []colParam, err error) { +// Only supports: UPDATE
SET = $N[, = $N ...] WHERE [AND ...] +func parseSimpleUpdate(sql string) (tableName string, assignments []colParam, conditions []condition, err error) { trimmed := strings.TrimSpace(sql) upper := strings.ToUpper(trimmed) if !strings.HasPrefix(upper, "UPDATE ") { @@ -343,27 +335,58 @@ func parseAssignments(s string) ([]colParam, error) { return assignments, nil } -func parseConditions(s string) ([]colParam, error) { +// parseConditions splits a WHERE clause body on "AND" and parses each part as +// either "= $N" or "IS [NOT] NULL". +func parseConditions(s string) ([]condition, error) { parts := strings.Split(s, "AND") - conditions := make([]colParam, 0, len(parts)) + conditions := make([]condition, 0, len(parts)) for _, part := range parts { - col, paramIdx, err := parseColParam(strings.TrimSpace(part)) + c, err := parseCondition(strings.TrimSpace(part)) if err != nil { return nil, err } - conditions = append(conditions, colParam{column: col, paramIdx: paramIdx}) + conditions = append(conditions, c) } return conditions, nil } -func rowMatchesConditions(columns []string, row []any, conditions []colParam, params []any) (bool, error) { +func parseCondition(s string) (condition, error) { + fields := strings.Fields(s) + if len(fields) == 3 && strings.EqualFold(fields[1], "IS") && strings.EqualFold(fields[2], "NULL") { + return condition{column: fields[0], isNull: true}, nil + } + if len(fields) == 4 && strings.EqualFold(fields[1], "IS") && strings.EqualFold(fields[2], "NOT") && strings.EqualFold(fields[3], "NULL") { + return condition{column: fields[0], isNotNull: true}, nil + } + col, paramIdx, err := parseColParam(s) + if err != nil { + return condition{}, err + } + return condition{column: col, paramIdx: paramIdx}, nil +} + +// matchesConditions reports whether row satisfies every condition (vacuously +// true when conditions is empty, i.e. no WHERE clause at all). +func matchesConditions(columns []string, row []any, conditions []condition, params []any) (bool, error) { for _, condition := range conditions { colIdx := colIndex(columns, condition.column) if colIdx < 0 || colIdx >= len(row) { - return false, fmt.Errorf("rowMatchesConditions: column %q not found", condition.column) + return false, fmt.Errorf("matchesConditions: column %q not found", condition.column) + } + if condition.isNull { + if row[colIdx] != nil { + return false, nil + } + continue + } + if condition.isNotNull { + if row[colIdx] == nil { + return false, nil + } + continue } if condition.paramIdx <= 0 || condition.paramIdx > len(params) { - return false, fmt.Errorf("rowMatchesConditions: param index %d out of range", condition.paramIdx) + return false, fmt.Errorf("matchesConditions: param index %d out of range", condition.paramIdx) } if fmt.Sprint(row[colIdx]) != fmt.Sprint(params[condition.paramIdx-1]) { return false, nil @@ -559,3 +582,40 @@ func (c *InMemoryConfig) Get(key string) (string, bool) { v, ok := c.data[key] return v, ok } + +// ── FakePermissions ─────────────────────────────────────────────────────────── + +// FakePermissions is an in-memory plugin.PermissionBackend for tests. All +// permissions are denied by default; call Grant to simulate a caller who +// holds a given permission (typically a plugin-declared custom permission, +// e.g. "time_logging.manage_all"). +type FakePermissions struct { + mu sync.Mutex + granted map[string]bool +} + +func newFakePermissions() *FakePermissions { + return &FakePermissions{granted: make(map[string]bool)} +} + +// Grant marks permission as held by the current caller for the rest of the test. +func (p *FakePermissions) Grant(permission string) { + p.mu.Lock() + defer p.mu.Unlock() + p.granted[permission] = true +} + +// Revoke undoes a prior Grant, e.g. to assert on a permission being lost +// partway through a test. +func (p *FakePermissions) Revoke(permission string) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.granted, permission) +} + +// Check implements plugin.PermissionBackend. +func (p *FakePermissions) Check(permission string) bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.granted[permission] +} diff --git a/plugintest/backends_test.go b/plugintest/backends_test.go new file mode 100644 index 0000000..021c6dc --- /dev/null +++ b/plugintest/backends_test.go @@ -0,0 +1,86 @@ +package plugintest + +import "testing" + +// These tests cover InMemoryDB's WHERE-clause evaluation directly (SELECT, +// UPDATE, DELETE), since a regression here silently produces false negatives +// in every plugin's own test suite rather than a visible failure. + +func seedWidgets(db *InMemoryDB) { + db.SeedRows("widgets", + []string{"id", "owner_id", "deleted_at"}, + [][]any{ + {"1", "alice", nil}, + {"2", "alice", "2026-01-01"}, + {"3", "bob", nil}, + }, + ) +} + +func TestInMemoryDB_Select_MultiConditionWhere(t *testing.T) { + db := newInMemoryDB() + seedWidgets(db) + + result, err := db.Query( + "SELECT id FROM widgets WHERE owner_id = $1 AND deleted_at IS NULL", + []any{"alice"}, + ) + if err != nil { + t.Fatal(err) + } + if len(result.Rows) != 1 { + t.Fatalf("expected 1 row (alice's non-deleted widget), got %+v", result.Rows) + } + if result.Rows[0][0] != "1" { + t.Fatalf("expected widget 1, got %+v", result.Rows[0]) + } +} + +func TestInMemoryDB_Select_IsNotNull(t *testing.T) { + db := newInMemoryDB() + seedWidgets(db) + + result, err := db.Query("SELECT id FROM widgets WHERE deleted_at IS NOT NULL", nil) + if err != nil { + t.Fatal(err) + } + if len(result.Rows) != 1 || result.Rows[0][0] != "2" { + t.Fatalf("expected only widget 2, got %+v", result.Rows) + } +} + +func TestInMemoryDB_Delete_MultiConditionWhere(t *testing.T) { + db := newInMemoryDB() + seedWidgets(db) + + // Only widget 1 matches both conditions; widget 3 shares owner_id="bob" + // but not id=1, and must survive. + affected, err := db.Exec("DELETE FROM widgets WHERE id = $1 AND owner_id = $2", []any{"1", "alice"}) + if err != nil { + t.Fatal(err) + } + if affected != 1 { + t.Fatalf("expected 1 row deleted, got %d", affected) + } + remaining := db.AllRows("widgets") + if len(remaining) != 2 { + t.Fatalf("expected 2 rows remaining, got %+v", remaining) + } +} + +func TestInMemoryDB_Delete_DoesNotMatchOtherRowsSharingOneCondition(t *testing.T) { + db := newInMemoryDB() + seedWidgets(db) + + // id=3 belongs to bob, not alice — must not be deleted even though it + // shares no id with widget 1/2. This guards against a WHERE evaluator + // that only checks the first condition (owner_id) and ignores the rest. + _, err := db.Exec("DELETE FROM widgets WHERE id = $1 AND owner_id = $2", []any{"3", "alice"}) + if err != nil { + t.Fatal(err) + } + remaining := db.AllRows("widgets") + if len(remaining) != 3 { + t.Fatalf("expected no rows deleted (id/owner mismatch), got %+v", remaining) + } +} diff --git a/plugintest/plugintest.go b/plugintest/plugintest.go index 89d80d2..c9afbc7 100644 --- a/plugintest/plugintest.go +++ b/plugintest/plugintest.go @@ -44,6 +44,9 @@ type Context struct { Log *CapturingLogger // Config is an in-memory config store. Config *InMemoryConfig + // Permissions controls which permissions plugin.Context.Permissions().Check + // reports as held by the caller. All permissions are denied by default. + Permissions *FakePermissions pluginCtx *plugin.Context // routes registered by Plugin.Init (via the plugin.Context) @@ -59,18 +62,20 @@ func NewContext(t testing.TB) *Context { kv := newInMemoryKV() log := newCapturingLogger() cfg := newInMemoryConfig() + perm := newFakePermissions() - pCtx := plugin.NewContextForTest(db, kv, log, cfg) + pCtx := plugin.NewContextForTest(db, kv, log, cfg, perm) d := &testDispatcher{pluginCtx: pCtx} tc := &Context{ - DB: db, - KV: kv, - Log: log, - Config: cfg, - pluginCtx: pCtx, - dispatcher: d, - t: t, + DB: db, + KV: kv, + Log: log, + Config: cfg, + Permissions: perm, + pluginCtx: pCtx, + dispatcher: d, + t: t, } t.Cleanup(func() { _ = tc }) return tc diff --git a/testing.go b/testing.go index ac4e9cc..df2e8a6 100644 --- a/testing.go +++ b/testing.go @@ -5,8 +5,8 @@ package plugin // // Production code should use [Run], which installs the WASM host-function // backends automatically. This function is safe to call from any build target. -func NewContextForTest(db DBBackend, kv KVBackend, log LogBackend, cfg ConfigBackend) *Context { - return newContext(db, kv, log, cfg) +func NewContextForTest(db DBBackend, kv KVBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context { + return newContext(db, kv, log, cfg, perm) } // DispatchRoute calls the handler registered at method+path in ctx and writes diff --git a/wasm_backends.go b/wasm_backends.go index e56df96..04fd455 100644 --- a/wasm_backends.go +++ b/wasm_backends.go @@ -168,6 +168,20 @@ func (b *wasmConfigBackend) Get(key string) (string, bool) { return string(wasmSlice(valPtr, valLen)), true } +// ── WASM Permission backend ─────────────────────────────────────────────────── + +type wasmPermissionBackend struct{} + +func newWASMPermissionBackend() PermissionBackend { return &wasmPermissionBackend{} } + +func (b *wasmPermissionBackend) Check(permission string) bool { + permBytes := []byte(permission) + if len(permBytes) == 0 { + return false + } + return hostPermissionCheck(int64(ptrOf(permBytes)), int64(len(permBytes))) != 0 +} + // ── EmitEvent ───────────────────────────────────────────────────────────────── // EmitEvent publishes an event to the paca event bus from WASM. diff --git a/wasm_imports.go b/wasm_imports.go index a84a8d4..69cd153 100644 --- a/wasm_imports.go +++ b/wasm_imports.go @@ -66,3 +66,9 @@ func hostActivityRecord(payloadPtr, payloadLen int64) int32 //go:wasmimport paca config_get //go:noescape func hostConfigGet(keyPtr, keyLen, valuePtrPtr, valueLenPtr int64) + +// paca.permission_check(permissionPtr i64, permissionLen i64) -> ok i32 +// +//go:wasmimport paca permission_check +//go:noescape +func hostPermissionCheck(permissionPtr, permissionLen int64) int32