Skip to content

Commit 1fc56cb

Browse files
committed
Add accessor field to authorizer rules
Expose SQLite's 4th authorizer callback argument (the innermost trigger or view that caused an authorization check) as an optional `accessor` field on authorization rules. This enables view-scoped authorization: a rule can allow reads from an underlying table only when accessed through a specific view, while blocking direct table access. For example: { action: Action.READ, table: "data", accessor: "my_view", policy: Authorization.ALLOW } This rule allows reads from "data" only when the read is driven by "my_view" (accessor = "my_view"), and blocks direct SELECT on "data" (where accessor is null). The accessor field supports the same pattern matching as other fields: plain strings for exact match, or { glob: "pattern" } for wildcards.
1 parent 407f13d commit 1fc56cb

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)