Skip to content

Commit 51d9df6

Browse files
authored
Add Database.authorizer() API (#180)
2 parents 0db0b14 + 609a48d commit 51d9df6

14 files changed

+246
-3
lines changed

auth.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Authorization outcome.
3+
*
4+
* @readonly
5+
* @enum {number}
6+
* @property {number} ALLOW - Allow access to a resource.
7+
* @property {number} DENY - Deny access to a resource and throw an error.
8+
*/
9+
const Authorization = {
10+
/**
11+
* Allow access to a resource.
12+
* @type {number}
13+
*/
14+
ALLOW: 0,
15+
16+
/**
17+
* Deny access to a resource and throw an error in `prepare()`.
18+
* @type {number}
19+
*/
20+
DENY: 1,
21+
};
22+
module.exports = Authorization;

docs/api.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,32 @@ This function is currently not supported.
6767

6868
This function is currently not supported.
6969

70+
### authorizer(rules) ⇒ this
71+
72+
Configure authorization rules. The `rules` object is a map from table name to
73+
`Authorization` object, which defines if access to table is allowed or denied.
74+
If a table has no authorization rule, access to it is _denied_ by default.
75+
76+
Example:
77+
78+
```javascript
79+
db.authorizer({
80+
"users": Authorization.ALLOW
81+
});
82+
83+
// Access is allowed.
84+
const stmt = db.prepare("SELECT * FROM users");
85+
86+
db.authorizer({
87+
"users": Authorization.DENY
88+
});
89+
90+
// Access is denied.
91+
const stmt = db.prepare("SELECT * FROM users");
92+
```
93+
94+
**Note: This is an experimental API and, therefore, subject to change.**
95+
7096
### loadExtension(path, [entryPoint]) ⇒ this
7197

7298
Loads a SQLite3 extension

index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const {
3333
databaseExecSync,
3434
databasePrepareSync,
3535
databaseDefaultSafeIntegers,
36+
databaseAuthorizer,
3637
databaseLoadExtension,
3738
databaseMaxWriteReplicationIndex,
3839
statementRaw,
@@ -46,6 +47,7 @@ const {
4647
rowsNext,
4748
} = requireNative();
4849

50+
const Authorization = require("./auth");
4951
const SqliteError = require("./sqlite-error");
5052

5153
function convertError(err) {
@@ -227,6 +229,10 @@ class Database {
227229
throw new Error("not implemented");
228230
}
229231

232+
authorizer(rules) {
233+
databaseAuthorizer.call(this.db, rules);
234+
}
235+
230236
loadExtension(...args) {
231237
databaseLoadExtension.call(this.db, ...args);
232238
}
@@ -425,4 +431,5 @@ class Statement {
425431
}
426432

427433
module.exports = Database;
434+
module.exports.Authorization = Authorization;
428435
module.exports.SqliteError = SqliteError;

integration-tests/tests/extensions.test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import test from "ava";
2+
import { Authorization } from "libsql";
23

34
test.serial("Statement.run() returning duration", async (t) => {
45
const db = t.context.db;
@@ -18,6 +19,32 @@ test.serial("Statement.get() returning duration", async (t) => {
1819
t.log(info._metadata?.duration)
1920
});
2021

22+
test.serial("Database.authorizer()/allow", async (t) => {
23+
const db = t.context.db;
24+
25+
db.authorizer({
26+
"users": Authorization.ALLOW
27+
});
28+
29+
const stmt = db.prepare("SELECT * FROM users");
30+
const users = stmt.all();
31+
t.is(users.length, 2);
32+
});
33+
34+
test.serial("Database.authorizer()/deny", async (t) => {
35+
const db = t.context.db;
36+
37+
db.authorizer({
38+
"users": Authorization.DENY
39+
});
40+
await t.throwsAsync(async () => {
41+
return await db.prepare("SELECT * FROM users");
42+
}, {
43+
instanceOf: t.context.errorType,
44+
code: "SQLITE_AUTH"
45+
});
46+
});
47+
2148
const connect = async (path_opt) => {
2249
const path = path_opt ?? "hello.db";
2350
const x = await import("libsql");

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"main": "index.js",
1616
"types": "types/index.d.ts",
1717
"files": [
18+
"auth.js",
1819
"index.js",
1920
"sqlite-error.js",
2021
"promise.js",

promise.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ if (0) {
88
require("./.targets");
99
}
1010

11+
const Authorization = require("./auth");
1112
const SqliteError = require("./sqlite-error");
1213

1314
function convertError(err) {
@@ -48,6 +49,7 @@ const {
4849
databasePrepareAsync,
4950
databaseMaxWriteReplicationIndex,
5051
databaseDefaultSafeIntegers,
52+
databaseAuthorizer,
5153
databaseLoadExtension,
5254
statementRaw,
5355
statementIsReader,
@@ -231,6 +233,10 @@ class Database {
231233
throw new Error("not implemented");
232234
}
233235

236+
authorizer(rules) {
237+
databaseAuthorizer.call(this.db, rules);
238+
}
239+
234240
loadExtension(...args) {
235241
databaseLoadExtension.call(this.db, ...args);
236242
}
@@ -429,4 +435,5 @@ class Statement {
429435
}
430436

431437
module.exports = Database;
438+
module.exports.Authorization = Authorization;
432439
module.exports.SqliteError = SqliteError;

src/auth.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
use tracing::trace;
2+
3+
use std::collections::HashSet;
4+
5+
pub struct AuthorizerBuilder {
6+
allow_list: HashSet<String>,
7+
deny_list: HashSet<String>,
8+
}
9+
10+
impl AuthorizerBuilder {
11+
pub fn new() -> Self {
12+
Self {
13+
allow_list: HashSet::new(),
14+
deny_list: HashSet::new(),
15+
}
16+
}
17+
18+
pub fn allow(&mut self, table: &str) -> &mut Self {
19+
self.allow_list.insert(table.to_string());
20+
self
21+
}
22+
23+
pub fn deny(&mut self, table: &str) -> &mut Self {
24+
self.deny_list.insert(table.to_string());
25+
self
26+
}
27+
28+
pub fn build(self) -> Authorizer {
29+
Authorizer::new(self.allow_list, self.deny_list)
30+
}
31+
}
32+
33+
pub struct Authorizer {
34+
allow_list: HashSet<String>,
35+
deny_list: HashSet<String>,
36+
}
37+
38+
impl Authorizer {
39+
pub fn new(allow_list: HashSet<String>, deny_list: HashSet<String>) -> Self {
40+
Self {
41+
allow_list,
42+
deny_list,
43+
}
44+
}
45+
46+
pub fn authorize(&self, ctx: &libsql::AuthContext) -> libsql::Authorization {
47+
use libsql::AuthAction;
48+
let ret = match ctx.action {
49+
AuthAction::Unknown { .. } => libsql::Authorization::Deny,
50+
AuthAction::CreateIndex { table_name, .. } => self.authorize_table(table_name),
51+
AuthAction::CreateTable { table_name, .. } => self.authorize_table(table_name),
52+
AuthAction::CreateTempIndex { table_name, .. } => self.authorize_table(table_name),
53+
AuthAction::CreateTempTable { table_name, .. } => self.authorize_table(table_name),
54+
AuthAction::CreateTempTrigger { table_name, .. } => self.authorize_table(table_name),
55+
AuthAction::CreateTempView { .. } => libsql::Authorization::Deny,
56+
AuthAction::CreateTrigger { table_name, .. } => self.authorize_table(table_name),
57+
AuthAction::CreateView { .. } => libsql::Authorization::Deny,
58+
AuthAction::Delete { table_name, .. } => self.authorize_table(table_name),
59+
AuthAction::DropIndex { table_name, .. } => self.authorize_table(table_name),
60+
AuthAction::DropTable { table_name, .. } => self.authorize_table(table_name),
61+
AuthAction::DropTempIndex { table_name, .. } => self.authorize_table(table_name),
62+
AuthAction::DropTempTable { table_name, .. } => self.authorize_table(table_name),
63+
AuthAction::DropTempTrigger { table_name, .. } => self.authorize_table(table_name),
64+
AuthAction::DropTempView { .. } => libsql::Authorization::Deny,
65+
AuthAction::DropTrigger { .. } => libsql::Authorization::Deny,
66+
AuthAction::DropView { .. } => libsql::Authorization::Deny,
67+
AuthAction::Insert { table_name, .. } => self.authorize_table(table_name),
68+
AuthAction::Pragma { .. } => libsql::Authorization::Deny,
69+
AuthAction::Read { table_name, .. } => self.authorize_table(table_name),
70+
AuthAction::Select { .. } => libsql::Authorization::Allow,
71+
AuthAction::Transaction { .. } => libsql::Authorization::Deny,
72+
AuthAction::Update { table_name, .. } => self.authorize_table(table_name),
73+
AuthAction::Attach { .. } => libsql::Authorization::Deny,
74+
AuthAction::Detach { .. } => libsql::Authorization::Deny,
75+
AuthAction::AlterTable { table_name, .. } => self.authorize_table(table_name),
76+
AuthAction::Reindex { .. } => libsql::Authorization::Deny,
77+
AuthAction::Analyze { .. } => libsql::Authorization::Deny,
78+
AuthAction::CreateVtable { .. } => libsql::Authorization::Deny,
79+
AuthAction::DropVtable { .. } => libsql::Authorization::Deny,
80+
AuthAction::Function { .. } => libsql::Authorization::Deny,
81+
AuthAction::Savepoint { .. } => libsql::Authorization::Deny,
82+
AuthAction::Recursive { .. } => libsql::Authorization::Deny,
83+
};
84+
trace!("authorize(ctx = {:?}) -> {:?}", ctx, ret);
85+
ret
86+
}
87+
88+
fn authorize_table(&self, table: &str) -> libsql::Authorization {
89+
if self.deny_list.contains(table) {
90+
return libsql::Authorization::Deny;
91+
}
92+
if self.allow_list.contains(table) {
93+
return libsql::Authorization::Allow;
94+
}
95+
libsql::Authorization::Deny
96+
}
97+
}

src/database.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::time::Duration;
77
use tokio::sync::Mutex;
88
use tracing::trace;
99

10+
use crate::auth::AuthorizerBuilder;
1011
use crate::errors::{throw_database_closed_error, throw_libsql_error};
1112
use crate::runtime;
1213
use crate::Statement;
@@ -371,6 +372,44 @@ impl Database {
371372
self.default_safe_integers.replace(toggle);
372373
}
373374

375+
pub fn js_authorizer(mut cx: FunctionContext) -> JsResult<JsUndefined> {
376+
let db: Handle<'_, JsBox<Database>> = cx.this()?;
377+
let rules_obj = cx.argument::<JsObject>(0)?;
378+
let conn = match db.get_conn(&mut cx) {
379+
Some(conn) => conn,
380+
None => throw_database_closed_error(&mut cx)?,
381+
};
382+
let mut auth = AuthorizerBuilder::new();
383+
let prop_names: Handle<JsArray> = rules_obj.get_own_property_names(&mut cx)?;
384+
let prop_len = prop_names.len(&mut cx);
385+
for i in 0..prop_len {
386+
let key_js = prop_names.get::<JsString, _, _>(&mut cx, i)?;
387+
let key: String = key_js.to_string(&mut cx)?.value(&mut cx);
388+
let value = rules_obj.get::<JsNumber, _, _>(&mut cx, key.as_str())?;
389+
let value = value.value(&mut cx) as i32;
390+
if value == 0 {
391+
// Authorization.ALLOW
392+
auth.allow(&key);
393+
} else if value == 1 {
394+
// Authorization.DENY
395+
auth.deny(&key);
396+
} else {
397+
return cx.throw_error(format!(
398+
"Invalid authorization rule value '{}' for table '{}'. Expected 0 (ALLOW) or 1 (DENY).",
399+
value, key
400+
));
401+
}
402+
}
403+
let auth = auth.build();
404+
if let Err(err) = conn
405+
.blocking_lock()
406+
.authorizer(Some(Arc::new(move |ctx| auth.authorize(ctx))))
407+
{
408+
throw_libsql_error(&mut cx, err)?;
409+
}
410+
Ok(cx.undefined())
411+
}
412+
374413
pub fn js_load_extension(mut cx: FunctionContext) -> JsResult<JsUndefined> {
375414
let db: Handle<'_, JsBox<Database>> = cx.this()?;
376415
let extension = cx.argument::<JsString>(0)?.value(&mut cx);

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod auth;
12
mod database;
23
mod errors;
34
mod statement;
@@ -44,6 +45,7 @@ fn main(mut cx: ModuleContext) -> NeonResult<()> {
4445
"databaseDefaultSafeIntegers",
4546
Database::js_default_safe_integers,
4647
)?;
48+
cx.export_function("databaseAuthorizer", Database::js_authorizer)?;
4749
cx.export_function("databaseLoadExtension", Database::js_load_extension)?;
4850
cx.export_function(
4951
"databaseMaxWriteReplicationIndex",

src/statement.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ impl Statement {
122122
pub fn js_interrupt(mut cx: FunctionContext) -> JsResult<JsNull> {
123123
let stmt: Handle<'_, JsBox<Statement>> = cx.this()?;
124124
let mut raw_stmt = stmt.stmt.blocking_lock();
125-
raw_stmt.interrupt().or_else(|err| throw_libsql_error(&mut cx, err))?;
125+
raw_stmt
126+
.interrupt()
127+
.or_else(|err| throw_libsql_error(&mut cx, err))?;
126128
Ok(cx.null())
127129
}
128130

types/auth.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export = Authorization;
2+
/**
3+
* *
4+
*/
5+
type Authorization = number;
6+
declare namespace Authorization {
7+
let ALLOW: number;
8+
let DENY: number;
9+
}
10+
//# sourceMappingURL=auth.d.ts.map

types/auth.d.ts.map

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change

types/promise.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ declare class Database {
3535
function(name: any, options: any, fn: any): void;
3636
aggregate(name: any, options: any): void;
3737
table(name: any, factory: any): void;
38+
authorizer(rules: any): void;
3839
loadExtension(...args: any[]): void;
3940
maxWriteReplicationIndex(): any;
4041
/**
@@ -58,7 +59,8 @@ declare class Database {
5859
unsafeMode(...args: any[]): void;
5960
}
6061
declare namespace Database {
61-
export { SqliteError };
62+
export { Authorization, SqliteError };
6263
}
64+
import Authorization = require("./auth");
6365
import SqliteError = require("./sqlite-error");
6466
//# sourceMappingURL=promise.d.ts.map

types/promise.d.ts.map

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change

0 commit comments

Comments
 (0)