diff --git a/docs/api.md b/docs/api.md
index 0d203b4..026e0f3 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -134,11 +134,37 @@ db.authorizer({
| table | string \| { glob: string } | Table name pattern. Omit to match any table. |
| column | string \| { glob: string } | Column name pattern (relevant for READ/UPDATE). Omit to match any. |
| entity | string \| { glob: string } | Entity name (index, trigger, view, pragma, function). Omit to match any. |
+| accessor | string \| { glob: string } | The innermost trigger or view that caused this access. See below. |
| policy | number | `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_*" }`
diff --git a/integration-tests/tests/extensions.test.js b/integration-tests/tests/extensions.test.js
index 1ff1010..09d8265 100644
--- a/integration-tests/tests/extensions.test.js
+++ b/integration-tests/tests/extensions.test.js
@@ -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) => {
diff --git a/src/auth.rs b/src/auth.rs
index 4cbdd57..ff73028 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -37,6 +37,11 @@ pub struct ActionInfo<'a> {
pub table_name: Option<&'a str>,
pub column_name: Option<&'a str>,
pub entity_name: Option<&'a str>,
+ /// The innermost trigger or view that caused this authorization check.
+ /// Populated from SQLite's 4th authorizer callback argument (arg4).
+ /// For example, when a READ occurs because a view is being expanded,
+ /// this contains the view name.
+ pub accessor: Option<&'a str>,
}
pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a> {
@@ -46,6 +51,7 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: None,
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::CreateIndex {
index_name,
@@ -55,12 +61,14 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: None,
entity_name: Some(index_name),
+ accessor: None,
},
AuthAction::CreateTable { table_name } => ActionInfo {
code: SQLITE_CREATE_TABLE,
table_name: Some(table_name),
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::CreateTempIndex {
index_name,
@@ -70,12 +78,14 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: None,
entity_name: Some(index_name),
+ accessor: None,
},
AuthAction::CreateTempTable { table_name } => ActionInfo {
code: SQLITE_CREATE_TEMP_TABLE,
table_name: Some(table_name),
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::CreateTempTrigger {
trigger_name,
@@ -85,12 +95,14 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: None,
entity_name: Some(trigger_name),
+ accessor: None,
},
AuthAction::CreateTempView { view_name } => ActionInfo {
code: SQLITE_CREATE_TEMP_VIEW,
table_name: None,
column_name: None,
entity_name: Some(view_name),
+ accessor: None,
},
AuthAction::CreateTrigger {
trigger_name,
@@ -100,18 +112,21 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: None,
entity_name: Some(trigger_name),
+ accessor: None,
},
AuthAction::CreateView { view_name } => ActionInfo {
code: SQLITE_CREATE_VIEW,
table_name: None,
column_name: None,
entity_name: Some(view_name),
+ accessor: None,
},
AuthAction::Delete { table_name } => ActionInfo {
code: SQLITE_DELETE,
table_name: Some(table_name),
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::DropIndex {
index_name,
@@ -121,12 +136,14 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: None,
entity_name: Some(index_name),
+ accessor: None,
},
AuthAction::DropTable { table_name } => ActionInfo {
code: SQLITE_DROP_TABLE,
table_name: Some(table_name),
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::DropTempIndex {
index_name,
@@ -136,12 +153,14 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: None,
entity_name: Some(index_name),
+ accessor: None,
},
AuthAction::DropTempTable { table_name } => ActionInfo {
code: SQLITE_DROP_TEMP_TABLE,
table_name: Some(table_name),
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::DropTempTrigger {
trigger_name,
@@ -151,12 +170,14 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: None,
entity_name: Some(trigger_name),
+ accessor: None,
},
AuthAction::DropTempView { view_name } => ActionInfo {
code: SQLITE_DROP_TEMP_VIEW,
table_name: None,
column_name: None,
entity_name: Some(view_name),
+ accessor: None,
},
AuthAction::DropTrigger {
trigger_name,
@@ -166,24 +187,28 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: None,
entity_name: Some(trigger_name),
+ accessor: None,
},
AuthAction::DropView { view_name } => ActionInfo {
code: SQLITE_DROP_VIEW,
table_name: None,
column_name: None,
entity_name: Some(view_name),
+ accessor: None,
},
AuthAction::Insert { table_name } => ActionInfo {
code: SQLITE_INSERT,
table_name: Some(table_name),
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::Pragma { pragma_name, .. } => ActionInfo {
code: SQLITE_PRAGMA,
table_name: None,
column_name: None,
entity_name: Some(pragma_name),
+ accessor: None,
},
AuthAction::Read {
table_name,
@@ -193,18 +218,21 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: Some(column_name),
entity_name: None,
+ accessor: None,
},
AuthAction::Select => ActionInfo {
code: SQLITE_SELECT,
table_name: None,
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::Transaction { .. } => ActionInfo {
code: SQLITE_TRANSACTION,
table_name: None,
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::Update {
table_name,
@@ -214,36 +242,42 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: Some(column_name),
entity_name: None,
+ accessor: None,
},
AuthAction::Attach { filename } => ActionInfo {
code: SQLITE_ATTACH,
table_name: None,
column_name: None,
entity_name: Some(filename),
+ accessor: None,
},
AuthAction::Detach { database_name } => ActionInfo {
code: SQLITE_DETACH,
table_name: None,
column_name: None,
entity_name: Some(database_name),
+ accessor: None,
},
AuthAction::AlterTable { table_name, .. } => ActionInfo {
code: SQLITE_ALTER_TABLE,
table_name: Some(table_name),
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::Reindex { index_name } => ActionInfo {
code: SQLITE_REINDEX,
table_name: None,
column_name: None,
entity_name: Some(index_name),
+ accessor: None,
},
AuthAction::Analyze { table_name } => ActionInfo {
code: SQLITE_ANALYZE,
table_name: Some(table_name),
column_name: None,
entity_name: None,
+ accessor: None,
},
AuthAction::CreateVtable {
table_name,
@@ -253,6 +287,7 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: None,
entity_name: Some(module_name),
+ accessor: None,
},
AuthAction::DropVtable {
table_name,
@@ -262,24 +297,28 @@ pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a>
table_name: Some(table_name),
column_name: None,
entity_name: Some(module_name),
+ accessor: None,
},
AuthAction::Function { function_name } => ActionInfo {
code: SQLITE_FUNCTION,
table_name: None,
column_name: None,
entity_name: Some(function_name),
+ accessor: None,
},
AuthAction::Savepoint { savepoint_name, .. } => ActionInfo {
code: SQLITE_SAVEPOINT,
table_name: None,
column_name: None,
entity_name: Some(savepoint_name),
+ accessor: None,
},
AuthAction::Recursive => ActionInfo {
code: SQLITE_RECURSIVE,
table_name: None,
column_name: None,
entity_name: None,
+ accessor: None,
},
}
}
@@ -294,6 +333,11 @@ pub struct AuthRule {
pub column: Option,
/// Generic entity name matcher for index/trigger/view/pragma/function names.
pub entity: Option,
+ /// Matcher for the accessor (the innermost trigger or view that caused
+ /// this authorization check). This is SQLite's 4th authorizer callback
+ /// argument. When set, the rule only matches if the accessor matches.
+ /// When None, the rule matches regardless of accessor value.
+ pub accessor: Option,
/// The authorization to return if this rule matches.
pub authorization: libsql::Authorization,
}
@@ -337,6 +381,17 @@ impl AuthRule {
None => return false,
}
}
+ // Check accessor pattern
+ if let Some(ref pat) = self.accessor {
+ match info.accessor {
+ Some(name) => {
+ if !pat.matches(name) {
+ return false;
+ }
+ }
+ None => return false,
+ }
+ }
true
}
}
@@ -352,7 +407,8 @@ impl Authorizer {
}
pub fn authorize(&self, ctx: &libsql::AuthContext) -> libsql::Authorization {
- let info = extract_action_info(&ctx.action);
+ let mut info = extract_action_info(&ctx.action);
+ info.accessor = ctx.accessor;
for rule in &self.rules {
if rule.matches(&info) {
trace!(
@@ -429,6 +485,7 @@ impl AuthorizerBuilder {
table: Some(PatternMatcher::Exact(table.clone())),
column: None,
entity: None,
+ accessor: None,
authorization: libsql::Authorization::Deny,
});
}
@@ -440,6 +497,7 @@ impl AuthorizerBuilder {
table: Some(PatternMatcher::Exact(table.clone())),
column: None,
entity: None,
+ accessor: None,
authorization: libsql::Authorization::Allow,
});
}
@@ -450,6 +508,7 @@ impl AuthorizerBuilder {
table: None,
column: None,
entity: None,
+ accessor: None,
authorization: libsql::Authorization::Allow,
});
diff --git a/src/lib.rs b/src/lib.rs
index 0f2142c..21f4389 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -740,6 +740,14 @@ fn parse_single_rule(rule_obj: &napi::JsObject) -> Result
None
};
+ // Parse accessor pattern
+ let accessor = if rule_obj.has_named_property("accessor")? {
+ let val: JsUnknown = rule_obj.get_named_property("accessor")?;
+ Some(parse_pattern(val, "accessor")?)
+ } else {
+ None
+ };
+
// Parse policy (required)
let policy_val: napi::JsNumber = rule_obj.get_named_property("policy")?;
let authorization = int_to_authorization(policy_val.get_int32()?)?;
@@ -749,6 +757,7 @@ fn parse_single_rule(rule_obj: &napi::JsObject) -> Result
table,
column,
entity,
+ accessor,
authorization,
})
}