Skip to content

Commit 8d2a263

Browse files
authored
Add accessor field to authorizer rules (#216)
## Description Adds an optional `accessor` field to authorization rules, exposing SQLite's 4th authorizer callback argument — the innermost trigger or view that caused the authorization check. When querying through a view, SQLite's authorizer fires READ callbacks with the underlying table name, not the view name. Without the accessor field, the only way to allow view-based access while blocking direct table access is to materialize the view into a temporary table. With the accessor field, rules can scope reads by the view context: ```javascript { action: Action.READ, table: "data", accessor: "my_view", policy: Authorization.ALLOW } ``` This allows `SELECT * FROM my_view` (accessor = "my_view") but blocks `SELECT * FROM data` directly (accessor = null). The `accessor` field supports exact strings and `{ glob: "pattern" }`, same as other pattern fields. The `libsql` Rust crate already exposes this as `AuthContext.accessor: Option<&str>` — this PR wires it through to the JS rule-matching system. ## How was this change tested? - [x] Automated test (unit, integration, etc.) - [ ] Manual test (provide reproducible testing steps below) Four integration tests added covering view-scoped access, direct access blocking, glob pattern matching on accessor, and subquery escape prevention.
2 parents 407f13d + 1fc56cb commit 8d2a263

File tree

4 files changed

+180
-2
lines changed

4 files changed

+180
-2
lines changed

docs/api.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,37 @@ db.authorizer({
134134
| table | <code>string \| { glob: string }</code> | Table name pattern. Omit to match any table. |
135135
| column | <code>string \| { glob: string }</code> | Column name pattern (relevant for READ/UPDATE). Omit to match any. |
136136
| entity | <code>string \| { glob: string }</code> | Entity name (index, trigger, view, pragma, function). Omit to match any. |
137+
| accessor | <code>string \| { glob: string }</code> | The innermost trigger or view that caused this access. See below. |
137138
| policy | <code>number</code> | `Authorization.ALLOW`, `Authorization.DENY`, or `Authorization.IGNORE`. |
138139

140+
#### Accessor field
141+
142+
The `accessor` field corresponds to the 4th argument of SQLite's C-level authorizer callback. When a READ occurs because a view is being expanded, SQLite sets this to the view name. For direct table access, it is `null`.
143+
144+
This enables **view-scoped authorization**: you can allow reads from an underlying table only when accessed through a specific view, while blocking direct access.
145+
146+
```javascript
147+
db.authorizer({
148+
rules: [
149+
{ action: Action.SELECT, policy: Authorization.ALLOW },
150+
// Allow reads from the view itself
151+
{ action: Action.READ, table: "my_view", policy: Authorization.ALLOW },
152+
// Allow reads from the underlying table ONLY when accessed via my_view
153+
{ action: Action.READ, table: "underlying_data", accessor: "my_view", policy: Authorization.ALLOW },
154+
],
155+
defaultPolicy: Authorization.DENY,
156+
});
157+
158+
// Works — accessed through the view
159+
db.prepare("SELECT * FROM my_view");
160+
161+
// Blocked — direct access has accessor=null, which doesn't match "my_view"
162+
db.prepare("SELECT * FROM underlying_data"); // Error: SQLITE_AUTH
163+
```
164+
139165
#### Pattern matching
140166

141-
Pattern fields (`table`, `column`, `entity`) accept either:
167+
Pattern fields (`table`, `column`, `entity`, `accessor`) accept either:
142168

143169
- A **plain string** for exact matching: `"users"`
144170
- An **object with a `glob` key** for glob matching: `{ glob: "logs_*" }`

integration-tests/tests/extensions.test.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,90 @@ test.serial("Glob: pattern with no match denies correctly", async (t) => {
464464
}, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" });
465465
});
466466

467+
// ---- Accessor (view-scoped authorization) ----
468+
469+
test.serial("Accessor: allows reads through a view when accessor matches", async (t) => {
470+
const db = t.context.db;
471+
472+
// Create a view over users
473+
db.exec("CREATE TEMPORARY VIEW users_view AS SELECT id, name FROM users");
474+
475+
db.authorizer({
476+
rules: [
477+
{ action: Action.SELECT, policy: Authorization.ALLOW },
478+
// Allow reads from the view itself
479+
{ action: Action.READ, table: "users_view", policy: Authorization.ALLOW },
480+
// Allow reads from users ONLY when accessed via users_view
481+
{ action: Action.READ, table: "users", accessor: "users_view", policy: Authorization.ALLOW },
482+
],
483+
defaultPolicy: Authorization.DENY,
484+
});
485+
486+
const stmt = db.prepare("SELECT * FROM users_view");
487+
const rows = stmt.all();
488+
t.is(rows.length, 2);
489+
});
490+
491+
test.serial("Accessor: blocks direct table access when accessor doesn't match", async (t) => {
492+
const db = t.context.db;
493+
494+
db.exec("CREATE TEMPORARY VIEW IF NOT EXISTS users_view AS SELECT id, name FROM users");
495+
496+
db.authorizer({
497+
rules: [
498+
{ action: Action.SELECT, policy: Authorization.ALLOW },
499+
{ action: Action.READ, table: "users_view", policy: Authorization.ALLOW },
500+
{ action: Action.READ, table: "users", accessor: "users_view", policy: Authorization.ALLOW },
501+
],
502+
defaultPolicy: Authorization.DENY,
503+
});
504+
505+
// Direct access has accessor=null, which doesn't match "users_view"
506+
await t.throwsAsync(async () => {
507+
return await db.prepare("SELECT * FROM users");
508+
}, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" });
509+
});
510+
511+
test.serial("Accessor: glob pattern matching", async (t) => {
512+
const db = t.context.db;
513+
514+
db.exec("CREATE TEMPORARY VIEW IF NOT EXISTS users_view AS SELECT id, name FROM users");
515+
516+
db.authorizer({
517+
rules: [
518+
{ action: Action.SELECT, policy: Authorization.ALLOW },
519+
{ action: Action.READ, table: "users_view", policy: Authorization.ALLOW },
520+
// Allow reads from users when accessed via any view matching the glob
521+
{ action: Action.READ, table: "users", accessor: { glob: "*_view" }, policy: Authorization.ALLOW },
522+
],
523+
defaultPolicy: Authorization.DENY,
524+
});
525+
526+
const stmt = db.prepare("SELECT * FROM users_view");
527+
const rows = stmt.all();
528+
t.is(rows.length, 2);
529+
});
530+
531+
test.serial("Accessor: blocks subquery escape to underlying table", async (t) => {
532+
const db = t.context.db;
533+
534+
db.exec("CREATE TEMPORARY VIEW IF NOT EXISTS users_view AS SELECT id, name FROM users");
535+
536+
db.authorizer({
537+
rules: [
538+
{ action: Action.SELECT, policy: Authorization.ALLOW },
539+
{ action: Action.READ, table: "users_view", policy: Authorization.ALLOW },
540+
{ action: Action.READ, table: "users", accessor: "users_view", policy: Authorization.ALLOW },
541+
],
542+
defaultPolicy: Authorization.DENY,
543+
});
544+
545+
// Subquery directly referencing users table (accessor=null for the subquery)
546+
await t.throwsAsync(async () => {
547+
return await db.prepare("SELECT * FROM users_view WHERE id IN (SELECT id FROM users)");
548+
}, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" });
549+
});
550+
467551
// ---- Setup ----
468552

469553
const connect = async (path_opt) => {

0 commit comments

Comments
 (0)