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