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
28 changes: 27 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,37 @@ db.authorizer({
| table | <code>string \| { glob: string }</code> | Table name pattern. Omit to match any table. |
| column | <code>string \| { glob: string }</code> | Column name pattern (relevant for READ/UPDATE). Omit to match any. |
| entity | <code>string \| { glob: string }</code> | Entity name (index, trigger, view, pragma, function). Omit to match any. |
| accessor | <code>string \| { glob: string }</code> | The innermost trigger or view that caused this access. See below. |
| policy | <code>number</code> | `Authorization.ALLOW`, `Authorization.DENY`, or `Authorization.IGNORE`. |

#### Accessor field

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`.

This enables **view-scoped authorization**: you can allow reads from an underlying table only when accessed through a specific view, while blocking direct access.

```javascript
db.authorizer({
rules: [
{ action: Action.SELECT, policy: Authorization.ALLOW },
// Allow reads from the view itself
{ action: Action.READ, table: "my_view", policy: Authorization.ALLOW },
// Allow reads from the underlying table ONLY when accessed via my_view
{ action: Action.READ, table: "underlying_data", accessor: "my_view", policy: Authorization.ALLOW },
],
defaultPolicy: Authorization.DENY,
});

// Works — accessed through the view
db.prepare("SELECT * FROM my_view");

// Blocked — direct access has accessor=null, which doesn't match "my_view"
db.prepare("SELECT * FROM underlying_data"); // Error: SQLITE_AUTH
```

#### Pattern matching

Pattern fields (`table`, `column`, `entity`) accept either:
Pattern fields (`table`, `column`, `entity`, `accessor`) accept either:

- A **plain string** for exact matching: `"users"`
- An **object with a `glob` key** for glob matching: `{ glob: "logs_*" }`
Expand Down
84 changes: 84 additions & 0 deletions integration-tests/tests/extensions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,90 @@ test.serial("Glob: pattern with no match denies correctly", async (t) => {
}, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" });
});

// ---- Accessor (view-scoped authorization) ----

test.serial("Accessor: allows reads through a view when accessor matches", async (t) => {
const db = t.context.db;

// Create a view over users
db.exec("CREATE TEMPORARY VIEW users_view AS SELECT id, name FROM users");

db.authorizer({
rules: [
{ action: Action.SELECT, policy: Authorization.ALLOW },
// Allow reads from the view itself
{ action: Action.READ, table: "users_view", policy: Authorization.ALLOW },
// Allow reads from users ONLY when accessed via users_view
{ action: Action.READ, table: "users", accessor: "users_view", policy: Authorization.ALLOW },
],
defaultPolicy: Authorization.DENY,
});

const stmt = db.prepare("SELECT * FROM users_view");
const rows = stmt.all();
t.is(rows.length, 2);
});

test.serial("Accessor: blocks direct table access when accessor doesn't match", async (t) => {
const db = t.context.db;

db.exec("CREATE TEMPORARY VIEW IF NOT EXISTS users_view AS SELECT id, name FROM users");

db.authorizer({
rules: [
{ action: Action.SELECT, policy: Authorization.ALLOW },
{ action: Action.READ, table: "users_view", policy: Authorization.ALLOW },
{ action: Action.READ, table: "users", accessor: "users_view", policy: Authorization.ALLOW },
],
defaultPolicy: Authorization.DENY,
});

// Direct access has accessor=null, which doesn't match "users_view"
await t.throwsAsync(async () => {
return await db.prepare("SELECT * FROM users");
}, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" });
});

test.serial("Accessor: glob pattern matching", async (t) => {
const db = t.context.db;

db.exec("CREATE TEMPORARY VIEW IF NOT EXISTS users_view AS SELECT id, name FROM users");

db.authorizer({
rules: [
{ action: Action.SELECT, policy: Authorization.ALLOW },
{ action: Action.READ, table: "users_view", policy: Authorization.ALLOW },
// Allow reads from users when accessed via any view matching the glob
{ action: Action.READ, table: "users", accessor: { glob: "*_view" }, policy: Authorization.ALLOW },
],
defaultPolicy: Authorization.DENY,
});

const stmt = db.prepare("SELECT * FROM users_view");
const rows = stmt.all();
t.is(rows.length, 2);
});

test.serial("Accessor: blocks subquery escape to underlying table", async (t) => {
const db = t.context.db;

db.exec("CREATE TEMPORARY VIEW IF NOT EXISTS users_view AS SELECT id, name FROM users");

db.authorizer({
rules: [
{ action: Action.SELECT, policy: Authorization.ALLOW },
{ action: Action.READ, table: "users_view", policy: Authorization.ALLOW },
{ action: Action.READ, table: "users", accessor: "users_view", policy: Authorization.ALLOW },
],
defaultPolicy: Authorization.DENY,
});

// Subquery directly referencing users table (accessor=null for the subquery)
await t.throwsAsync(async () => {
return await db.prepare("SELECT * FROM users_view WHERE id IN (SELECT id FROM users)");
}, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" });
});

// ---- Setup ----

const connect = async (path_opt) => {
Expand Down
Loading
Loading