Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions backends.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
8 changes: 7 additions & 1 deletion context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -60,14 +65,15 @@ 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),
db: &DB{backend: db},
kv: &KV{backend: kv},
log: &Logger{backend: log},
cfg: &Config{backend: cfg},
perm: &Permissions{backend: perm},
}
}

Expand Down
1 change: 1 addition & 0 deletions dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func (d *dispatcher) init() error {
newWASMKVBackend(),
newWASMLogBackend(),
newWASMConfigBackend(),
newWASMPermissionBackend(),
)
if err := d.plugin.Init(d.ctx); err != nil {
return err
Expand Down
13 changes: 9 additions & 4 deletions native_backends.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand All @@ -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) {}

Expand Down
1 change: 1 addition & 0 deletions native_protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
156 changes: 108 additions & 48 deletions plugintest/backends.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -195,24 +197,22 @@ 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
}
t, ok := db.tables[strings.ToLower(tableName)]
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)
Expand All @@ -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
}
Expand All @@ -262,47 +262,39 @@ type colParam struct {
paramIdx int
}

// parseSimpleSelect extracts table name and optional WHERE col = $N.
// Only supports: SELECT ... FROM <table> [WHERE <col> = $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 "<col> = $N" or
// "<col> 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 <table> [WHERE <cond> [AND <cond> ...]]" 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 <table> WHERE <col> = $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 <table> SET <col> = $N[, <col> = $N ...] WHERE <col> = $N [AND <col> = $N ...]
func parseSimpleUpdate(sql string) (tableName string, assignments []colParam, conditions []colParam, err error) {
// Only supports: UPDATE <table> SET <col> = $N[, <col> = $N ...] WHERE <cond> [AND <cond> ...]
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 ") {
Expand Down Expand Up @@ -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 "<col> = $N" or "<col> 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
Expand Down Expand Up @@ -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]
}
Loading
Loading