Skip to content

Commit d18582d

Browse files
committed
make glob field explicit in authorizer api and update docs
1 parent 2da1504 commit d18582d

3 files changed

Lines changed: 142 additions & 26 deletions

File tree

docs/api.md

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,31 +68,112 @@ This function is currently not supported.
6868

6969
This function is currently not supported.
7070

71-
### authorizer(rules) ⇒ this
71+
### authorizer(config) ⇒ this
7272

73-
Configure authorization rules. The `rules` object is a map from table name to
74-
`Authorization` object, which defines if access to table is allowed or denied.
75-
If a table has no authorization rule, access to it is _denied_ by default.
73+
Configure authorization rules for the database. Accepts three formats:
7674

77-
Example:
75+
- **Legacy format** — a map from table name to `Authorization.ALLOW` or `Authorization.DENY`
76+
- **Rule-based format** — an `AuthorizerConfig` object with ordered rules and pattern matching
77+
- **`null`** — removes the authorizer entirely
78+
79+
#### Legacy format
80+
81+
A simple object mapping table names to `Authorization.ALLOW` (0) or `Authorization.DENY` (1).
82+
Tables without an entry are denied by default.
7883

7984
```javascript
85+
const { Authorization } = require('libsql');
86+
8087
db.authorizer({
81-
"users": Authorization.ALLOW
88+
"users": Authorization.ALLOW,
89+
"secrets": Authorization.DENY,
8290
});
8391

84-
// Access is allowed.
92+
// Access to "users" is allowed.
8593
const stmt = db.prepare("SELECT * FROM users");
8694

95+
// Access to "secrets" throws SQLITE_AUTH.
96+
const stmt = db.prepare("SELECT * FROM secrets"); // Error!
97+
```
98+
99+
#### Rule-based format
100+
101+
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`).
102+
103+
```javascript
104+
const { Authorization, Action } = require('libsql');
105+
87106
db.authorizer({
88-
"users": Authorization.DENY
107+
rules: [
108+
// Hide sensitive columns (returns NULL instead of the real value)
109+
{ action: Action.READ, table: "users", column: "password_hash", policy: Authorization.IGNORE },
110+
{ action: Action.READ, table: "users", column: "ssn", policy: Authorization.IGNORE },
111+
112+
// Allow all reads
113+
{ action: Action.READ, policy: Authorization.ALLOW },
114+
115+
// Allow inserts on tables matching a glob pattern
116+
{ action: Action.INSERT, table: { glob: "logs_*" }, policy: Authorization.ALLOW },
117+
118+
// Deny DDL operations
119+
{ action: [Action.CREATE_TABLE, Action.DROP_TABLE, Action.ALTER_TABLE], policy: Authorization.DENY },
120+
121+
// Allow transactions and selects
122+
{ action: Action.TRANSACTION, policy: Authorization.ALLOW },
123+
{ action: Action.SELECT, policy: Authorization.ALLOW },
124+
],
125+
defaultPolicy: Authorization.DENY,
89126
});
127+
```
90128

91-
// Access is denied.
92-
const stmt = db.prepare("SELECT * FROM users");
129+
#### AuthRule fields
130+
131+
| Field | Type | Description |
132+
| -------- | ----------------------------------------- | -------------------------------------------------------------------- |
133+
| action | <code>number \| number[]</code> | Action code(s) to match (from `Action`). Omit to match all actions. |
134+
| table | <code>string \| { glob: string }</code> | Table name pattern. Omit to match any table. |
135+
| column | <code>string \| { glob: string }</code> | Column name pattern (relevant for READ/UPDATE). Omit to match any. |
136+
| entity | <code>string \| { glob: string }</code> | Entity name (index, trigger, view, pragma, function). Omit to match any. |
137+
| policy | <code>number</code> | `Authorization.ALLOW`, `Authorization.DENY`, or `Authorization.IGNORE`. |
138+
139+
#### Pattern matching
140+
141+
Pattern fields (`table`, `column`, `entity`) accept either:
142+
143+
- A **plain string** for exact matching: `"users"`
144+
- An **object with a `glob` key** for glob matching: `{ glob: "logs_*" }`
145+
146+
Glob patterns support `*` (match any number of characters) and `?` (match exactly one character).
147+
148+
```javascript
149+
// Exact match
150+
{ action: Action.READ, table: "users", policy: Authorization.ALLOW }
151+
152+
// Glob: all tables starting with "logs_"
153+
{ action: Action.READ, table: { glob: "logs_*" }, policy: Authorization.ALLOW }
154+
155+
// Glob: single-character wildcard
156+
{ action: Action.READ, table: { glob: "t?_data" }, policy: Authorization.ALLOW }
157+
158+
// Glob: match all tables
159+
{ action: Action.READ, table: { glob: "*" }, policy: Authorization.ALLOW }
93160
```
94161

95-
**Note: This is an experimental API and, therefore, subject to change.**
162+
#### Authorization values
163+
164+
| Value | Effect |
165+
| -------------------------- | ---------------------------------------------------------------------- |
166+
| `Authorization.ALLOW` (0) | Permit the operation. |
167+
| `Authorization.DENY` (1) | Reject the entire SQL statement with a `SQLITE_AUTH` error. |
168+
| `Authorization.IGNORE` (2) | For READ: return NULL instead of the column value. Otherwise: deny. |
169+
170+
#### Removing the authorizer
171+
172+
Pass `null` to remove the authorizer and allow all operations:
173+
174+
```javascript
175+
db.authorizer(null);
176+
```
96177

97178
### loadExtension(path, [entryPoint]) ⇒ this
98179

integration-tests/tests/extensions.test.js

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ test.serial("Rule-based: glob pattern on table name", async (t) => {
133133

134134
db.authorizer({
135135
rules: [
136-
{ action: Action.READ, table: "logs_*", policy: Authorization.ALLOW },
136+
{ action: Action.READ, table: { glob: "logs_*" }, policy: Authorization.ALLOW },
137137
{ action: Action.SELECT, policy: Authorization.ALLOW },
138138
],
139139
defaultPolicy: Authorization.DENY,
@@ -260,7 +260,7 @@ test.serial("Glob: ? matches exactly one character", async (t) => {
260260

261261
db.authorizer({
262262
rules: [
263-
{ action: Action.READ, table: "log_?", policy: Authorization.ALLOW },
263+
{ action: Action.READ, table: { glob: "log_?" }, policy: Authorization.ALLOW },
264264
{ action: Action.SELECT, policy: Authorization.ALLOW },
265265
],
266266
defaultPolicy: Authorization.DENY,
@@ -287,7 +287,7 @@ test.serial("Glob: ? does not match zero or multiple characters", async (t) => {
287287

288288
db.authorizer({
289289
rules: [
290-
{ action: Action.READ, table: "item_?", policy: Authorization.ALLOW },
290+
{ action: Action.READ, table: { glob: "item_?" }, policy: Authorization.ALLOW },
291291
{ action: Action.SELECT, policy: Authorization.ALLOW },
292292
],
293293
defaultPolicy: Authorization.DENY,
@@ -312,7 +312,7 @@ test.serial("Glob: * at start of pattern", async (t) => {
312312

313313
db.authorizer({
314314
rules: [
315-
{ action: Action.READ, table: "*_users", policy: Authorization.ALLOW },
315+
{ action: Action.READ, table: { glob: "*_users" }, policy: Authorization.ALLOW },
316316
{ action: Action.SELECT, policy: Authorization.ALLOW },
317317
],
318318
defaultPolicy: Authorization.DENY,
@@ -330,7 +330,7 @@ test.serial("Glob: * in middle of pattern", async (t) => {
330330

331331
db.authorizer({
332332
rules: [
333-
{ action: Action.READ, table: "app_*_logs", policy: Authorization.ALLOW },
333+
{ action: Action.READ, table: { glob: "app_*_logs" }, policy: Authorization.ALLOW },
334334
{ action: Action.SELECT, policy: Authorization.ALLOW },
335335
],
336336
defaultPolicy: Authorization.DENY,
@@ -353,7 +353,7 @@ test.serial("Glob: multiple wildcards in one pattern", async (t) => {
353353

354354
db.authorizer({
355355
rules: [
356-
{ action: Action.READ, table: "*_data_*", policy: Authorization.ALLOW },
356+
{ action: Action.READ, table: { glob: "*_data_*" }, policy: Authorization.ALLOW },
357357
{ action: Action.SELECT, policy: Authorization.ALLOW },
358358
],
359359
defaultPolicy: Authorization.DENY,
@@ -369,7 +369,7 @@ test.serial("Glob: on column name", async (t) => {
369369
// IGNORE columns matching e* → email gets NULL, everything else readable
370370
db.authorizer({
371371
rules: [
372-
{ action: Action.READ, table: "users", column: "e*", policy: Authorization.IGNORE },
372+
{ action: Action.READ, table: "users", column: { glob: "e*" }, policy: Authorization.IGNORE },
373373
{ action: Action.READ, policy: Authorization.ALLOW },
374374
{ action: Action.SELECT, policy: Authorization.ALLOW },
375375
],
@@ -387,7 +387,7 @@ test.serial("Glob: on entity name (pragma)", async (t) => {
387387

388388
db.authorizer({
389389
rules: [
390-
{ action: Action.PRAGMA, entity: "table_*", policy: Authorization.ALLOW },
390+
{ action: Action.PRAGMA, entity: { glob: "table_*" }, policy: Authorization.ALLOW },
391391
{ action: Action.READ, policy: Authorization.ALLOW },
392392
{ action: Action.SELECT, policy: Authorization.ALLOW },
393393
],
@@ -420,7 +420,7 @@ test.serial("Glob: table + column combo", async (t) => {
420420
// For any table matching user*, IGNORE columns matching e*
421421
db.authorizer({
422422
rules: [
423-
{ action: Action.READ, table: "user*", column: "e*", policy: Authorization.IGNORE },
423+
{ action: Action.READ, table: { glob: "user*" }, column: { glob: "e*" }, policy: Authorization.IGNORE },
424424
{ action: Action.READ, policy: Authorization.ALLOW },
425425
{ action: Action.SELECT, policy: Authorization.ALLOW },
426426
],
@@ -438,7 +438,7 @@ test.serial("Glob: wildcard-only pattern * matches everything", async (t) => {
438438

439439
db.authorizer({
440440
rules: [
441-
{ action: Action.READ, table: "*", policy: Authorization.ALLOW },
441+
{ action: Action.READ, table: { glob: "*" }, policy: Authorization.ALLOW },
442442
{ action: Action.SELECT, policy: Authorization.ALLOW },
443443
],
444444
defaultPolicy: Authorization.DENY,
@@ -453,7 +453,7 @@ test.serial("Glob: pattern with no match denies correctly", async (t) => {
453453

454454
db.authorizer({
455455
rules: [
456-
{ action: Action.READ, table: "nonexistent_*", policy: Authorization.ALLOW },
456+
{ action: Action.READ, table: { glob: "nonexistent_*" }, policy: Authorization.ALLOW },
457457
{ action: Action.SELECT, policy: Authorization.ALLOW },
458458
],
459459
defaultPolicy: Authorization.DENY,

src/lib.rs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,32 @@ impl Database {
426426
/// - Legacy format: `{ [tableName: string]: 0 | 1 }`
427427
/// - Full format: `{ rules: AuthRule[], defaultPolicy?: 0 | 1 | 2 }`
428428
/// - `null` to remove the authorizer
429+
///
430+
/// Pattern fields (`table`, `column`, `entity`) accept a plain string for
431+
/// exact matching, or `{ glob: "pattern" }` for glob matching with `*` and `?`.
432+
///
433+
/// # Examples
434+
///
435+
/// ```javascript
436+
/// const { Authorization, Action } = require('libsql');
437+
///
438+
/// // Legacy table-level allow/deny
439+
/// db.authorizer({ "users": Authorization.ALLOW });
440+
///
441+
/// // Rule-based with glob patterns
442+
/// db.authorizer({
443+
/// rules: [
444+
/// { action: Action.READ, table: "users", column: "password", policy: Authorization.IGNORE },
445+
/// { action: Action.INSERT, table: { glob: "logs_*" }, policy: Authorization.ALLOW },
446+
/// { action: Action.READ, policy: Authorization.ALLOW },
447+
/// { action: Action.SELECT, policy: Authorization.ALLOW },
448+
/// ],
449+
/// defaultPolicy: Authorization.DENY,
450+
/// });
451+
///
452+
/// // Remove authorizer
453+
/// db.authorizer(null);
454+
/// ```
429455
#[napi]
430456
pub fn authorizer(&self, env: Env, config: JsUnknown) -> Result<()> {
431457
let conn = match &self.conn {
@@ -727,20 +753,29 @@ fn parse_single_rule(rule_obj: &napi::JsObject) -> Result<crate::auth::AuthRule>
727753
})
728754
}
729755

