diff --git a/.gitignore b/.gitignore
index 72c0e1a..3bfd06a 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/Cargo.toml b/Cargo.toml
index 8ddeac7..9e4de83 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,7 @@ 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"
diff --git a/auth.js b/auth.js
index 1766f6b..7b11622 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 374a741..8cac2d8 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/docs/api.md b/docs/api.md
index 46116cb..0d203b4 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/index.d.ts b/index.d.ts
index 367e13e..dec5287 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 90fc3bb..1ff1010 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,425 @@ 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: { glob: "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: 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: "upper", 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: { glob: "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: { glob: "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: { glob: "*_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: { glob: "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: { glob: "*_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: { glob: "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: { glob: "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);
+});
+
+test.serial("Glob: table + column combo", async (t) => {
+ const db = t.context.db;
+
+ // For any table matching user*, IGNORE columns matching e*
+ db.authorizer({
+ rules: [
+ { action: Action.READ, table: { glob: "user*" }, column: { glob: "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 = 2").get();
+ t.is(row.id, 2);
+ t.is(row.name, "Bob");
+ t.is(row.email, null); // email matches e*, users matches user*
+});
+
+test.serial("Glob: wildcard-only pattern * matches everything", async (t) => {
+ const db = t.context.db;
+
+ db.authorizer({
+ rules: [
+ { action: Action.READ, table: { glob: "*" }, 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: { glob: "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 +477,15 @@ 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 products;
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)
`);
db.exec(
diff --git a/promise.js b/promise.js
index f364013..50214e6 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,
diff --git a/src/auth.rs b/src/auth.rs
index acc03ca..4cbdd57 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -1,7 +1,374 @@
-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),
+}
+
+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),
+ }
+ }
+}
+
+/// 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 +392,68 @@ 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![
+ 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,
+ ];
-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![SQLITE_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 61e1581..0f2142c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -422,58 +422,64 @@ impl Database {
/// Sets the authorizer for the database.
///
- /// # Arguments
+ /// Accepts either:
+ /// - Legacy format: `{ [tableName: string]: 0 | 1 }`
+ /// - Full format: `{ rules: AuthRule[], defaultPolicy?: 0 | 1 | 2 }`
+ /// - `null` to remove the authorizer
///
- /// * `env` - The environment.
- /// * `rules_obj` - The rules object.
+ /// Pattern fields (`table`, `column`, `entity`) accept a plain string for
+ /// exact matching, or `{ glob: "pattern" }` for glob matching with `*` and `?`.
///
- /// The `rules_obj` is a JavaScript object with the following properties:
+ /// # Examples
///
- /// * `Authorization.ALLOW` - Allow access to the table.
- /// * `Authorization.DENY` - Deny access to the table.
+ /// ```javascript
+ /// const { Authorization, Action } = require('libsql');
///
- /// Example:
+ /// // Legacy table-level allow/deny
+ /// db.authorizer({ "users": Authorization.ALLOW });
///
- /// ```javascript
+ /// // Rule-based with glob patterns
/// db.authorizer({
- /// "users": Authorization.ALLOW
+ /// 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, 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 +625,162 @@ 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: 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()?;
+ 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 {
+ 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 or {{ glob: \"pattern\" }}",
+ field_name
+ ))),
+ }
+}
+
/// Result of a database sync operation.
#[napi(object)]
pub struct SyncResult {