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, }) }