730-
/// Parse a pattern value: plain string or glob (auto-detected by `*` or `?`).
756+
/// Parse a pattern value: plain string (exact match) or `{ glob: "pattern" }`.
731757
fn parse_pattern(val: JsUnknown, field_name: &str) -> Result<crate::auth::PatternMatcher> {
732758
match val.get_type()? {
733759
ValueType::String => {
734760
let s: napi::JsString = val.coerce_to_string()?;
735761
let owned = s.into_utf8()?.into_owned()?;
736-
if owned.contains('*') || owned.contains('?') {
762+
Ok(crate::auth::PatternMatcher::Exact(owned))
763+
}
764+
ValueType::Object => {
765+
let obj: napi::JsObject = val.coerce_to_object()?;
766+
if obj.has_named_property("glob")? {
767+
let s: napi::JsString = obj.get_named_property("glob")?;
768+
let owned = s.into_utf8()?.into_owned()?;
737769
Ok(crate::auth::PatternMatcher::Glob(owned))
738770
} else {
739-
Ok(crate::auth::PatternMatcher::Exact(owned))
771+
Err(napi::Error::from_reason(format!(
772+
"{} must be a string or {{ glob: \"pattern\" }}",
773+
field_name
774+
)))
740775
}
741776
}
742777
_ => Err(napi::Error::from_reason(format!(
743-
"{} must be a string",
778+
"{} must be a string or {{ glob: \"pattern\" }}",
744779
field_name
745780
))),
746781
}

0 commit comments

Comments
 (0)