From 1f217caaadd84447c3ec783d3e549bc16d21bc2a Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Wed, 23 Apr 2025 09:02:31 +0300 Subject: [PATCH 1/3] Add Database.authorizer() API --- auth.js | 22 +++++ docs/api.md | 26 ++++++ index.js | 7 ++ integration-tests/tests/extensions.test.js | 27 ++++++ package.json | 1 + promise.js | 7 ++ src/auth.rs | 100 +++++++++++++++++++++ src/database.rs | 34 +++++++ src/lib.rs | 2 + src/statement.rs | 4 +- types/auth.d.ts | 10 +++ types/auth.d.ts.map | 1 + types/promise.d.ts | 4 +- types/promise.d.ts.map | 2 +- 14 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 auth.js create mode 100644 src/auth.rs create mode 100644 types/auth.d.ts create mode 100644 types/auth.d.ts.map diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..1766f6b --- /dev/null +++ b/auth.js @@ -0,0 +1,22 @@ +/** + * Authorization outcome. + * + * @readonly + * @enum {number} + * @property {number} ALLOW - Allow access to a resource. + * @property {number} DENY - Deny access to a resource and throw an error. + */ +const Authorization = { + /** + * Allow access to a resource. + * @type {number} + */ + ALLOW: 0, + + /** + * Deny access to a resource and throw an error in `prepare()`. + * @type {number} + */ + DENY: 1, +}; +module.exports = Authorization; diff --git a/docs/api.md b/docs/api.md index 9a1f658..189cc27 100644 --- a/docs/api.md +++ b/docs/api.md @@ -67,6 +67,32 @@ This function is currently not supported. This function is currently not supported. +### authorizer(rules) ⇒ 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. + +Example: + +```javascript +db.authorizer({ + "users": Authorization.ALLOW +}); + +// Access is allowed. +const stmt = db.prepare("SELECT * FROM users"); + +db.authorizer({ + "users": Authorization.DENY +}); + +// Access is denied. +const stmt = db.prepare("SELECT * FROM users"); +``` + +**Note: This is an experimental API and, therefore, subject to change.** + ### loadExtension(path, [entryPoint]) ⇒ this Loads a SQLite3 extension diff --git a/index.js b/index.js index 253d7e9..a511dd1 100644 --- a/index.js +++ b/index.js @@ -33,6 +33,7 @@ const { databaseExecSync, databasePrepareSync, databaseDefaultSafeIntegers, + databaseAuthorizer, databaseLoadExtension, databaseMaxWriteReplicationIndex, statementRaw, @@ -46,6 +47,7 @@ const { rowsNext, } = requireNative(); +const Authorization = require("./auth"); const SqliteError = require("./sqlite-error"); function convertError(err) { @@ -227,6 +229,10 @@ class Database { throw new Error("not implemented"); } + authorizer(rules) { + databaseAuthorizer.call(this.db, rules); + } + loadExtension(...args) { databaseLoadExtension.call(this.db, ...args); } @@ -425,4 +431,5 @@ class Statement { } module.exports = Database; +module.exports.Authorization = Authorization; module.exports.SqliteError = SqliteError; diff --git a/integration-tests/tests/extensions.test.js b/integration-tests/tests/extensions.test.js index 1a02755..ce69a7f 100644 --- a/integration-tests/tests/extensions.test.js +++ b/integration-tests/tests/extensions.test.js @@ -1,4 +1,5 @@ import test from "ava"; +import { Authorization } from "libsql"; test.serial("Statement.run() returning duration", async (t) => { const db = t.context.db; @@ -18,6 +19,32 @@ test.serial("Statement.get() returning duration", async (t) => { t.log(info._metadata?.duration) }); +test.serial("Database.authorizer()/allow", async (t) => { + const db = t.context.db; + + db.authorizer({ + "users": Authorization.ALLOW + }); + + const stmt = db.prepare("SELECT * FROM users"); + const users = stmt.all(); + t.is(users.length, 2); +}); + +test.serial("Database.authorizer()/deny", async (t) => { + const db = t.context.db; + + db.authorizer({ + "users": Authorization.DENY + }); + await t.throwsAsync(async () => { + return await db.prepare("SELECT * FROM users"); + }, { + instanceOf: t.context.errorType, + code: "SQLITE_AUTH" + }); +}); + const connect = async (path_opt) => { const path = path_opt ?? "hello.db"; const x = await import("libsql"); diff --git a/package.json b/package.json index 7af8b53..852f654 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "main": "index.js", "types": "types/index.d.ts", "files": [ + "auth.js", "index.js", "sqlite-error.js", "promise.js", diff --git a/promise.js b/promise.js index 96720dd..3a9d6eb 100644 --- a/promise.js +++ b/promise.js @@ -8,6 +8,7 @@ if (0) { require("./.targets"); } +const Authorization = require("./auth"); const SqliteError = require("./sqlite-error"); function convertError(err) { @@ -48,6 +49,7 @@ const { databasePrepareAsync, databaseMaxWriteReplicationIndex, databaseDefaultSafeIntegers, + databaseAuthorizer, databaseLoadExtension, statementRaw, statementIsReader, @@ -231,6 +233,10 @@ class Database { throw new Error("not implemented"); } + authorizer(rules) { + databaseAuthorizer.call(this.db, rules); + } + loadExtension(...args) { databaseLoadExtension.call(this.db, ...args); } @@ -429,4 +435,5 @@ class Statement { } module.exports = Database; +module.exports.Authorization = Authorization; module.exports.SqliteError = SqliteError; diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..00bba28 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,100 @@ +use tracing::trace; + +use std::collections::HashSet; + +pub struct AuthorizerBuilder { + allow_list: HashSet, + deny_list: HashSet, +} + +impl AuthorizerBuilder { + pub fn new() -> Self { + Self { + allow_list: HashSet::new(), + deny_list: HashSet::new(), + } + } + + pub fn allow(&mut self, table: &str) -> &mut Self { + self.allow_list.insert(table.to_string()); + self + } + + pub fn deny(&mut self, table: &str) -> &mut Self { + self.deny_list.insert(table.to_string()); + self + } + + pub fn build(self) -> Authorizer { + Authorizer::new(self.allow_list, self.deny_list) + } +} + +pub struct Authorizer { + allow_list: HashSet, + deny_list: HashSet, +} + +impl Authorizer { + pub fn new( + allow_list: HashSet, + deny_list: HashSet, + ) -> Self { + Self { + allow_list, + deny_list, + } + } + + 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; + } + if self.allow_list.contains(table) { + return libsql::Authorization::Allow; + } + libsql::Authorization::Deny + } +} diff --git a/src/database.rs b/src/database.rs index f1fcca7..5896ae1 100644 --- a/src/database.rs +++ b/src/database.rs @@ -7,6 +7,7 @@ use std::time::Duration; use tokio::sync::Mutex; use tracing::trace; +use crate::auth::AuthorizerBuilder; use crate::errors::{throw_database_closed_error, throw_libsql_error}; use crate::runtime; use crate::Statement; @@ -371,6 +372,39 @@ impl Database { self.default_safe_integers.replace(toggle); } + pub fn js_authorizer(mut cx: FunctionContext) -> JsResult { + let db: Handle<'_, JsBox> = cx.this()?; + let rules_obj = cx.argument::(0)?; + let conn = match db.get_conn(&mut cx) { + Some(conn) => conn, + None => throw_database_closed_error(&mut cx)?, + }; + let mut auth = AuthorizerBuilder::new(); + let prop_names: Handle = rules_obj.get_own_property_names(&mut cx)?; + let prop_len = prop_names.len(&mut cx); + for i in 0..prop_len { + let key_js = prop_names.get::(&mut cx, i)?; + let key: String = key_js.to_string(&mut cx)?.value(&mut cx); + let value = rules_obj.get::(&mut cx, key.as_str())?; + let value = value.value(&mut cx) as i32; + if value == 0 { + // Authorization.ALLOW + auth.allow(&key); + } else if value == 1 { + // Authorization.DENY + auth.deny(&key); + } + } + let auth = auth.build(); + if let Err(err) = conn + .blocking_lock() + .authorizer(Some(Arc::new(move |ctx| auth.authorize(ctx)))) + { + throw_libsql_error(&mut cx, err)?; + } + Ok(cx.undefined()) + } + pub fn js_load_extension(mut cx: FunctionContext) -> JsResult { let db: Handle<'_, JsBox> = cx.this()?; let extension = cx.argument::(0)?.value(&mut cx); diff --git a/src/lib.rs b/src/lib.rs index a493a24..16b8b1f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +mod auth; mod database; mod errors; mod statement; @@ -44,6 +45,7 @@ fn main(mut cx: ModuleContext) -> NeonResult<()> { "databaseDefaultSafeIntegers", Database::js_default_safe_integers, )?; + cx.export_function("databaseAuthorizer", Database::js_authorizer)?; cx.export_function("databaseLoadExtension", Database::js_load_extension)?; cx.export_function( "databaseMaxWriteReplicationIndex", diff --git a/src/statement.rs b/src/statement.rs index 9f0b31d..10214ca 100644 --- a/src/statement.rs +++ b/src/statement.rs @@ -122,7 +122,9 @@ impl Statement { pub fn js_interrupt(mut cx: FunctionContext) -> JsResult { let stmt: Handle<'_, JsBox> = cx.this()?; let mut raw_stmt = stmt.stmt.blocking_lock(); - raw_stmt.interrupt().or_else(|err| throw_libsql_error(&mut cx, err))?; + raw_stmt + .interrupt() + .or_else(|err| throw_libsql_error(&mut cx, err))?; Ok(cx.null()) } diff --git a/types/auth.d.ts b/types/auth.d.ts new file mode 100644 index 0000000..8b6d7bb --- /dev/null +++ b/types/auth.d.ts @@ -0,0 +1,10 @@ +export = Authorization; +/** + * * + */ +type Authorization = number; +declare namespace Authorization { + let ALLOW: number; + let DENY: number; +} +//# sourceMappingURL=auth.d.ts.map \ No newline at end of file diff --git a/types/auth.d.ts.map b/types/auth.d.ts.map new file mode 100644 index 0000000..eb37725 --- /dev/null +++ b/types/auth.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../auth.js"],"names":[],"mappings":";;;;qBAIU,MAAM;;eAOJ,MAAM;cAMN,MAAM"} \ No newline at end of file diff --git a/types/promise.d.ts b/types/promise.d.ts index 6a07a73..c510606 100644 --- a/types/promise.d.ts +++ b/types/promise.d.ts @@ -35,6 +35,7 @@ declare class Database { function(name: any, options: any, fn: any): void; aggregate(name: any, options: any): void; table(name: any, factory: any): void; + authorizer(rules: any): void; loadExtension(...args: any[]): void; maxWriteReplicationIndex(): any; /** @@ -58,7 +59,8 @@ declare class Database { unsafeMode(...args: any[]): void; } declare namespace Database { - export { SqliteError }; + export { Authorization, SqliteError }; } +import Authorization = require("./auth"); import SqliteError = require("./sqlite-error"); //# sourceMappingURL=promise.d.ts.map \ No newline at end of file diff --git a/types/promise.d.ts.map b/types/promise.d.ts.map index 4c630c4..3c4d71e 100644 --- a/types/promise.d.ts.map +++ b/types/promise.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"promise.d.ts","sourceRoot":"","sources":["../promise.js"],"names":[],"mappings":";AA6DA;;GAEG;AACH;IACE;;;;;OAKG;IACH,kBAFW,MAAM,aAoChB;IArBG,QAAmH;IAQrH,gBAAiC;IACjC,kBAAqB;IACrB,aAAc;IACd,cAAgB;IAYlB,YAEC;IAED,sCAEC;IAED;;;;OAIG;IACH,aAFW,MAAM,OAQhB;IAED;;;;OAIG;IACH,sEA8BC;IAED,uCAQC;IAED,0CAEC;IAED,8BAEC;IAED,iDAqBC;IAED,yCAYC;IAED,qCAUC;IAED,oCAEC;IAED,gCAEC;IAED;;;;OAIG;IACH,UAFW,MAAM,OAMhB;IAED;;OAEG;IACH,kBAEC;IAED;;OAEG;IACH,cAEC;IAED;;OAEG;IACH,uCAGC;IAED,iCAEC;CACF"} \ No newline at end of file +{"version":3,"file":"promise.d.ts","sourceRoot":"","sources":["../promise.js"],"names":[],"mappings":";AAgEA;;GAEG;AACH;IACE;;;;;OAKG;IACH,kBAFW,MAAM,aAoChB;IArBG,QAAmH;IAQrH,gBAAiC;IACjC,kBAAqB;IACrB,aAAc;IACd,cAAgB;IAYlB,YAEC;IAED,sCAEC;IAED;;;;OAIG;IACH,aAFW,MAAM,OAQhB;IAED;;;;OAIG;IACH,sEA8BC;IAED,uCAQC;IAED,0CAEC;IAED,8BAEC;IAED,iDAqBC;IAED,yCAYC;IAED,qCAUC;IAED,6BAEC;IAED,oCAEC;IAED,gCAEC;IAED;;;;OAIG;IACH,UAFW,MAAM,OAMhB;IAED;;OAEG;IACH,kBAEC;IAED;;OAEG;IACH,cAEC;IAED;;OAEG;IACH,uCAGC;IAED,iCAEC;CACF"} \ No newline at end of file From 086560ef9a37f1dddcac4b06957354293872718a Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Wed, 23 Apr 2025 13:02:13 +0300 Subject: [PATCH 2/3] Fix js_authorizer() validation for bad rules --- src/database.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/database.rs b/src/database.rs index 5896ae1..2febff9 100644 --- a/src/database.rs +++ b/src/database.rs @@ -393,6 +393,11 @@ impl Database { } else if value == 1 { // Authorization.DENY auth.deny(&key); + } else { + return cx.throw_error(format!( + "Invalid authorization rule value '{}' for table '{}'. Expected 0 (ALLOW) or 1 (DENY).", + value, key + )); } } let auth = auth.build(); From 609a48de9b4d7ba0ad7569ddcfeb9ebdaf31719a Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Wed, 23 Apr 2025 13:02:42 +0300 Subject: [PATCH 3/3] cargo fmt --- src/auth.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 00bba28..acc03ca 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -36,10 +36,7 @@ pub struct Authorizer { } impl Authorizer { - pub fn new( - allow_list: HashSet, - deny_list: HashSet, - ) -> Self { + pub fn new(allow_list: HashSet, deny_list: HashSet) -> Self { Self { allow_list, deny_list,