From 1757ac08a11b7d042cac50790868a26778dbcd06 Mon Sep 17 00:00:00 2001 From: PThorpe92 Date: Fri, 3 Apr 2026 17:40:50 -0400 Subject: [PATCH 1/4] Redesign rust Authorizer API --- Cargo.toml | 2 + src/auth.rs | 473 +++++++++++++++++++++++++++++++++++++++++++++------- src/lib.rs | 251 +++++++++++++++++++++++----- 3 files changed, 619 insertions(+), 107 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8ddeac7c..10607c6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,10 +10,12 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] +glob-match = "0.2" libsql = { version = "0.9.30", features = ["encryption"] } napi = { version = "2", default-features = false, features = ["napi6", "tokio_rt", "async"] } napi-derive = "2" once_cell = "1.18.0" +regex = "1" serde_json = "1.0.140" tokio = { version = "1.47.1", features = [ "rt-multi-thread" ] } tracing = "0.1" diff --git a/src/auth.rs b/src/auth.rs index acc03ca2..00d254d6 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,377 @@ -use tracing::trace; +use libsql::{ + ffi::SQLITE_ALTER_TABLE, ffi::SQLITE_ANALYZE, ffi::SQLITE_ATTACH, ffi::SQLITE_COPY, + ffi::SQLITE_CREATE_INDEX, ffi::SQLITE_CREATE_TABLE, ffi::SQLITE_CREATE_TEMP_INDEX, + ffi::SQLITE_CREATE_TEMP_TABLE, ffi::SQLITE_CREATE_TEMP_TRIGGER, ffi::SQLITE_CREATE_TEMP_VIEW, + ffi::SQLITE_CREATE_TRIGGER, ffi::SQLITE_CREATE_VIEW, ffi::SQLITE_CREATE_VTABLE, + ffi::SQLITE_DELETE, ffi::SQLITE_DETACH, ffi::SQLITE_DROP_INDEX, ffi::SQLITE_DROP_TABLE, + ffi::SQLITE_DROP_TEMP_INDEX, ffi::SQLITE_DROP_TEMP_TABLE, ffi::SQLITE_DROP_TEMP_TRIGGER, + ffi::SQLITE_DROP_TEMP_VIEW, ffi::SQLITE_DROP_TRIGGER, ffi::SQLITE_DROP_VIEW, + ffi::SQLITE_DROP_VTABLE, ffi::SQLITE_FUNCTION, ffi::SQLITE_INSERT, ffi::SQLITE_PRAGMA, + ffi::SQLITE_READ, ffi::SQLITE_RECURSIVE, ffi::SQLITE_REINDEX, ffi::SQLITE_SAVEPOINT, + ffi::SQLITE_SELECT, ffi::SQLITE_TRANSACTION, ffi::SQLITE_UPDATE, AuthAction, +}; use std::collections::HashSet; +use tracing::trace; + +/// How a pattern matches against a string identifier. +pub enum PatternMatcher { + /// Case-sensitive exact match. + Exact(String), + /// Glob pattern (supports `*` and `?` wildcards). + Glob(String), + /// Compiled regular expression. + Regex(regex::Regex), +} + +impl PatternMatcher { + pub fn matches(&self, value: &str) -> bool { + match self { + PatternMatcher::Exact(s) => s == value, + PatternMatcher::Glob(pattern) => glob_match::glob_match(pattern, value), + PatternMatcher::Regex(re) => re.is_match(value), + } + } +} + +/// Action info extraction +pub struct ActionInfo<'a> { + pub code: i32, + pub table_name: Option<&'a str>, + pub column_name: Option<&'a str>, + pub entity_name: Option<&'a str>, +} + +pub fn extract_action_info<'a>(action: &'a libsql::AuthAction) -> ActionInfo<'a> { + match action { + AuthAction::Unknown { .. } => ActionInfo { + code: SQLITE_COPY, + table_name: None, + column_name: None, + entity_name: None, + }, + AuthAction::CreateIndex { + index_name, + table_name, + } => ActionInfo { + code: SQLITE_CREATE_INDEX, + table_name: Some(table_name), + column_name: None, + entity_name: Some(index_name), + }, + AuthAction::CreateTable { table_name } => ActionInfo { + code: SQLITE_CREATE_TABLE, + table_name: Some(table_name), + column_name: None, + entity_name: None, + }, + AuthAction::CreateTempIndex { + index_name, + table_name, + } => ActionInfo { + code: SQLITE_CREATE_TEMP_INDEX, + table_name: Some(table_name), + column_name: None, + entity_name: Some(index_name), + }, + AuthAction::CreateTempTable { table_name } => ActionInfo { + code: SQLITE_CREATE_TEMP_TABLE, + table_name: Some(table_name), + column_name: None, + entity_name: None, + }, + AuthAction::CreateTempTrigger { + trigger_name, + table_name, + } => ActionInfo { + code: SQLITE_CREATE_TEMP_TRIGGER, + table_name: Some(table_name), + column_name: None, + entity_name: Some(trigger_name), + }, + AuthAction::CreateTempView { view_name } => ActionInfo { + code: SQLITE_CREATE_TEMP_VIEW, + table_name: None, + column_name: None, + entity_name: Some(view_name), + }, + AuthAction::CreateTrigger { + trigger_name, + table_name, + } => ActionInfo { + code: SQLITE_CREATE_TRIGGER, + table_name: Some(table_name), + column_name: None, + entity_name: Some(trigger_name), + }, + AuthAction::CreateView { view_name } => ActionInfo { + code: SQLITE_CREATE_VIEW, + table_name: None, + column_name: None, + entity_name: Some(view_name), + }, + AuthAction::Delete { table_name } => ActionInfo { + code: SQLITE_DELETE, + table_name: Some(table_name), + column_name: None, + entity_name: None, + }, + AuthAction::DropIndex { + index_name, + table_name, + } => ActionInfo { + code: SQLITE_DROP_INDEX, + table_name: Some(table_name), + column_name: None, + entity_name: Some(index_name), + }, + AuthAction::DropTable { table_name } => ActionInfo { + code: SQLITE_DROP_TABLE, + table_name: Some(table_name), + column_name: None, + entity_name: None, + }, + AuthAction::DropTempIndex { + index_name, + table_name, + } => ActionInfo { + code: SQLITE_DROP_TEMP_INDEX, + table_name: Some(table_name), + column_name: None, + entity_name: Some(index_name), + }, + AuthAction::DropTempTable { table_name } => ActionInfo { + code: SQLITE_DROP_TEMP_TABLE, + table_name: Some(table_name), + column_name: None, + entity_name: None, + }, + AuthAction::DropTempTrigger { + trigger_name, + table_name, + } => ActionInfo { + code: SQLITE_DROP_TEMP_TRIGGER, + table_name: Some(table_name), + column_name: None, + entity_name: Some(trigger_name), + }, + AuthAction::DropTempView { view_name } => ActionInfo { + code: SQLITE_DROP_TEMP_VIEW, + table_name: None, + column_name: None, + entity_name: Some(view_name), + }, + AuthAction::DropTrigger { + trigger_name, + table_name, + } => ActionInfo { + code: SQLITE_DROP_TRIGGER, + table_name: Some(table_name), + column_name: None, + entity_name: Some(trigger_name), + }, + AuthAction::DropView { view_name } => ActionInfo { + code: SQLITE_DROP_VIEW, + table_name: None, + column_name: None, + entity_name: Some(view_name), + }, + AuthAction::Insert { table_name } => ActionInfo { + code: SQLITE_INSERT, + table_name: Some(table_name), + column_name: None, + entity_name: None, + }, + AuthAction::Pragma { pragma_name, .. } => ActionInfo { + code: SQLITE_PRAGMA, + table_name: None, + column_name: None, + entity_name: Some(pragma_name), + }, + AuthAction::Read { + table_name, + column_name, + } => ActionInfo { + code: SQLITE_READ, + table_name: Some(table_name), + column_name: Some(column_name), + entity_name: None, + }, + AuthAction::Select => ActionInfo { + code: SQLITE_SELECT, + table_name: None, + column_name: None, + entity_name: None, + }, + AuthAction::Transaction { .. } => ActionInfo { + code: SQLITE_TRANSACTION, + table_name: None, + column_name: None, + entity_name: None, + }, + AuthAction::Update { + table_name, + column_name, + } => ActionInfo { + code: SQLITE_UPDATE, + table_name: Some(table_name), + column_name: Some(column_name), + entity_name: None, + }, + AuthAction::Attach { filename } => ActionInfo { + code: SQLITE_ATTACH, + table_name: None, + column_name: None, + entity_name: Some(filename), + }, + AuthAction::Detach { database_name } => ActionInfo { + code: SQLITE_DETACH, + table_name: None, + column_name: None, + entity_name: Some(database_name), + }, + AuthAction::AlterTable { table_name, .. } => ActionInfo { + code: SQLITE_ALTER_TABLE, + table_name: Some(table_name), + column_name: None, + entity_name: None, + }, + AuthAction::Reindex { index_name } => ActionInfo { + code: SQLITE_REINDEX, + table_name: None, + column_name: None, + entity_name: Some(index_name), + }, + AuthAction::Analyze { table_name } => ActionInfo { + code: SQLITE_ANALYZE, + table_name: Some(table_name), + column_name: None, + entity_name: None, + }, + AuthAction::CreateVtable { + table_name, + module_name, + } => ActionInfo { + code: SQLITE_CREATE_VTABLE, + table_name: Some(table_name), + column_name: None, + entity_name: Some(module_name), + }, + AuthAction::DropVtable { + table_name, + module_name, + } => ActionInfo { + code: SQLITE_DROP_VTABLE, + table_name: Some(table_name), + column_name: None, + entity_name: Some(module_name), + }, + AuthAction::Function { function_name } => ActionInfo { + code: SQLITE_FUNCTION, + table_name: None, + column_name: None, + entity_name: Some(function_name), + }, + AuthAction::Savepoint { savepoint_name, .. } => ActionInfo { + code: SQLITE_SAVEPOINT, + table_name: None, + column_name: None, + entity_name: Some(savepoint_name), + }, + AuthAction::Recursive => ActionInfo { + code: SQLITE_RECURSIVE, + table_name: None, + column_name: None, + entity_name: None, + }, + } +} + +/// A single authorization rule. +pub struct AuthRule { + /// Which action codes this rule applies to (empty = match all). + pub actions: Vec, + /// Table name matcher (None = match any table). + pub table: Option, + /// Column name matcher (None = match any column). + pub column: Option, + /// Generic entity name matcher for index/trigger/view/pragma/function names. + pub entity: Option, + /// The authorization to return if this rule matches. + pub authorization: libsql::Authorization, +} + +impl AuthRule { + fn matches(&self, info: &ActionInfo) -> bool { + // Check action code + if !self.actions.is_empty() && !self.actions.contains(&info.code) { + return false; + } + // Check table pattern + if let Some(ref pat) = self.table { + match info.table_name { + Some(name) => { + if !pat.matches(name) { + return false; + } + } + None => return false, + } + } + // Check column pattern + if let Some(ref pat) = self.column { + match info.column_name { + Some(name) => { + if !pat.matches(name) { + return false; + } + } + None => return false, + } + } + // Check entity pattern + if let Some(ref pat) = self.entity { + match info.entity_name { + Some(name) => { + if !pat.matches(name) { + return false; + } + } + None => return false, + } + } + true + } +} + +pub struct Authorizer { + rules: Vec, + default: libsql::Authorization, +} + +impl Authorizer { + pub fn new(rules: Vec, default: libsql::Authorization) -> Self { + Self { rules, default } + } + + pub fn authorize(&self, ctx: &libsql::AuthContext) -> libsql::Authorization { + let info = extract_action_info(&ctx.action); + for rule in &self.rules { + if rule.matches(&info) { + trace!( + "authorize(ctx = {:?}) -> {:?} (rule match)", + ctx, + rule.authorization + ); + return rule.authorization; + } + } + trace!("authorize(ctx = {:?}) -> {:?} (default)", ctx, self.default); + self.default + } +} +/// Legacy builder (backward compatibility) pub struct AuthorizerBuilder { allow_list: HashSet, deny_list: HashSet, @@ -25,73 +395,50 @@ impl AuthorizerBuilder { self } + /// Converts the legacy allow/deny lists into an ordered rule set. + /// + /// Deny rules come first (higher priority), then allow rules. + /// Default policy is Deny (same as the old behavior). pub fn build(self) -> Authorizer { - Authorizer::new(self.allow_list, self.deny_list) - } -} + let mut rules = Vec::new(); -pub struct Authorizer { - allow_list: HashSet, - deny_list: HashSet, -} + // Table-bearing action codes (actions where the old authorizer checked tables) + let table_actions: Vec = vec![ + 1, 2, 3, 4, 5, 7, 9, 10, 11, 12, 13, 14, 16, 18, 20, 23, 26, 29, 30, + ]; -impl Authorizer { - pub fn new(allow_list: HashSet, deny_list: HashSet) -> Self { - Self { - allow_list, - deny_list, + // Deny rules first + for table in &self.deny_list { + rules.push(AuthRule { + actions: table_actions.clone(), + table: Some(PatternMatcher::Exact(table.clone())), + column: None, + entity: None, + authorization: libsql::Authorization::Deny, + }); } - } - pub fn authorize(&self, ctx: &libsql::AuthContext) -> libsql::Authorization { - use libsql::AuthAction; - let ret = match ctx.action { - AuthAction::Unknown { .. } => libsql::Authorization::Deny, - AuthAction::CreateIndex { table_name, .. } => self.authorize_table(table_name), - AuthAction::CreateTable { table_name, .. } => self.authorize_table(table_name), - AuthAction::CreateTempIndex { table_name, .. } => self.authorize_table(table_name), - AuthAction::CreateTempTable { table_name, .. } => self.authorize_table(table_name), - AuthAction::CreateTempTrigger { table_name, .. } => self.authorize_table(table_name), - AuthAction::CreateTempView { .. } => libsql::Authorization::Deny, - AuthAction::CreateTrigger { table_name, .. } => self.authorize_table(table_name), - AuthAction::CreateView { .. } => libsql::Authorization::Deny, - AuthAction::Delete { table_name, .. } => self.authorize_table(table_name), - AuthAction::DropIndex { table_name, .. } => self.authorize_table(table_name), - AuthAction::DropTable { table_name, .. } => self.authorize_table(table_name), - AuthAction::DropTempIndex { table_name, .. } => self.authorize_table(table_name), - AuthAction::DropTempTable { table_name, .. } => self.authorize_table(table_name), - AuthAction::DropTempTrigger { table_name, .. } => self.authorize_table(table_name), - AuthAction::DropTempView { .. } => libsql::Authorization::Deny, - AuthAction::DropTrigger { .. } => libsql::Authorization::Deny, - AuthAction::DropView { .. } => libsql::Authorization::Deny, - AuthAction::Insert { table_name, .. } => self.authorize_table(table_name), - AuthAction::Pragma { .. } => libsql::Authorization::Deny, - AuthAction::Read { table_name, .. } => self.authorize_table(table_name), - AuthAction::Select { .. } => libsql::Authorization::Allow, - AuthAction::Transaction { .. } => libsql::Authorization::Deny, - AuthAction::Update { table_name, .. } => self.authorize_table(table_name), - AuthAction::Attach { .. } => libsql::Authorization::Deny, - AuthAction::Detach { .. } => libsql::Authorization::Deny, - AuthAction::AlterTable { table_name, .. } => self.authorize_table(table_name), - AuthAction::Reindex { .. } => libsql::Authorization::Deny, - AuthAction::Analyze { .. } => libsql::Authorization::Deny, - AuthAction::CreateVtable { .. } => libsql::Authorization::Deny, - AuthAction::DropVtable { .. } => libsql::Authorization::Deny, - AuthAction::Function { .. } => libsql::Authorization::Deny, - AuthAction::Savepoint { .. } => libsql::Authorization::Deny, - AuthAction::Recursive { .. } => libsql::Authorization::Deny, - }; - trace!("authorize(ctx = {:?}) -> {:?}", ctx, ret); - ret - } - - fn authorize_table(&self, table: &str) -> libsql::Authorization { - if self.deny_list.contains(table) { - return libsql::Authorization::Deny; + // Then allow rules + for table in &self.allow_list { + rules.push(AuthRule { + actions: table_actions.clone(), + table: Some(PatternMatcher::Exact(table.clone())), + column: None, + entity: None, + authorization: libsql::Authorization::Allow, + }); } - if self.allow_list.contains(table) { - return libsql::Authorization::Allow; - } - libsql::Authorization::Deny + + // Legacy behavior: always allow SELECT (no table context) + rules.push(AuthRule { + actions: vec![21], // SELECT + table: None, + column: None, + entity: None, + authorization: libsql::Authorization::Allow, + }); + + // Everything else denies by default (same as old behavior) + Authorizer::new(rules, libsql::Authorization::Deny) } } diff --git a/src/lib.rs b/src/lib.rs index 61e15814..a942e1bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -422,58 +422,38 @@ impl Database { /// Sets the authorizer for the database. /// - /// # Arguments - /// - /// * `env` - The environment. - /// * `rules_obj` - The rules object. - /// - /// The `rules_obj` is a JavaScript object with the following properties: - /// - /// * `Authorization.ALLOW` - Allow access to the table. - /// * `Authorization.DENY` - Deny access to the table. - /// - /// Example: - /// - /// ```javascript - /// db.authorizer({ - /// "users": Authorization.ALLOW - /// }); - /// ``` + /// Accepts either: + /// - Legacy format: `{ [tableName: string]: 0 | 1 }` + /// - Full format: `{ rules: AuthRule[], defaultPolicy?: 0 | 1 | 2 }` + /// - `null` to remove the authorizer #[napi] - pub fn authorizer(&self, env: Env, rules_obj: napi::JsObject) -> Result<()> { + pub fn authorizer(&self, env: Env, config: JsUnknown) -> Result<()> { let conn = match &self.conn { Some(c) => c.clone(), None => { return Err(throw_database_closed_error(&env).into()); } }; - let mut builder = crate::auth::AuthorizerBuilder::new(); - let prop_names = rules_obj.get_property_names()?; - let len = prop_names.get_array_length()?; - for idx in 0..len { - let key_js: napi::JsString = prop_names.get_element::(idx)?; - let key = key_js.into_utf8()?.into_owned()?; - let value_js: napi::JsNumber = rules_obj.get_named_property(&key)?; - let value = value_js.get_int32()?; - match value { - 0 => { - // Authorization.ALLOW - builder.allow(&key); - } - 1 => { - // Authorization.DENY - builder.deny(&key); - } - _ => { - let msg = format!( - "Invalid authorization rule value '{}' for table '{}'. Expected 0 (ALLOW) or 1 (DENY).", - value, key - ); - return Err(napi::Error::from_reason(msg)); - } - } + + // null/undefined → remove authorizer + let val_type = config.get_type()?; + if val_type == ValueType::Null || val_type == ValueType::Undefined { + let none_hook: Option = None; + conn.authorizer(none_hook).map_err(Error::from)?; + return Ok(()); } - let authorizer = builder.build(); + + let obj: napi::JsObject = config.coerce_to_object()?; + + // Detect format: if "rules" property exists, use new format; otherwise legacy + let has_rules = obj.has_named_property("rules")?; + + let authorizer = if has_rules { + parse_rule_config(&obj)? + } else { + parse_legacy_config(&obj)? + }; + let auth_arc = std::sync::Arc::new(authorizer); let closure = { let auth_arc = auth_arc.clone(); @@ -619,6 +599,189 @@ impl Database { } } +fn int_to_authorization(val: i32) -> Result { + match val { + 0 => Ok(libsql::Authorization::Allow), + 1 => Ok(libsql::Authorization::Deny), + 2 => Ok(libsql::Authorization::Ignore), + _ => Err(napi::Error::from_reason(format!( + "Invalid authorization value '{val}'. Expected 0 (ALLOW), 1 (DENY), or 2 (IGNORE).", + ))), + } +} + +/// Parse legacy `{ tableName: 0|1 }` format. +fn parse_legacy_config(obj: &napi::JsObject) -> Result { + let mut builder = crate::auth::AuthorizerBuilder::new(); + let prop_names = obj.get_property_names()?; + let len = prop_names.get_array_length()?; + for idx in 0..len { + let key_js: napi::JsString = prop_names.get_element::(idx)?; + let key = key_js.into_utf8()?.into_owned()?; + let value_js: napi::JsNumber = obj.get_named_property(&key)?; + let value = value_js.get_int32()?; + match value { + 0 => { + builder.allow(&key); + } + 1 => { + builder.deny(&key); + } + _ => { + let msg = format!( + "Invalid authorization rule value '{value}' for table '{key}'. Expected 0 (ALLOW) or 1 (DENY).", + ); + return Err(napi::Error::from_reason(msg)); + } + } + } + Ok(builder.build()) +} + +/// Parse new `{ rules: [...], defaultPolicy?: number }` format. +fn parse_rule_config(obj: &napi::JsObject) -> Result { + let rules_arr: napi::JsObject = obj.get_named_property("rules")?; + let rules_len = rules_arr.get_array_length()?; + + let default_policy = if obj.has_named_property("defaultPolicy")? { + let val: napi::JsNumber = obj.get_named_property("defaultPolicy")?; + int_to_authorization(val.get_int32()?)? + } else { + libsql::Authorization::Deny + }; + + let mut rules = Vec::with_capacity(rules_len as usize); + for i in 0..rules_len { + let rule_obj: napi::JsObject = rules_arr.get_element(i)?; + rules.push(parse_single_rule(&rule_obj)?); + } + + Ok(crate::auth::Authorizer::new(rules, default_policy)) +} + +/// Parse a single rule object from the JS rules array. +fn parse_single_rule(rule_obj: &napi::JsObject) -> Result { + // Parse action(s) + let actions = if rule_obj.has_named_property("action")? { + let action_val: JsUnknown = rule_obj.get_named_property("action")?; + match action_val.get_type()? { + ValueType::Number => { + let n: napi::JsNumber = action_val.coerce_to_number()?; + vec![n.get_int32()?] + } + ValueType::Object => { + // Array of numbers + let arr: napi::JsObject = action_val.coerce_to_object()?; + let len = arr.get_array_length()?; + let mut v = Vec::with_capacity(len as usize); + for j in 0..len { + let n: napi::JsNumber = arr.get_element(j)?; + v.push(n.get_int32()?); + } + v + } + _ => { + return Err(napi::Error::from_reason( + "action must be a number or array of numbers".to_string(), + )); + } + } + } else { + vec![] + }; + + // Parse table pattern + let table = if rule_obj.has_named_property("table")? { + let val: JsUnknown = rule_obj.get_named_property("table")?; + Some(parse_pattern(val, "table")?) + } else { + None + }; + + // Parse column pattern + let column = if rule_obj.has_named_property("column")? { + let val: JsUnknown = rule_obj.get_named_property("column")?; + Some(parse_pattern(val, "column")?) + } else { + None + }; + + // Parse entity pattern + let entity = if rule_obj.has_named_property("entity")? { + let val: JsUnknown = rule_obj.get_named_property("entity")?; + Some(parse_pattern(val, "entity")?) + } 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()?)?; + + Ok(crate::auth::AuthRule { + actions, + table, + column, + entity, + authorization, + }) +} + +/// Parse a pattern value: string (exact or glob) or RegExp. +fn parse_pattern(val: JsUnknown, field_name: &str) -> Result { + match val.get_type()? { + ValueType::String => { + let s: napi::JsString = val.coerce_to_string()?; + let owned = s.into_utf8()?.into_owned()?; + // Auto-detect glob: if the string contains * or ?, treat as glob + if owned.contains('*') || owned.contains('?') { + Ok(crate::auth::PatternMatcher::Glob(owned)) + } else { + Ok(crate::auth::PatternMatcher::Exact(owned)) + } + } + ValueType::Object => { + // Check if it's a RegExp by checking for .source property + let obj: napi::JsObject = val.coerce_to_object()?; + if obj.has_named_property("source")? { + let source_js: napi::JsString = obj.get_named_property("source")?; + let source = source_js.into_utf8()?.into_owned()?; + + // Check for flags (we support 'i' for case-insensitive) + let flags_str = if obj.has_named_property("flags")? { + let flags_js: napi::JsString = obj.get_named_property("flags")?; + flags_js.into_utf8()?.into_owned()? + } else { + String::new() + }; + + let pattern = if flags_str.contains('i') { + format!("(?i){}", source) + } else { + source + }; + + let re = regex::Regex::new(&pattern).map_err(|e| { + napi::Error::from_reason(format!( + "Invalid regex pattern for {}: {}", + field_name, e + )) + })?; + Ok(crate::auth::PatternMatcher::Regex(re)) + } else { + Err(napi::Error::from_reason(format!( + "{} must be a string or RegExp", + field_name + ))) + } + } + _ => Err(napi::Error::from_reason(format!( + "{} must be a string or RegExp", + field_name + ))), + } +} + /// Result of a database sync operation. #[napi(object)] pub struct SyncResult { From c8b8e1defb868488192bd312e56e047238bf6690 Mon Sep 17 00:00:00 2001 From: PThorpe92 Date: Fri, 3 Apr 2026 17:55:51 -0400 Subject: [PATCH 2/4] Implement types and api on JS side for new Authorizer API --- .gitignore | 2 +- auth.js | 53 +- compat.js | 19 +- index.d.ts | 23 +- integration-tests/tests/extensions.test.js | 655 ++++++++++++++++++++- promise.js | 19 +- 6 files changed, 726 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index 72c0e1a8..3bfd06a5 100644 --- a/.gitignore +++ b/.gitignore @@ -111,7 +111,7 @@ dist .vscode-test # End of https://www.toptal.com/developers/gitignore/api/node - +integration-tests/*.db # Created by https://www.toptal.com/developers/gitignore/api/macos # Edit at https://www.toptal.com/developers/gitignore?templates=macos diff --git a/auth.js b/auth.js index 1766f6b4..7b116221 100644 --- a/auth.js +++ b/auth.js @@ -5,6 +5,7 @@ * @enum {number} * @property {number} ALLOW - Allow access to a resource. * @property {number} DENY - Deny access to a resource and throw an error. + * @property {number} IGNORE - For READ: return NULL instead of the column value. For other actions: equivalent to DENY. */ const Authorization = { /** @@ -18,5 +19,55 @@ const Authorization = { * @type {number} */ DENY: 1, + + /** + * For READ: return NULL instead of the actual column value. + * For other actions: equivalent to DENY. + * @type {number} + */ + IGNORE: 2, +}; + +/** + * SQLite authorizer action codes. + * + * @readonly + * @enum {number} + */ +const Action = { + CREATE_INDEX: 1, + CREATE_TABLE: 2, + CREATE_TEMP_INDEX: 3, + CREATE_TEMP_TABLE: 4, + CREATE_TEMP_TRIGGER: 5, + CREATE_TEMP_VIEW: 6, + CREATE_TRIGGER: 7, + CREATE_VIEW: 8, + DELETE: 9, + DROP_INDEX: 10, + DROP_TABLE: 11, + DROP_TEMP_INDEX: 12, + DROP_TEMP_TABLE: 13, + DROP_TEMP_TRIGGER: 14, + DROP_TEMP_VIEW: 15, + DROP_TRIGGER: 16, + DROP_VIEW: 17, + INSERT: 18, + PRAGMA: 19, + READ: 20, + SELECT: 21, + TRANSACTION: 22, + UPDATE: 23, + ATTACH: 24, + DETACH: 25, + ALTER_TABLE: 26, + REINDEX: 27, + ANALYZE: 28, + CREATE_VTABLE: 29, + DROP_VTABLE: 30, + FUNCTION: 31, + SAVEPOINT: 32, + RECURSIVE: 33, }; -module.exports = Authorization; + +module.exports = { Authorization, Action }; diff --git a/compat.js b/compat.js index 374a7418..8cac2d8c 100644 --- a/compat.js +++ b/compat.js @@ -2,7 +2,7 @@ const { Database: NativeDb, databasePrepareSync, databaseSyncSync, databaseExecSync, statementRunSync, statementGetSync, statementIterateSync, iteratorNextSync } = require("./index.js"); const SqliteError = require("./sqlite-error.js"); -const Authorization = require("./auth"); +const { Authorization, Action } = require("./auth"); function convertError(err) { // Handle errors from Rust with JSON-encoded message @@ -167,14 +167,6 @@ class Database { throw new Error("not implemented"); } - authorizer(rules) { - try { - this.db.authorizer(rules); - } catch (err) { - throw convertError(err); - } - } - loadExtension(...args) { try { this.db.loadExtension(...args); @@ -218,8 +210,12 @@ class Database { this.db.close(); } - authorizer(hook) { - this.db.authorizer(hook); + authorizer(config) { + try { + this.db.authorizer(config); + } catch (err) { + throw convertError(err); + } return this; } @@ -372,3 +368,4 @@ class Statement { module.exports = Database; module.exports.SqliteError = SqliteError; module.exports.Authorization = Authorization; +module.exports.Action = Action; diff --git a/index.d.ts b/index.d.ts index 367e13ec..dec52870 100644 --- a/index.d.ts +++ b/index.d.ts @@ -76,25 +76,12 @@ export declare class Database { /** * Sets the authorizer for the database. * - * # Arguments - * - * * `env` - The environment. - * * `rules_obj` - The rules object. - * - * The `rules_obj` is a JavaScript object with the following properties: - * - * * `Authorization.ALLOW` - Allow access to the table. - * * `Authorization.DENY` - Deny access to the table. - * - * Example: - * - * ```javascript - * db.authorizer({ - * "users": Authorization.ALLOW - * }); - * ``` + * Accepts either: + * - Legacy format: `{ [tableName: string]: 0 | 1 }` + * - Full format: `{ rules: AuthRule[], defaultPolicy?: 0 | 1 | 2 }` + * - `null` to remove the authorizer */ - authorizer(rulesObj: object): void + authorizer(config: unknown): void /** * Loads an extension into the database. * diff --git a/integration-tests/tests/extensions.test.js b/integration-tests/tests/extensions.test.js index 90fc3bb0..cfa5803c 100644 --- a/integration-tests/tests/extensions.test.js +++ b/integration-tests/tests/extensions.test.js @@ -1,5 +1,5 @@ import test from "ava"; -import { Authorization } from "libsql"; +import { Authorization, Action } from "libsql"; test.serial("Statement.run() returning duration", async (t) => { const db = t.context.db; @@ -19,7 +19,9 @@ test.serial("Statement.get() returning duration", async (t) => { t.log(info._metadata?.duration) }); -test.serial("Database.authorizer()/allow", async (t) => { +// ---- Legacy API (backward compatibility) ---- + +test.serial("Database.authorizer()/allow (legacy)", async (t) => { const db = t.context.db; db.authorizer({ @@ -31,7 +33,7 @@ test.serial("Database.authorizer()/allow", async (t) => { t.is(users.length, 2); }); -test.serial("Database.authorizer()/deny", async (t) => { +test.serial("Database.authorizer()/deny (legacy)", async (t) => { const db = t.context.db; db.authorizer({ @@ -45,6 +47,640 @@ test.serial("Database.authorizer()/deny", async (t) => { }); }); +// ---- Rule-based API ---- + +test.serial("Rule-based: allow READ on table", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [ + { action: Action.READ, table: "users", policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const stmt = db.prepare("SELECT * FROM users"); + const users = stmt.all(); + t.is(users.length, 2); +}); + +test.serial("Rule-based: deny all with default policy", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [], + defaultPolicy: Authorization.DENY, + }); + + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { + instanceOf: t.context.errorType, + code: "SQLITE_AUTH" + }); +}); + +test.serial("Rule-based: action-level deny PRAGMA", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [ + { action: Action.PRAGMA, policy: Authorization.DENY }, + ], + defaultPolicy: Authorization.ALLOW, + }); + + await t.throwsAsync(async () => { + return await db.prepare("PRAGMA table_info('users')"); + }, { + instanceOf: t.context.errorType, + code: "SQLITE_AUTH" + }); +}); + +test.serial("Rule-based: multiple actions in single rule", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [ + { action: [Action.INSERT, Action.UPDATE, Action.DELETE], table: "users", policy: Authorization.DENY }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + { action: Action.READ, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.ALLOW, + }); + + // SELECT should work + const stmt = db.prepare("SELECT * FROM users"); + const users = stmt.all(); + t.is(users.length, 2); + + // INSERT should be denied + await t.throwsAsync(async () => { + return await db.prepare("INSERT INTO users (id, name, email) VALUES (3, 'Eve', 'eve@example.org')"); + }, { + instanceOf: t.context.errorType, + code: "SQLITE_AUTH" + }); +}); + +test.serial("Rule-based: glob pattern on table name", async (t) => { + const db = t.context.db; + + db.exec("CREATE TABLE IF NOT EXISTS logs_access (id INTEGER PRIMARY KEY, msg TEXT)"); + db.exec("INSERT INTO logs_access (id, msg) VALUES (1, 'hello')"); + + db.authorizer({ + rules: [ + { action: Action.READ, table: "logs_*", policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const stmt = db.prepare("SELECT * FROM logs_access"); + const rows = stmt.all(); + t.is(rows.length, 1); + + // users table should be denied (doesn't match logs_*) + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { + instanceOf: t.context.errorType, + code: "SQLITE_AUTH" + }); +}); + +test.serial("Rule-based: regex pattern on table name", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [ + { action: Action.READ, table: /^users$/, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const stmt = db.prepare("SELECT * FROM users"); + const users = stmt.all(); + t.is(users.length, 2); +}); + +test.serial("Rule-based: IGNORE returns NULL for READ columns", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [ + { action: Action.READ, table: "users", column: "email", policy: Authorization.IGNORE }, + { action: Action.READ, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const stmt = db.prepare("SELECT id, name, email FROM users WHERE id = 1"); + const row = stmt.get(); + t.is(row.id, 1); + t.is(row.name, "Alice"); + t.is(row.email, null); +}); + +test.serial("Rule-based: entity pattern for functions", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [ + { action: Action.FUNCTION, entity: /^(lower|upper|length)$/, policy: Authorization.ALLOW }, + { action: Action.READ, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const stmt = db.prepare("SELECT upper(name) as uname FROM users WHERE id = 1"); + const row = stmt.get(); + t.is(row.uname, "ALICE"); +}); + +test.serial("Rule-based: first match wins (order matters)", async (t) => { + const db = t.context.db; + + // Specific deny for users table, then broad allow for all reads + db.authorizer({ + rules: [ + { action: Action.READ, table: "users", policy: Authorization.DENY }, + { action: Action.READ, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.ALLOW, + }); + + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { + instanceOf: t.context.errorType, + code: "SQLITE_AUTH" + }); +}); + +test.serial("Rule-based: null removes authorizer", async (t) => { + const db = t.context.db; + + // Set a restrictive authorizer + db.authorizer({ + rules: [], + defaultPolicy: Authorization.DENY, + }); + + // Should fail + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { + instanceOf: t.context.errorType, + code: "SQLITE_AUTH" + }); + + // Remove authorizer + db.authorizer(null); + + // Should succeed now + const stmt = db.prepare("SELECT * FROM users"); + const users = stmt.all(); + t.is(users.length, 2); +}); + +test.serial("Rule-based: default policy allow", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [], + defaultPolicy: Authorization.ALLOW, + }); + + const stmt = db.prepare("SELECT * FROM users"); + const users = stmt.all(); + t.is(users.length, 2); +}); + +// ---- Glob pattern tests ---- + +test.serial("Glob: ? matches exactly one character", async (t) => { + const db = t.context.db; + + db.exec("CREATE TABLE IF NOT EXISTS log_a (id INTEGER PRIMARY KEY, msg TEXT)"); + db.exec("CREATE TABLE IF NOT EXISTS log_b (id INTEGER PRIMARY KEY, msg TEXT)"); + db.exec("INSERT INTO log_a (id, msg) VALUES (1, 'aaa')"); + db.exec("INSERT INTO log_b (id, msg) VALUES (1, 'bbb')"); + + db.authorizer({ + rules: [ + { action: Action.READ, table: "log_?", policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + // log_a and log_b should both match log_? + const a = db.prepare("SELECT * FROM log_a").all(); + t.is(a.length, 1); + const b = db.prepare("SELECT * FROM log_b").all(); + t.is(b.length, 1); + + // users should NOT match log_? + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); +}); + +test.serial("Glob: ? does not match zero or multiple characters", async (t) => { + const db = t.context.db; + + // Create tables with varying suffix lengths + db.exec("CREATE TABLE IF NOT EXISTS item_ (id INTEGER PRIMARY KEY)"); + db.exec("CREATE TABLE IF NOT EXISTS item_ab (id INTEGER PRIMARY KEY)"); + + db.authorizer({ + rules: [ + { action: Action.READ, table: "item_?", policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + // item_ (zero chars after _) should NOT match item_? + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM item_"); + }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); + + // item_ab (two chars after _) should NOT match item_? + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM item_ab"); + }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); +}); + +test.serial("Glob: * at start of pattern", async (t) => { + const db = t.context.db; + + db.exec("CREATE TABLE IF NOT EXISTS audit_users (id INTEGER PRIMARY KEY, msg TEXT)"); + db.exec("INSERT INTO audit_users (id, msg) VALUES (1, 'x')"); + + db.authorizer({ + rules: [ + { action: Action.READ, table: "*_users", policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const rows = db.prepare("SELECT * FROM audit_users").all(); + t.is(rows.length, 1); +}); + +test.serial("Glob: * in middle of pattern", async (t) => { + const db = t.context.db; + + db.exec("CREATE TABLE IF NOT EXISTS app_prod_logs (id INTEGER PRIMARY KEY, msg TEXT)"); + db.exec("INSERT INTO app_prod_logs (id, msg) VALUES (1, 'hello')"); + + db.authorizer({ + rules: [ + { action: Action.READ, table: "app_*_logs", policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const rows = db.prepare("SELECT * FROM app_prod_logs").all(); + t.is(rows.length, 1); + + // users doesn't match app_*_logs + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); +}); + +test.serial("Glob: multiple wildcards in one pattern", async (t) => { + const db = t.context.db; + + db.exec("CREATE TABLE IF NOT EXISTS x_data_y (id INTEGER PRIMARY KEY)"); + db.exec("INSERT INTO x_data_y (id) VALUES (1)"); + + db.authorizer({ + rules: [ + { action: Action.READ, table: "*_data_*", policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const rows = db.prepare("SELECT * FROM x_data_y").all(); + t.is(rows.length, 1); +}); + +test.serial("Glob: on column name", async (t) => { + const db = t.context.db; + + // IGNORE columns matching e* → email gets NULL, everything else readable + db.authorizer({ + rules: [ + { action: Action.READ, table: "users", column: "e*", policy: Authorization.IGNORE }, + { action: Action.READ, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const row = db.prepare("SELECT id, name, email FROM users WHERE id = 1").get(); + t.is(row.id, 1); + t.is(row.name, "Alice"); + t.is(row.email, null); // email matches e*, gets IGNORE → NULL +}); + +test.serial("Glob: on entity name (pragma)", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [ + { action: Action.PRAGMA, entity: "table_*", policy: Authorization.ALLOW }, + { action: Action.READ, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + // table_info matches table_* + const info = db.prepare("PRAGMA table_info('users')").all(); + t.true(info.length > 0); +}); + +test.serial("Glob: exact string without wildcards is exact match", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [ + { action: Action.READ, table: "users", policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const rows = db.prepare("SELECT * FROM users").all(); + t.is(rows.length, 2); +}); + +// ---- Regex pattern tests ---- + +test.serial("Regex: case-insensitive flag", async (t) => { + const db = t.context.db; + + db.exec("CREATE TABLE IF NOT EXISTS Users_CI (id INTEGER PRIMARY KEY, val TEXT)"); + db.exec("INSERT INTO Users_CI (id, val) VALUES (1, 'test')"); + + db.authorizer({ + rules: [ + { action: Action.READ, table: /^users_ci$/i, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const rows = db.prepare("SELECT * FROM Users_CI").all(); + t.is(rows.length, 1); +}); + +test.serial("Regex: partial match (no anchors)", async (t) => { + const db = t.context.db; + + // /user/ without anchors should match "users" (partial match) + db.authorizer({ + rules: [ + { action: Action.READ, table: /user/, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const rows = db.prepare("SELECT * FROM users").all(); + t.is(rows.length, 2); +}); + +test.serial("Regex: anchored pattern rejects partial matches", async (t) => { + const db = t.context.db; + + // /^user$/ should NOT match "users" (has trailing s) + db.authorizer({ + rules: [ + { action: Action.READ, table: /^user$/, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); +}); + +test.serial("Regex: alternation pattern", async (t) => { + const db = t.context.db; + + db.exec("CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, pname TEXT)"); + db.exec("INSERT INTO products (id, pname) VALUES (1, 'Widget')"); + + db.authorizer({ + rules: [ + { action: Action.READ, table: /^(users|products)$/, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const u = db.prepare("SELECT * FROM users").all(); + t.is(u.length, 2); + const p = db.prepare("SELECT * FROM products").all(); + t.is(p.length, 1); +}); + +test.serial("Regex: character class pattern", async (t) => { + const db = t.context.db; + + db.exec("CREATE TABLE IF NOT EXISTS t1_data (id INTEGER PRIMARY KEY)"); + db.exec("CREATE TABLE IF NOT EXISTS t2_data (id INTEGER PRIMARY KEY)"); + db.exec("INSERT INTO t1_data (id) VALUES (1)"); + db.exec("INSERT INTO t2_data (id) VALUES (1)"); + + db.authorizer({ + rules: [ + { action: Action.READ, table: /^t[0-9]_data$/, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const r1 = db.prepare("SELECT * FROM t1_data").all(); + t.is(r1.length, 1); + const r2 = db.prepare("SELECT * FROM t2_data").all(); + t.is(r2.length, 1); + + // users shouldn't match + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); +}); + +test.serial("Regex: on column name with IGNORE", async (t) => { + const db = t.context.db; + + // IGNORE any column ending in "il" → email gets NULL + db.authorizer({ + rules: [ + { action: Action.READ, table: "users", column: /il$/, policy: Authorization.IGNORE }, + { action: Action.READ, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const row = db.prepare("SELECT id, name, email FROM users WHERE id = 1").get(); + t.is(row.id, 1); + t.is(row.name, "Alice"); + t.is(row.email, null); // "email" ends in "il" +}); + +test.serial("Regex: on entity name for allowed functions", async (t) => { + const db = t.context.db; + + // Allow only functions starting with lowercase letters + db.authorizer({ + rules: [ + { action: Action.FUNCTION, entity: /^[a-z]/, policy: Authorization.ALLOW }, + { action: Action.READ, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const row = db.prepare("SELECT length(name) as len FROM users WHERE id = 1").get(); + t.is(row.len, 5); // "Alice" = 5 chars +}); + +test.serial("Regex: non-matching regex denies correctly", async (t) => { + const db = t.context.db; + + // Only allow tables starting with "archive_" + db.authorizer({ + rules: [ + { action: Action.READ, table: /^archive_/, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + // users doesn't start with archive_ + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); +}); + +test.serial("Regex: complex pattern with quantifiers", async (t) => { + const db = t.context.db; + + db.exec("CREATE TABLE IF NOT EXISTS logs_2024_01 (id INTEGER PRIMARY KEY, msg TEXT)"); + db.exec("INSERT INTO logs_2024_01 (id, msg) VALUES (1, 'jan')"); + + // Match logs_YYYY_MM pattern + db.authorizer({ + rules: [ + { action: Action.READ, table: /^logs_\d{4}_\d{2}$/, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const rows = db.prepare("SELECT * FROM logs_2024_01").all(); + t.is(rows.length, 1); + + // users doesn't match the date pattern + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); +}); + +// ---- Combined glob/regex with multiple fields ---- + +test.serial("Glob table + regex column combo", async (t) => { + const db = t.context.db; + + // For any table matching user*, IGNORE columns matching a secret-ish pattern + db.authorizer({ + rules: [ + { action: Action.READ, table: "user*", column: /^(email|password|ssn)$/, policy: Authorization.IGNORE }, + { action: Action.READ, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const row = db.prepare("SELECT id, name, email FROM users WHERE id = 2").get(); + t.is(row.id, 2); + t.is(row.name, "Bob"); + t.is(row.email, null); // email matched the regex, users matched user* +}); + +test.serial("Regex table + glob column combo", async (t) => { + const db = t.context.db; + + // For tables matching /^users$/, IGNORE columns matching e* + db.authorizer({ + rules: [ + { action: Action.READ, table: /^users$/, column: "e*", policy: Authorization.IGNORE }, + { action: Action.READ, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const row = db.prepare("SELECT id, name, email FROM users WHERE id = 1").get(); + t.is(row.id, 1); + t.is(row.name, "Alice"); + t.is(row.email, null); +}); + +test.serial("Glob: wildcard-only pattern * matches everything", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [ + { action: Action.READ, table: "*", policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + const rows = db.prepare("SELECT * FROM users").all(); + t.is(rows.length, 2); +}); + +test.serial("Glob: pattern with no match denies correctly", async (t) => { + const db = t.context.db; + + db.authorizer({ + rules: [ + { action: Action.READ, table: "nonexistent_*", policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, + }); + + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); +}); + +// ---- Setup ---- + const connect = async (path_opt) => { const path = path_opt ?? "hello.db"; const x = await import("libsql"); @@ -56,6 +692,19 @@ test.beforeEach(async (t) => { const [db, errorType, provider] = await connect(); db.exec(` DROP TABLE IF EXISTS users; + DROP TABLE IF EXISTS logs_access; + DROP TABLE IF EXISTS log_a; + DROP TABLE IF EXISTS log_b; + DROP TABLE IF EXISTS item_; + DROP TABLE IF EXISTS item_ab; + DROP TABLE IF EXISTS audit_users; + DROP TABLE IF EXISTS app_prod_logs; + DROP TABLE IF EXISTS x_data_y; + DROP TABLE IF EXISTS Users_CI; + DROP TABLE IF EXISTS products; + DROP TABLE IF EXISTS t1_data; + DROP TABLE IF EXISTS t2_data; + DROP TABLE IF EXISTS logs_2024_01; CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT) `); db.exec( diff --git a/promise.js b/promise.js index f3640135..50214e6f 100644 --- a/promise.js +++ b/promise.js @@ -2,7 +2,7 @@ const { Database: NativeDb, connect: nativeConnect } = require("./index.js"); const SqliteError = require("./sqlite-error.js"); -const Authorization = require("./auth"); +const { Authorization, Action } = require("./auth"); /** * @import {Options as NativeOptions, Statement as NativeStatement} from './index.js' @@ -204,14 +204,6 @@ class Database { throw new Error("not implemented"); } - authorizer(rules) { - try { - this.db.authorizer(rules); - } catch (err) { - throw convertError(err); - } - } - /** * Loads an extension into the database * @param {Parameters} args - Arguments to pass to the underlying loadExtension method @@ -259,8 +251,12 @@ class Database { this.db.close(); } - authorizer(hook) { - this.db.authorizer(hook); + authorizer(config) { + try { + this.db.authorizer(config); + } catch (err) { + throw convertError(err); + } return this; } @@ -419,6 +415,7 @@ class Statement { } module.exports = { + Action, Authorization, Database, SqliteError, From 2da1504525c726131cd6307c360119f8bc9118d6 Mon Sep 17 00:00:00 2001 From: PThorpe92 Date: Fri, 3 Apr 2026 18:37:32 -0400 Subject: [PATCH 3/4] remove regex from authorizer api and replace magic values with constants --- Cargo.toml | 1 - integration-tests/tests/extensions.test.js | 229 +-------------------- src/auth.rs | 25 ++- src/lib.rs | 40 +--- 4 files changed, 27 insertions(+), 268 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 10607c6a..9e4de838 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ libsql = { version = "0.9.30", features = ["encryption"] } napi = { version = "2", default-features = false, features = ["napi6", "tokio_rt", "async"] } napi-derive = "2" once_cell = "1.18.0" -regex = "1" serde_json = "1.0.140" tokio = { version = "1.47.1", features = [ "rt-multi-thread" ] } tracing = "0.1" diff --git a/integration-tests/tests/extensions.test.js b/integration-tests/tests/extensions.test.js index cfa5803c..78406e50 100644 --- a/integration-tests/tests/extensions.test.js +++ b/integration-tests/tests/extensions.test.js @@ -152,22 +152,6 @@ test.serial("Rule-based: glob pattern on table name", async (t) => { }); }); -test.serial("Rule-based: regex pattern on table name", async (t) => { - const db = t.context.db; - - db.authorizer({ - rules: [ - { action: Action.READ, table: /^users$/, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - const stmt = db.prepare("SELECT * FROM users"); - const users = stmt.all(); - t.is(users.length, 2); -}); - test.serial("Rule-based: IGNORE returns NULL for READ columns", async (t) => { const db = t.context.db; @@ -192,7 +176,7 @@ test.serial("Rule-based: entity pattern for functions", async (t) => { db.authorizer({ rules: [ - { action: Action.FUNCTION, entity: /^(lower|upper|length)$/, policy: Authorization.ALLOW }, + { action: Action.FUNCTION, entity: "upper", policy: Authorization.ALLOW }, { action: Action.READ, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], @@ -430,193 +414,13 @@ test.serial("Glob: exact string without wildcards is exact match", async (t) => t.is(rows.length, 2); }); -// ---- Regex pattern tests ---- - -test.serial("Regex: case-insensitive flag", async (t) => { - const db = t.context.db; - - db.exec("CREATE TABLE IF NOT EXISTS Users_CI (id INTEGER PRIMARY KEY, val TEXT)"); - db.exec("INSERT INTO Users_CI (id, val) VALUES (1, 'test')"); - - db.authorizer({ - rules: [ - { action: Action.READ, table: /^users_ci$/i, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - const rows = db.prepare("SELECT * FROM Users_CI").all(); - t.is(rows.length, 1); -}); - -test.serial("Regex: partial match (no anchors)", async (t) => { - const db = t.context.db; - - // /user/ without anchors should match "users" (partial match) - db.authorizer({ - rules: [ - { action: Action.READ, table: /user/, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - const rows = db.prepare("SELECT * FROM users").all(); - t.is(rows.length, 2); -}); - -test.serial("Regex: anchored pattern rejects partial matches", async (t) => { - const db = t.context.db; - - // /^user$/ should NOT match "users" (has trailing s) - db.authorizer({ - rules: [ - { action: Action.READ, table: /^user$/, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - await t.throwsAsync(async () => { - return await db.prepare("SELECT * FROM users"); - }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); -}); - -test.serial("Regex: alternation pattern", async (t) => { - const db = t.context.db; - - db.exec("CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, pname TEXT)"); - db.exec("INSERT INTO products (id, pname) VALUES (1, 'Widget')"); - - db.authorizer({ - rules: [ - { action: Action.READ, table: /^(users|products)$/, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - const u = db.prepare("SELECT * FROM users").all(); - t.is(u.length, 2); - const p = db.prepare("SELECT * FROM products").all(); - t.is(p.length, 1); -}); - -test.serial("Regex: character class pattern", async (t) => { - const db = t.context.db; - - db.exec("CREATE TABLE IF NOT EXISTS t1_data (id INTEGER PRIMARY KEY)"); - db.exec("CREATE TABLE IF NOT EXISTS t2_data (id INTEGER PRIMARY KEY)"); - db.exec("INSERT INTO t1_data (id) VALUES (1)"); - db.exec("INSERT INTO t2_data (id) VALUES (1)"); - - db.authorizer({ - rules: [ - { action: Action.READ, table: /^t[0-9]_data$/, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - const r1 = db.prepare("SELECT * FROM t1_data").all(); - t.is(r1.length, 1); - const r2 = db.prepare("SELECT * FROM t2_data").all(); - t.is(r2.length, 1); - - // users shouldn't match - await t.throwsAsync(async () => { - return await db.prepare("SELECT * FROM users"); - }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); -}); - -test.serial("Regex: on column name with IGNORE", async (t) => { - const db = t.context.db; - - // IGNORE any column ending in "il" → email gets NULL - db.authorizer({ - rules: [ - { action: Action.READ, table: "users", column: /il$/, policy: Authorization.IGNORE }, - { action: Action.READ, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - const row = db.prepare("SELECT id, name, email FROM users WHERE id = 1").get(); - t.is(row.id, 1); - t.is(row.name, "Alice"); - t.is(row.email, null); // "email" ends in "il" -}); - -test.serial("Regex: on entity name for allowed functions", async (t) => { +test.serial("Glob: table + column combo", async (t) => { const db = t.context.db; - // Allow only functions starting with lowercase letters + // For any table matching user*, IGNORE columns matching e* db.authorizer({ rules: [ - { action: Action.FUNCTION, entity: /^[a-z]/, policy: Authorization.ALLOW }, - { action: Action.READ, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - const row = db.prepare("SELECT length(name) as len FROM users WHERE id = 1").get(); - t.is(row.len, 5); // "Alice" = 5 chars -}); - -test.serial("Regex: non-matching regex denies correctly", async (t) => { - const db = t.context.db; - - // Only allow tables starting with "archive_" - db.authorizer({ - rules: [ - { action: Action.READ, table: /^archive_/, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - // users doesn't start with archive_ - await t.throwsAsync(async () => { - return await db.prepare("SELECT * FROM users"); - }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); -}); - -test.serial("Regex: complex pattern with quantifiers", async (t) => { - const db = t.context.db; - - db.exec("CREATE TABLE IF NOT EXISTS logs_2024_01 (id INTEGER PRIMARY KEY, msg TEXT)"); - db.exec("INSERT INTO logs_2024_01 (id, msg) VALUES (1, 'jan')"); - - // Match logs_YYYY_MM pattern - db.authorizer({ - rules: [ - { action: Action.READ, table: /^logs_\d{4}_\d{2}$/, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - const rows = db.prepare("SELECT * FROM logs_2024_01").all(); - t.is(rows.length, 1); - - // users doesn't match the date pattern - await t.throwsAsync(async () => { - return await db.prepare("SELECT * FROM users"); - }, { instanceOf: t.context.errorType, code: "SQLITE_AUTH" }); -}); - -// ---- Combined glob/regex with multiple fields ---- - -test.serial("Glob table + regex column combo", async (t) => { - const db = t.context.db; - - // For any table matching user*, IGNORE columns matching a secret-ish pattern - db.authorizer({ - rules: [ - { action: Action.READ, table: "user*", column: /^(email|password|ssn)$/, policy: Authorization.IGNORE }, + { action: Action.READ, table: "user*", column: "e*", policy: Authorization.IGNORE }, { action: Action.READ, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], @@ -626,26 +430,7 @@ test.serial("Glob table + regex column combo", async (t) => { const row = db.prepare("SELECT id, name, email FROM users WHERE id = 2").get(); t.is(row.id, 2); t.is(row.name, "Bob"); - t.is(row.email, null); // email matched the regex, users matched user* -}); - -test.serial("Regex table + glob column combo", async (t) => { - const db = t.context.db; - - // For tables matching /^users$/, IGNORE columns matching e* - db.authorizer({ - rules: [ - { action: Action.READ, table: /^users$/, column: "e*", policy: Authorization.IGNORE }, - { action: Action.READ, policy: Authorization.ALLOW }, - { action: Action.SELECT, policy: Authorization.ALLOW }, - ], - defaultPolicy: Authorization.DENY, - }); - - const row = db.prepare("SELECT id, name, email FROM users WHERE id = 1").get(); - t.is(row.id, 1); - t.is(row.name, "Alice"); - t.is(row.email, null); + t.is(row.email, null); // email matches e*, users matches user* }); test.serial("Glob: wildcard-only pattern * matches everything", async (t) => { @@ -700,11 +485,7 @@ test.beforeEach(async (t) => { DROP TABLE IF EXISTS audit_users; DROP TABLE IF EXISTS app_prod_logs; DROP TABLE IF EXISTS x_data_y; - DROP TABLE IF EXISTS Users_CI; DROP TABLE IF EXISTS products; - DROP TABLE IF EXISTS t1_data; - DROP TABLE IF EXISTS t2_data; - DROP TABLE IF EXISTS logs_2024_01; CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT) `); db.exec( diff --git a/src/auth.rs b/src/auth.rs index 00d254d6..4cbdd57e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -20,8 +20,6 @@ pub enum PatternMatcher { Exact(String), /// Glob pattern (supports `*` and `?` wildcards). Glob(String), - /// Compiled regular expression. - Regex(regex::Regex), } impl PatternMatcher { @@ -29,7 +27,6 @@ impl PatternMatcher { match self { PatternMatcher::Exact(s) => s == value, PatternMatcher::Glob(pattern) => glob_match::glob_match(pattern, value), - PatternMatcher::Regex(re) => re.is_match(value), } } } @@ -404,7 +401,25 @@ impl AuthorizerBuilder { // Table-bearing action codes (actions where the old authorizer checked tables) let table_actions: Vec = vec![ - 1, 2, 3, 4, 5, 7, 9, 10, 11, 12, 13, 14, 16, 18, 20, 23, 26, 29, 30, + SQLITE_CREATE_INDEX, + SQLITE_CREATE_TABLE, + SQLITE_CREATE_TEMP_INDEX, + SQLITE_CREATE_TEMP_TABLE, + SQLITE_CREATE_TEMP_TRIGGER, + SQLITE_CREATE_TRIGGER, + SQLITE_DELETE, + SQLITE_DROP_INDEX, + SQLITE_DROP_TABLE, + SQLITE_DROP_TEMP_INDEX, + SQLITE_DROP_TEMP_TABLE, + SQLITE_DROP_TEMP_TRIGGER, + SQLITE_DROP_TRIGGER, + SQLITE_INSERT, + SQLITE_READ, + SQLITE_UPDATE, + SQLITE_ALTER_TABLE, + SQLITE_CREATE_VTABLE, + SQLITE_DROP_VTABLE, ]; // Deny rules first @@ -431,7 +446,7 @@ impl AuthorizerBuilder { // Legacy behavior: always allow SELECT (no table context) rules.push(AuthRule { - actions: vec![21], // SELECT + actions: vec![SQLITE_SELECT], table: None, column: None, entity: None, diff --git a/src/lib.rs b/src/lib.rs index a942e1bf..fe8f0051 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -727,56 +727,20 @@ fn parse_single_rule(rule_obj: &napi::JsObject) -> Result }) } -/// Parse a pattern value: string (exact or glob) or RegExp. +/// Parse a pattern value: plain string or glob (auto-detected by `*` or `?`). fn parse_pattern(val: JsUnknown, field_name: &str) -> Result { match val.get_type()? { ValueType::String => { let s: napi::JsString = val.coerce_to_string()?; let owned = s.into_utf8()?.into_owned()?; - // Auto-detect glob: if the string contains * or ?, treat as glob if owned.contains('*') || owned.contains('?') { Ok(crate::auth::PatternMatcher::Glob(owned)) } else { Ok(crate::auth::PatternMatcher::Exact(owned)) } } - ValueType::Object => { - // Check if it's a RegExp by checking for .source property - let obj: napi::JsObject = val.coerce_to_object()?; - if obj.has_named_property("source")? { - let source_js: napi::JsString = obj.get_named_property("source")?; - let source = source_js.into_utf8()?.into_owned()?; - - // Check for flags (we support 'i' for case-insensitive) - let flags_str = if obj.has_named_property("flags")? { - let flags_js: napi::JsString = obj.get_named_property("flags")?; - flags_js.into_utf8()?.into_owned()? - } else { - String::new() - }; - - let pattern = if flags_str.contains('i') { - format!("(?i){}", source) - } else { - source - }; - - let re = regex::Regex::new(&pattern).map_err(|e| { - napi::Error::from_reason(format!( - "Invalid regex pattern for {}: {}", - field_name, e - )) - })?; - Ok(crate::auth::PatternMatcher::Regex(re)) - } else { - Err(napi::Error::from_reason(format!( - "{} must be a string or RegExp", - field_name - ))) - } - } _ => Err(napi::Error::from_reason(format!( - "{} must be a string or RegExp", + "{} must be a string", field_name ))), } From d18582dccca242367a888912022321ce3c3729e9 Mon Sep 17 00:00:00 2001 From: PThorpe92 Date: Fri, 3 Apr 2026 19:05:53 -0400 Subject: [PATCH 4/4] make glob field explicit in authorizer api and update docs --- docs/api.md | 103 ++++++++++++++++++--- integration-tests/tests/extensions.test.js | 22 ++--- src/lib.rs | 43 ++++++++- 3 files changed, 142 insertions(+), 26 deletions(-) diff --git a/docs/api.md b/docs/api.md index 46116cbf..0d203b4b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -68,31 +68,112 @@ This function is currently not supported. This function is currently not supported. -### authorizer(rules) ⇒ this +### authorizer(config) ⇒ this -Configure authorization rules. The `rules` object is a map from table name to -`Authorization` object, which defines if access to table is allowed or denied. -If a table has no authorization rule, access to it is _denied_ by default. +Configure authorization rules for the database. Accepts three formats: -Example: +- **Legacy format** — a map from table name to `Authorization.ALLOW` or `Authorization.DENY` +- **Rule-based format** — an `AuthorizerConfig` object with ordered rules and pattern matching +- **`null`** — removes the authorizer entirely + +#### Legacy format + +A simple object mapping table names to `Authorization.ALLOW` (0) or `Authorization.DENY` (1). +Tables without an entry are denied by default. ```javascript +const { Authorization } = require('libsql'); + db.authorizer({ - "users": Authorization.ALLOW + "users": Authorization.ALLOW, + "secrets": Authorization.DENY, }); -// Access is allowed. +// Access to "users" is allowed. const stmt = db.prepare("SELECT * FROM users"); +// Access to "secrets" throws SQLITE_AUTH. +const stmt = db.prepare("SELECT * FROM secrets"); // Error! +``` + +#### Rule-based format + +An object with a `rules` array and an optional `defaultPolicy`. Rules are evaluated in order — **first match wins**. If no rule matches, `defaultPolicy` applies (defaults to `DENY`). + +```javascript +const { Authorization, Action } = require('libsql'); + db.authorizer({ - "users": Authorization.DENY + rules: [ + // Hide sensitive columns (returns NULL instead of the real value) + { action: Action.READ, table: "users", column: "password_hash", policy: Authorization.IGNORE }, + { action: Action.READ, table: "users", column: "ssn", policy: Authorization.IGNORE }, + + // Allow all reads + { action: Action.READ, policy: Authorization.ALLOW }, + + // Allow inserts on tables matching a glob pattern + { action: Action.INSERT, table: { glob: "logs_*" }, policy: Authorization.ALLOW }, + + // Deny DDL operations + { action: [Action.CREATE_TABLE, Action.DROP_TABLE, Action.ALTER_TABLE], policy: Authorization.DENY }, + + // Allow transactions and selects + { action: Action.TRANSACTION, policy: Authorization.ALLOW }, + { action: Action.SELECT, policy: Authorization.ALLOW }, + ], + defaultPolicy: Authorization.DENY, }); +``` -// Access is denied. -const stmt = db.prepare("SELECT * FROM users"); +#### AuthRule fields + +| Field | Type | Description | +| -------- | ----------------------------------------- | -------------------------------------------------------------------- | +| action | number \| number[] | Action code(s) to match (from `Action`). Omit to match all actions. | +| 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. | +| policy | number | `Authorization.ALLOW`, `Authorization.DENY`, or `Authorization.IGNORE`. | + +#### Pattern matching + +Pattern fields (`table`, `column`, `entity`) accept either: + +- A **plain string** for exact matching: `"users"` +- An **object with a `glob` key** for glob matching: `{ glob: "logs_*" }` + +Glob patterns support `*` (match any number of characters) and `?` (match exactly one character). + +```javascript +// Exact match +{ action: Action.READ, table: "users", policy: Authorization.ALLOW } + +// Glob: all tables starting with "logs_" +{ action: Action.READ, table: { glob: "logs_*" }, policy: Authorization.ALLOW } + +// Glob: single-character wildcard +{ action: Action.READ, table: { glob: "t?_data" }, policy: Authorization.ALLOW } + +// Glob: match all tables +{ action: Action.READ, table: { glob: "*" }, policy: Authorization.ALLOW } ``` -**Note: This is an experimental API and, therefore, subject to change.** +#### Authorization values + +| Value | Effect | +| -------------------------- | ---------------------------------------------------------------------- | +| `Authorization.ALLOW` (0) | Permit the operation. | +| `Authorization.DENY` (1) | Reject the entire SQL statement with a `SQLITE_AUTH` error. | +| `Authorization.IGNORE` (2) | For READ: return NULL instead of the column value. Otherwise: deny. | + +#### Removing the authorizer + +Pass `null` to remove the authorizer and allow all operations: + +```javascript +db.authorizer(null); +``` ### loadExtension(path, [entryPoint]) ⇒ this diff --git a/integration-tests/tests/extensions.test.js b/integration-tests/tests/extensions.test.js index 78406e50..1ff1010c 100644 --- a/integration-tests/tests/extensions.test.js +++ b/integration-tests/tests/extensions.test.js @@ -133,7 +133,7 @@ test.serial("Rule-based: glob pattern on table name", async (t) => { db.authorizer({ rules: [ - { action: Action.READ, table: "logs_*", policy: Authorization.ALLOW }, + { action: Action.READ, table: { glob: "logs_*" }, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], defaultPolicy: Authorization.DENY, @@ -260,7 +260,7 @@ test.serial("Glob: ? matches exactly one character", async (t) => { db.authorizer({ rules: [ - { action: Action.READ, table: "log_?", policy: Authorization.ALLOW }, + { action: Action.READ, table: { glob: "log_?" }, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], defaultPolicy: Authorization.DENY, @@ -287,7 +287,7 @@ test.serial("Glob: ? does not match zero or multiple characters", async (t) => { db.authorizer({ rules: [ - { action: Action.READ, table: "item_?", policy: Authorization.ALLOW }, + { action: Action.READ, table: { glob: "item_?" }, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], defaultPolicy: Authorization.DENY, @@ -312,7 +312,7 @@ test.serial("Glob: * at start of pattern", async (t) => { db.authorizer({ rules: [ - { action: Action.READ, table: "*_users", policy: Authorization.ALLOW }, + { action: Action.READ, table: { glob: "*_users" }, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], defaultPolicy: Authorization.DENY, @@ -330,7 +330,7 @@ test.serial("Glob: * in middle of pattern", async (t) => { db.authorizer({ rules: [ - { action: Action.READ, table: "app_*_logs", policy: Authorization.ALLOW }, + { action: Action.READ, table: { glob: "app_*_logs" }, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], defaultPolicy: Authorization.DENY, @@ -353,7 +353,7 @@ test.serial("Glob: multiple wildcards in one pattern", async (t) => { db.authorizer({ rules: [ - { action: Action.READ, table: "*_data_*", policy: Authorization.ALLOW }, + { action: Action.READ, table: { glob: "*_data_*" }, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], defaultPolicy: Authorization.DENY, @@ -369,7 +369,7 @@ test.serial("Glob: on column name", async (t) => { // IGNORE columns matching e* → email gets NULL, everything else readable db.authorizer({ rules: [ - { action: Action.READ, table: "users", column: "e*", policy: Authorization.IGNORE }, + { action: Action.READ, table: "users", column: { glob: "e*" }, policy: Authorization.IGNORE }, { action: Action.READ, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], @@ -387,7 +387,7 @@ test.serial("Glob: on entity name (pragma)", async (t) => { db.authorizer({ rules: [ - { action: Action.PRAGMA, entity: "table_*", policy: Authorization.ALLOW }, + { action: Action.PRAGMA, entity: { glob: "table_*" }, policy: Authorization.ALLOW }, { action: Action.READ, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], @@ -420,7 +420,7 @@ test.serial("Glob: table + column combo", async (t) => { // For any table matching user*, IGNORE columns matching e* db.authorizer({ rules: [ - { action: Action.READ, table: "user*", column: "e*", policy: Authorization.IGNORE }, + { action: Action.READ, table: { glob: "user*" }, column: { glob: "e*" }, policy: Authorization.IGNORE }, { action: Action.READ, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], @@ -438,7 +438,7 @@ test.serial("Glob: wildcard-only pattern * matches everything", async (t) => { db.authorizer({ rules: [ - { action: Action.READ, table: "*", policy: Authorization.ALLOW }, + { action: Action.READ, table: { glob: "*" }, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], defaultPolicy: Authorization.DENY, @@ -453,7 +453,7 @@ test.serial("Glob: pattern with no match denies correctly", async (t) => { db.authorizer({ rules: [ - { action: Action.READ, table: "nonexistent_*", policy: Authorization.ALLOW }, + { action: Action.READ, table: { glob: "nonexistent_*" }, policy: Authorization.ALLOW }, { action: Action.SELECT, policy: Authorization.ALLOW }, ], defaultPolicy: Authorization.DENY, diff --git a/src/lib.rs b/src/lib.rs index fe8f0051..0f2142ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -426,6 +426,32 @@ impl Database { /// - Legacy format: `{ [tableName: string]: 0 | 1 }` /// - Full format: `{ rules: AuthRule[], defaultPolicy?: 0 | 1 | 2 }` /// - `null` to remove the authorizer + /// + /// Pattern fields (`table`, `column`, `entity`) accept a plain string for + /// exact matching, or `{ glob: "pattern" }` for glob matching with `*` and `?`. + /// + /// # Examples + /// + /// ```javascript + /// const { Authorization, Action } = require('libsql'); + /// + /// // Legacy table-level allow/deny + /// db.authorizer({ "users": Authorization.ALLOW }); + /// + /// // Rule-based with glob patterns + /// db.authorizer({ + /// rules: [ + /// { action: Action.READ, table: "users", column: "password", policy: Authorization.IGNORE }, + /// { action: Action.INSERT, table: { glob: "logs_*" }, policy: Authorization.ALLOW }, + /// { action: Action.READ, policy: Authorization.ALLOW }, + /// { action: Action.SELECT, policy: Authorization.ALLOW }, + /// ], + /// defaultPolicy: Authorization.DENY, + /// }); + /// + /// // Remove authorizer + /// db.authorizer(null); + /// ``` #[napi] pub fn authorizer(&self, env: Env, config: JsUnknown) -> Result<()> { let conn = match &self.conn { @@ -727,20 +753,29 @@ fn parse_single_rule(rule_obj: &napi::JsObject) -> Result }) } -/// Parse a pattern value: plain string or glob (auto-detected by `*` or `?`). +/// Parse a pattern value: plain string (exact match) or `{ glob: "pattern" }`. fn parse_pattern(val: JsUnknown, field_name: &str) -> Result { match val.get_type()? { ValueType::String => { let s: napi::JsString = val.coerce_to_string()?; let owned = s.into_utf8()?.into_owned()?; - if owned.contains('*') || owned.contains('?') { + Ok(crate::auth::PatternMatcher::Exact(owned)) + } + ValueType::Object => { + let obj: napi::JsObject = val.coerce_to_object()?; + if obj.has_named_property("glob")? { + let s: napi::JsString = obj.get_named_property("glob")?; + let owned = s.into_utf8()?.into_owned()?; Ok(crate::auth::PatternMatcher::Glob(owned)) } else { - Ok(crate::auth::PatternMatcher::Exact(owned)) + Err(napi::Error::from_reason(format!( + "{} must be a string or {{ glob: \"pattern\" }}", + field_name + ))) } } _ => Err(napi::Error::from_reason(format!( - "{} must be a string", + "{} must be a string or {{ glob: \"pattern\" }}", field_name ))), }