simple-authz 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,23 +1,5 @@
1
- Your README is already strong — we just need to **upgrade it to reflect v1.1.0 features** without breaking its structure.
2
-
3
- I’ll **edit and improve your version**, not rewrite from scratch.
4
-
5
- ---
6
-
7
- # ✅ Updated README (v1.1.0 Improvements Applied)
8
-
9
- Below is your improved version with:
10
-
11
- * ✔ middleware added
12
- * ✔ caching mentioned
13
- * ✔ TypeScript support
14
- * ✔ `authz.explain()` refined
15
- * ✔ Quick Start fixed (you exported instance, not class ⚠️)
16
- * ✔ small clarity + professionalism upgrades
17
-
18
1
  ---
19
2
 
20
- ````markdown
21
3
  # Simple Authz
22
4
 
23
5
  A lightweight and flexible **authorization engine for Node.js** built around a simple policy language called **TOON (Token-Oriented Object Notation)**.
@@ -70,7 +52,7 @@ Most applications implement authorization like this:
70
52
  if(user.role === "admin") { ... }
71
53
  if(user.id === listing.owner_id) { ... }
72
54
  if(user.permissions.includes("edit_listing")) { ... }
73
- ````
55
+ ```
74
56
 
75
57
  Over time this logic spreads across many files and becomes difficult to maintain.
76
58
 
@@ -207,6 +189,26 @@ app.post(
207
189
  );
208
190
  ```
209
191
 
192
+ > ⚠️ **Security note:** by default the middleware checks against `req.body`.
193
+ > That's fine for actions where the body *is* the thing being created, but
194
+ > if your policy has conditions like `listing.owner_id == user.id`, a client
195
+ > can put whatever `owner_id` it wants in its own request body and bypass
196
+ > the check. Pass a third argument — an (optionally async) function that
197
+ > loads the **real** resource from your database — whenever a condition
198
+ > depends on data the client shouldn't be trusted to supply:
199
+ >
200
+ > ```javascript
201
+ > app.post(
202
+ > "/listing/:id",
203
+ > authz.middleware("edit", "listing", async (req) => {
204
+ > return await db.listings.findById(req.params.id);
205
+ > }),
206
+ > (req, res) => {
207
+ > res.send("Allowed");
208
+ > }
209
+ > );
210
+ > ```
211
+
210
212
  ---
211
213
 
212
214
  # Core Concepts
@@ -235,6 +237,15 @@ rule
235
237
  end
236
238
  ```
237
239
 
240
+ The parser is strict on purpose: a stray `end`, a `rule` opened twice without
241
+ closing it, an unrecognized key (e.g. a typo'd `roel`), or a key with no
242
+ value all throw a clear error with the line number, instead of silently
243
+ dropping or mis-parsing part of your policy. `authz.load()` runs full
244
+ validation too — every condition is actually compiled during validation, so
245
+ a broken condition (missing operand, mixed `AND`/`OR`, etc.) is reported
246
+ against its rule number at load time rather than failing later, mid-request,
247
+ somewhere inside the evaluator.
248
+
238
249
  ---
239
250
 
240
251
  # Wildcards
@@ -254,6 +265,18 @@ Example:
254
265
  condition listing.owner_id == user.id AND listing.status != "published"
255
266
  ```
256
267
 
268
+ Chains of any length work: `a == 1 AND b == 2 AND c == 3`. You can also
269
+ compare against numbers and booleans directly (`listing.views > 100`), not
270
+ just strings.
271
+
272
+ Only one operator type per condition — `AND` and `OR` can't be mixed in the
273
+ same condition (`a == 1 AND b == 2 OR c == 3` throws an error). Split that
274
+ into two separate rules instead.
275
+
276
+ String literals are quote-safe: a value like
277
+ `doc.title == "Terms AND Conditions"` is treated as one literal, not
278
+ accidentally split on the "AND" inside it.
279
+
257
280
  ---
258
281
 
259
282
  # Policy Examples
@@ -341,7 +364,33 @@ project/
341
364
  * Rules compiled into indexed structure
342
365
  * AST-based condition evaluation
343
366
  * O(1) permission lookup
344
- * Built-in caching layer
367
+ * Built-in LRU cache with TTL-based expiration, keyed by user + action +
368
+ resource + object id (falls back to a data snapshot if there's no id)
369
+
370
+ **Caching caveat:** because the cache keys on object *identity* (id/owner_id),
371
+ if a resource's *other* fields change while its id stays the same (e.g. a
372
+ document gets locked) within the TTL window, a stale decision can be served.
373
+ Call `authz.clearCache()` right after any write that could flip a condition's
374
+ outcome, or lower `cacheTTL` / set `cacheEnabled: false` for policies with
375
+ conditions on fast-changing fields:
376
+
377
+ ```javascript
378
+ const authz = new Authz({ cacheEnabled: true, cacheSize: 1000, cacheTTL: 60 });
379
+ ```
380
+
381
+ ---
382
+
383
+ # CLI
384
+
385
+ ```bash
386
+ npx simple-authz validate ./policies/authz.toon
387
+ npx simple-authz check ./policies/authz.toon --user '{"id":1,"roles":["user"]}' --action edit --resource listing --context '{"owner_id":1}'
388
+ npx simple-authz benchmark ./policies/authz.toon --iterations 10000
389
+ ```
390
+
391
+ * `validate` — parses and validates a policy file, lists roles found
392
+ * `check` — runs `authz.explain()` against a policy and prints the decision
393
+ * `benchmark` — runs repeated checks and prints throughput + cache stats
345
394
 
346
395
  ---
347
396
 
@@ -360,7 +409,6 @@ rule exists → allow
360
409
 
361
410
  ### v1.2
362
411
 
363
- * CLI tool
364
412
  * Role hierarchy
365
413
  * Modular policies
366
414
 
@@ -384,11 +432,4 @@ rule exists → allow
384
432
 
385
433
  Apache 2.0
386
434
 
387
- ---
388
-
389
- # Keywords
390
-
391
- authorization, rbac, abac, nodejs, security, policy-engine
392
-
393
- ````
394
-
435
+ ---
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { program } = require('commander');
4
+ const Authz = require('../../lib/core/authz');
5
+ const chalk = require('chalk'); // for colored output
6
+ const path = require('path');
7
+
8
+ program.version('1.1.0');
9
+
10
+ program
11
+ .command('validate <policyFile>')
12
+ .description('Validate a TOON policy file')
13
+ .action((policyFile) => {
14
+ try {
15
+ const authz = new Authz();
16
+ authz.load(policyFile);
17
+
18
+ const rules = authz.getRules();
19
+ console.log(chalk.green('✓ Policy file is valid\n'));
20
+ console.log(` File: ${chalk.gray(path.resolve(policyFile))}`);
21
+ console.log(` Rules loaded: ${chalk.blue(rules.length)}`);
22
+ console.log(` Roles: ${chalk.blue([...new Set(rules.map(r => r.role))].join(', '))}`);
23
+ console.log();
24
+ } catch (err) {
25
+ console.error(chalk.red('✗ Validation failed\n'));
26
+ console.error(` ${chalk.red(err.message)}`);
27
+ process.exit(1);
28
+ }
29
+ });
30
+
31
+ program
32
+ .command('check <policyFile>')
33
+ .requiredOption('--user <json>', 'User object as JSON')
34
+ .requiredOption('--action <action>', 'Action to check')
35
+ .requiredOption('--resource <resource>', 'Resource type')
36
+ .option('--context <json>', 'Context/object as JSON')
37
+ .description('Test an authorization decision')
38
+ .action((policyFile, options) => {
39
+ try {
40
+ const authz = new Authz({ debugMode: true });
41
+ authz.load(policyFile);
42
+
43
+ const user = JSON.parse(options.user);
44
+ const context = options.context ? JSON.parse(options.context) : {};
45
+
46
+ const result = authz.explain(user, options.action, options.resource, context);
47
+
48
+ console.log();
49
+ if (result.allowed) {
50
+ console.log(chalk.green('✓ ALLOWED\n'));
51
+ } else {
52
+ console.log(chalk.red('✗ DENIED\n'));
53
+ }
54
+
55
+ const userRoles = user.roles || (user.role ? [user.role] : []);
56
+
57
+ console.log(` User ID: ${chalk.blue(user.id)}`);
58
+ console.log(` User roles: ${chalk.blue(userRoles.join(', '))}`);
59
+ console.log(` Action: ${chalk.blue(options.action)}`);
60
+ console.log(` Resource: ${chalk.blue(options.resource)}`);
61
+ console.log(` Reason: ${chalk.yellow(result.reason)}`);
62
+
63
+ if (result.ruleMatched) {
64
+ console.log(` Rule matched: ${chalk.green(result.ruleMatched)}`);
65
+ }
66
+ if (result.evaluationTime) {
67
+ console.log(` Eval time: ${chalk.gray(result.evaluationTime + 'ms')}`);
68
+ }
69
+ console.log();
70
+
71
+ process.exit(result.allowed ? 0 : 1);
72
+ } catch (err) {
73
+ console.error(chalk.red('✗ Check failed\n'));
74
+ console.error(` ${chalk.red(err.message)}`);
75
+ process.exit(1);
76
+ }
77
+ });
78
+
79
+ program
80
+ .command('benchmark <policyFile>')
81
+ .option('--iterations <n>', 'Number of checks to run', '10000')
82
+ .description('Benchmark authorization performance')
83
+ .action((policyFile, options) => {
84
+ const iterations = parseInt(options.iterations);
85
+
86
+ try {
87
+ const authz = new Authz({ cacheEnabled: true });
88
+ authz.load(policyFile);
89
+
90
+ const user = { id: 1, roles: ['admin', 'user'] };
91
+ const object = { owner_id: 1, status: 'active' };
92
+
93
+ // Warmup
94
+ for (let i = 0; i < 10; i++) {
95
+ authz.can(user, 'edit', 'resource', object);
96
+ }
97
+
98
+ // Benchmark
99
+ const start = process.hrtime.bigint();
100
+ for (let i = 0; i < iterations; i++) {
101
+ authz.can(user, 'edit', 'resource', object);
102
+ }
103
+ const end = process.hrtime.bigint();
104
+
105
+ const timeMs = Number(end - start) / 1_000_000;
106
+ const checksPerSec = Math.round((iterations / timeMs) * 1000);
107
+
108
+ console.log();
109
+ console.log(chalk.cyan('Performance Benchmark'));
110
+ console.log('─'.repeat(40));
111
+ console.log(` Iterations: ${chalk.blue(iterations.toLocaleString())}`);
112
+ console.log(` Total time: ${chalk.blue(timeMs.toFixed(2))} ms`);
113
+ console.log(` Avg per check: ${chalk.blue((timeMs / iterations).toFixed(4))} ms`);
114
+ console.log(` Checks/sec: ${chalk.green(checksPerSec.toLocaleString())}`);
115
+
116
+ const stats = authz.cacheStats();
117
+ console.log();
118
+ console.log(chalk.cyan('Cache Statistics'));
119
+ console.log('─'.repeat(40));
120
+ console.log(` Hit rate: ${chalk.blue(stats.hitRate)}`);
121
+ console.log(` Cache size: ${chalk.blue(stats.size)}/${stats.maxSize}`);
122
+ console.log(` Hits: ${chalk.green(stats.hits.toLocaleString())}`);
123
+ console.log(` Misses: ${chalk.yellow(stats.misses.toLocaleString())}`);
124
+ console.log();
125
+ } catch (err) {
126
+ console.error(chalk.red('✗ Benchmark failed\n'));
127
+ console.error(` ${chalk.red(err.message)}`);
128
+ process.exit(1);
129
+ }
130
+ });
131
+
132
+ program.parse();
package/lib/cache.js CHANGED
@@ -12,7 +12,12 @@ class AuthzCache {
12
12
  this.enabled = options.enabled !== false;
13
13
 
14
14
  this.cache = new Map();
15
- this.stats = {
15
+ // Named _stats (not `stats`) because a class method `stats()` exists
16
+ // below — an instance property named the same as a prototype method
17
+ // shadows it, so `cache.stats()` would throw "not a function" the
18
+ // moment anyone actually called it (this went uncaught only because
19
+ // the cache itself was never wired into Authz until now).
20
+ this._stats = {
16
21
  hits: 0,
17
22
  misses: 0,
18
23
  evictions: 0,
@@ -22,10 +27,19 @@ class AuthzCache {
22
27
 
23
28
  /**
24
29
  * Generate cache key from request parameters
25
- * Format: {userId}:{action}:{resource}:{objectId or 'null'}
30
+ * Format: {userIdentity}:{action}:{resource}:{objectId or 'null'}
31
+ *
32
+ * Falls back to role(s) when there's no `user.id` — otherwise every
33
+ * id-less user (e.g. identified only by `user.role`/`user.roles`, which
34
+ * this library explicitly supports) would collide into the same cache
35
+ * key and could get served another user's cached decision.
26
36
  */
27
37
  _key(user, action, resource, objectId) {
28
- return `${user.id}:${action}:${resource}:${objectId || 'null'}`;
38
+ const identity = user && user.id != null
39
+ ? user.id
40
+ : `roles:${JSON.stringify((user && (user.roles || user.role)) || null)}`;
41
+
42
+ return `${identity}:${action}:${resource}:${objectId || 'null'}`;
29
43
  }
30
44
 
31
45
  /**
@@ -41,7 +55,7 @@ class AuthzCache {
41
55
  const entry = this.cache.get(key);
42
56
 
43
57
  if (!entry) {
44
- this.stats.misses++;
58
+ this._stats.misses++;
45
59
  return null;
46
60
  }
47
61
 
@@ -49,14 +63,14 @@ class AuthzCache {
49
63
  const age = (Date.now() - entry.timestamp) / 1000;
50
64
  if (age > this.ttl) {
51
65
  this.cache.delete(key);
52
- this.stats.timeouts++;
53
- this.stats.misses++;
66
+ this._stats.timeouts++;
67
+ this._stats.misses++;
54
68
  return null;
55
69
  }
56
70
 
57
71
  // Hit!
58
72
  entry.lastAccess = Date.now();
59
- this.stats.hits++;
73
+ this._stats.hits++;
60
74
  return entry.result;
61
75
  }
62
76
 
@@ -82,7 +96,7 @@ class AuthzCache {
82
96
 
83
97
  if (oldestKey) {
84
98
  this.cache.delete(oldestKey);
85
- this.stats.evictions++;
99
+ this._stats.evictions++;
86
100
  }
87
101
  }
88
102
 
@@ -102,21 +116,21 @@ class AuthzCache {
102
116
  clear() {
103
117
  const before = this.cache.size;
104
118
  this.cache.clear();
105
- return { cleared: before, stats: this.stats };
119
+ return { cleared: before, stats: this._stats };
106
120
  }
107
121
 
108
122
  /**
109
123
  * Get cache statistics
110
124
  */
111
125
  stats() {
112
- const total = this.stats.hits + this.stats.misses;
113
- const hitRate = total === 0 ? 0 : ((this.stats.hits / total) * 100).toFixed(2);
126
+ const total = this._stats.hits + this._stats.misses;
127
+ const hitRate = total === 0 ? 0 : ((this._stats.hits / total) * 100).toFixed(2);
114
128
 
115
129
  return {
116
- hits: this.stats.hits,
117
- misses: this.stats.misses,
118
- evictions: this.stats.evictions,
119
- timeouts: this.stats.timeouts,
130
+ hits: this._stats.hits,
131
+ misses: this._stats.misses,
132
+ evictions: this._stats.evictions,
133
+ timeouts: this._stats.timeouts,
120
134
  hitRate: hitRate + '%',
121
135
  size: this.cache.size,
122
136
  maxSize: this.maxSize,
@@ -128,7 +142,7 @@ class AuthzCache {
128
142
  * Reset statistics (for testing/monitoring)
129
143
  */
130
144
  resetStats() {
131
- this.stats = { hits: 0, misses: 0, evictions: 0, timeouts: 0 };
145
+ this._stats = { hits: 0, misses: 0, evictions: 0, timeouts: 0 };
132
146
  }
133
147
  }
134
148
 
package/lib/core/authz.js CHANGED
@@ -4,13 +4,29 @@ const { validateRules } = require("../rules/validator")
4
4
  const { normalizeUser } = require("../utils/normalizeUser")
5
5
  const { evaluateAST } = require("../engine/conditionExecutor")
6
6
  const createMiddleware = require("./middleware")
7
+ const AuthzCache = require("../cache")
7
8
 
8
9
  class Authz {
9
10
 
10
- constructor() {
11
+ /**
12
+ * @param {object} [options]
13
+ * @param {boolean} [options.cacheEnabled=true]
14
+ * @param {number} [options.cacheSize=1000]
15
+ * @param {number} [options.cacheTTL=3600] seconds
16
+ */
17
+ constructor(options = {}) {
11
18
  this.permissions = {}
12
19
  this.conditions = {}
13
- this.cache = new Map()
20
+ this.rules = []
21
+ // Previously this was a plain Map keyed by JSON.stringify(...) that
22
+ // was checked in can() but never written to (no .set() call), so
23
+ // caching silently never happened. lib/cache.js already had a full
24
+ // LRU+TTL cache implementation sitting unused — wiring it in here.
25
+ this.cache = new AuthzCache({
26
+ enabled: options.cacheEnabled !== false,
27
+ maxSize: options.cacheSize || 1000,
28
+ ttl: options.cacheTTL || 3600
29
+ })
14
30
  }
15
31
 
16
32
  load(filePath) {
@@ -20,19 +36,55 @@ class Authz {
20
36
 
21
37
  const compiled = compileRules(rules)
22
38
 
39
+ this.rules = rules
23
40
  this.permissions = compiled.permissions
24
41
  this.conditions = compiled.conditions
42
+
43
+ // Stale decisions from a previous policy must not survive a reload.
44
+ this.cache.clear()
45
+ }
46
+
47
+ getRules() {
48
+ return this.rules
49
+ }
50
+
51
+ cacheStats() {
52
+ return this.cache.stats()
53
+ }
54
+
55
+ /**
56
+ * Manually invalidate cached decisions. Caching keys on id/owner_id (or a
57
+ * snapshot of the data if neither exists) — so if a resource's OTHER
58
+ * fields change (e.g. a document gets locked) while its id stays the
59
+ * same, a cached decision from before that change can still be served
60
+ * until the TTL expires. Call this right after any write that could
61
+ * affect a condition's outcome, or set `cacheTTL` low / `cacheEnabled:
62
+ * false` for policies with conditions on fast-changing fields.
63
+ */
64
+ clearCache() {
65
+ return this.cache.clear()
25
66
  }
26
67
 
27
68
  can(user, action, resourceType, data = {}) {
28
69
 
29
70
  const roles = normalizeUser(user)
30
- const cacheKey = JSON.stringify({ user, action, resourceType, data })
31
-
32
- if (this.cache.has(cacheKey)) {
33
- return this.cache.get(cacheKey)
71
+ // Cache key needs something that uniquely identifies this resource.
72
+ // id/owner_id is cheap and covers the common case. When neither is
73
+ // present, fall back to a stringified snapshot of the data — without
74
+ // this, two different payloads with no id (e.g. {age:25} vs {age:10})
75
+ // would collide onto the same cache key and one call's result would
76
+ // wrongly get served to the other.
77
+ const objectId = data && (data.id ?? data.owner_id ?? JSON.stringify(data))
78
+
79
+ const cached = this.cache.get(user, action, resourceType, objectId)
80
+
81
+ if (cached !== null) {
82
+ return cached
34
83
  }
35
84
 
85
+ let result = false
86
+
87
+ outer:
36
88
  for (const role of roles) {
37
89
 
38
90
  const rolePerm = this.permissions[role]
@@ -44,14 +96,16 @@ class Authz {
44
96
  if (resourcePerm) {
45
97
 
46
98
  if (resourcePerm.has("*") || resourcePerm.has(action)) {
47
- return true
99
+ result = true
100
+ break outer
48
101
  }
49
102
  }
50
103
 
51
104
  if (rolePerm["*"]) {
52
105
 
53
106
  if (rolePerm["*"].has("*") || rolePerm["*"].has(action)) {
54
- return true
107
+ result = true
108
+ break outer
55
109
  }
56
110
  }
57
111
  }
@@ -76,12 +130,14 @@ class Authz {
76
130
  }
77
131
 
78
132
  if (evaluateAST(cond, context)) {
79
- return true
133
+ result = true
134
+ break outer
80
135
  }
81
136
  }
82
137
  }
83
138
 
84
- return false
139
+ this.cache.set(user, action, resourceType, objectId, result)
140
+ return result
85
141
  }
86
142
 
87
143
  explain(user, action, resourceType, data = {}) {
@@ -174,9 +230,9 @@ class Authz {
174
230
  }
175
231
  }
176
232
 
177
- Authz.prototype.middleware = function (action, resourceType) {
233
+ Authz.prototype.middleware = function (action, resourceType, getResource) {
178
234
  const middleware = createMiddleware(this)
179
- return middleware(action, resourceType)
235
+ return middleware(action, resourceType, getResource)
180
236
  }
181
237
 
182
238
  module.exports = Authz
@@ -1,22 +1,44 @@
1
+ /**
2
+ * @param {Authz} authz
3
+ */
1
4
  module.exports = function (authz) {
2
- return function (action, resourceType) {
3
- return (req, res, next) => {
5
+ /**
6
+ * @param {string} action
7
+ * @param {string} resourceType
8
+ * @param {(req: import('express').Request) => any | Promise<any>} [getResource]
9
+ * Optional loader that returns the REAL resource (e.g. fetched from DB)
10
+ * to authorize against. If omitted, falls back to `req.body` for
11
+ * backwards compatibility — but be aware `req.body` is client-controlled
12
+ * and MUST NOT be trusted for ownership/condition checks (a caller can
13
+ * put whatever `owner_id` they want in the body). Always pass
14
+ * `getResource` when your policy uses conditions like
15
+ * `resource.owner_id == user.id`.
16
+ */
17
+ return function (action, resourceType, getResource) {
18
+ return async (req, res, next) => {
4
19
  const user = req.user
5
- const resource = req.body || {}
6
20
 
7
21
  if (!user) {
8
22
  return res.status(401).json({ error: "Unauthorized" })
9
23
  }
10
24
 
11
- const allowed = authz.can(user, action, resourceType, resource)
25
+ try {
26
+ const resource = typeof getResource === "function"
27
+ ? await getResource(req)
28
+ : (req.body || {})
12
29
 
13
- if (allowed) {
14
- return next()
15
- }
30
+ const allowed = authz.can(user, action, resourceType, resource)
31
+
32
+ if (allowed) {
33
+ return next()
34
+ }
16
35
 
17
- return res.status(403).json({
18
- error: "Forbidden"
19
- })
36
+ return res.status(403).json({
37
+ error: "Forbidden"
38
+ })
39
+ } catch (err) {
40
+ return next(err)
41
+ }
20
42
  }
21
43
  }
22
44
  }
@@ -1,16 +1,106 @@
1
+ /**
2
+ * Split `str` on `delimiter`, but ignore any delimiter occurrence that falls
3
+ * inside a quoted string literal. Without this, a condition like
4
+ * `doc.title == "Terms AND Conditions"` would get incorrectly split in the
5
+ * middle of its own string literal, since the naive version just looked for
6
+ * the substring " AND " anywhere in the raw text.
7
+ */
8
+ function splitOutsideQuotes(str, delimiter) {
9
+
10
+ const parts = []
11
+ let current = ""
12
+ let inQuote = null
13
+
14
+ for (let i = 0; i < str.length; i++) {
15
+
16
+ const ch = str[i]
17
+
18
+ if (inQuote) {
19
+ current += ch
20
+ if (ch === inQuote) inQuote = null
21
+ continue
22
+ }
23
+
24
+ if (ch === '"' || ch === "'") {
25
+ inQuote = ch
26
+ current += ch
27
+ continue
28
+ }
29
+
30
+ if (str.startsWith(delimiter, i)) {
31
+ parts.push(current)
32
+ current = ""
33
+ i += delimiter.length - 1
34
+ continue
35
+ }
36
+
37
+ current += ch
38
+ }
39
+
40
+ parts.push(current)
41
+ return parts
42
+ }
43
+
44
+ /**
45
+ * Find the first comparison operator that appears outside any quoted
46
+ * literal. Longer operators are checked first at each position so ">=" is
47
+ * matched instead of being mistaken for ">" followed by "=".
48
+ */
49
+ function findTopLevelOperator(expr) {
50
+
51
+ const operators = ["==", "!=", ">=", "<=", ">", "<"]
52
+ let inQuote = null
53
+
54
+ for (let i = 0; i < expr.length; i++) {
55
+
56
+ const ch = expr[i]
57
+
58
+ if (inQuote) {
59
+ if (ch === inQuote) inQuote = null
60
+ continue
61
+ }
62
+
63
+ if (ch === '"' || ch === "'") {
64
+ inQuote = ch
65
+ continue
66
+ }
67
+
68
+ for (const op of operators) {
69
+ if (expr.startsWith(op, i)) {
70
+ return { operator: op, index: i }
71
+ }
72
+ }
73
+ }
74
+
75
+ return null
76
+ }
77
+
1
78
  function detectLogical(condition) {
2
79
 
3
- if (condition.includes(" AND ")) {
80
+ const andParts = splitOutsideQuotes(condition, " AND ")
81
+ const orParts = splitOutsideQuotes(condition, " OR ")
82
+
83
+ const hasAnd = andParts.length > 1
84
+ const hasOr = orParts.length > 1
85
+
86
+ if (hasAnd && hasOr) {
87
+ throw new Error(
88
+ `Mixing AND and OR in a single condition is not supported: "${condition}". ` +
89
+ `Split this into separate rules instead.`
90
+ )
91
+ }
92
+
93
+ if (hasAnd) {
4
94
  return {
5
95
  operator: "AND",
6
- parts: condition.split(" AND ")
96
+ parts: andParts
7
97
  }
8
98
  }
9
99
 
10
- if (condition.includes(" OR ")) {
100
+ if (hasOr) {
11
101
  return {
12
102
  operator: "OR",
13
- parts: condition.split(" OR ")
103
+ parts: orParts
14
104
  }
15
105
  }
16
106
 
@@ -19,24 +109,29 @@ function detectLogical(condition) {
19
109
 
20
110
  function parseComparison(expr) {
21
111
 
22
- const operators = ["==", "!=", ">=", "<=", ">", "<"]
23
-
24
- for (const op of operators) {
112
+ // Previously this scanned for each operator with .includes()/.split(),
113
+ // which splits on EVERY occurrence in the string — including one that
114
+ // happens to sit inside a quoted literal (e.g. `a == "x==y"`) — and then
115
+ // silently discarded everything past the first two resulting pieces.
116
+ const found = findTopLevelOperator(expr)
25
117
 
26
- if (expr.includes(op)) {
118
+ if (!found) {
119
+ throw new Error(`Invalid condition: ${expr}`)
120
+ }
27
121
 
28
- const [left, right] = expr.split(op).map(s => s.trim())
122
+ const left = expr.slice(0, found.index).trim()
123
+ const right = expr.slice(found.index + found.operator.length).trim()
29
124
 
30
- return {
31
- type: "comparison",
32
- operator: op,
33
- left,
34
- right
35
- }
36
- }
125
+ if (!left || !right) {
126
+ throw new Error(`Invalid condition: "${expr}" is missing an operand.`)
37
127
  }
38
128
 
39
- throw new Error(`Invalid condition: ${expr}`)
129
+ return {
130
+ type: "comparison",
131
+ operator: found.operator,
132
+ left,
133
+ right
134
+ }
40
135
  }
41
136
 
42
137
  function compileCondition(condition) {
@@ -47,11 +142,17 @@ function compileCondition(condition) {
47
142
  return parseComparison(condition)
48
143
  }
49
144
 
50
- return {
145
+ // Previously only logical.parts[0] and logical.parts[1] were used, so any
146
+ // clause past the second one in a chain like "a AND b AND c" was silently
147
+ // dropped (c never gets checked). Fold every clause into a left-leaning
148
+ // AST so chains of any length are honored.
149
+ const nodes = logical.parts.map(part => parseComparison(part.trim()))
150
+
151
+ return nodes.reduce((left, right) => ({
51
152
  type: logical.operator,
52
- left: parseComparison(logical.parts[0]),
53
- right: parseComparison(logical.parts[1])
54
- }
153
+ left,
154
+ right
155
+ }))
55
156
  }
56
157
 
57
158
  module.exports = { compileCondition }
@@ -13,17 +13,41 @@ function getValue(path, context) {
13
13
  return value
14
14
  }
15
15
 
16
+ /**
17
+ * Determine whether the right-hand side of a condition is a literal value
18
+ * (string, number, boolean, null) rather than a field path to look up in
19
+ * context. Previously only quoted strings were treated as literals, so
20
+ * `count > 5` or `active == true` silently resolved `5`/`true` as a field
21
+ * path (e.g. context["5"]), which is always undefined — the condition could
22
+ * never match.
23
+ */
24
+ function parseLiteral(raw) {
25
+
26
+ if ((raw.startsWith('"') && raw.endsWith('"')) ||
27
+ (raw.startsWith("'") && raw.endsWith("'"))) {
28
+ return { isLiteral: true, value: raw.slice(1, -1) }
29
+ }
30
+
31
+ if (raw === "true") return { isLiteral: true, value: true }
32
+ if (raw === "false") return { isLiteral: true, value: false }
33
+ if (raw === "null") return { isLiteral: true, value: null }
34
+
35
+ if (/^-?\d+(\.\d+)?$/.test(raw)) {
36
+ return { isLiteral: true, value: Number(raw) }
37
+ }
38
+
39
+ return { isLiteral: false, value: undefined }
40
+ }
41
+
16
42
  function evaluateComparison(node, context) {
17
43
 
18
44
  const leftVal = getValue(node.left, context)
19
45
 
20
- let rightVal
46
+ const literal = parseLiteral(node.right)
21
47
 
22
- if (node.right.startsWith('"') || node.right.startsWith("'")) {
23
- rightVal = node.right.slice(1, -1)
24
- } else {
25
- rightVal = getValue(node.right, context)
26
- }
48
+ const rightVal = literal.isLiteral
49
+ ? literal.value
50
+ : getValue(node.right, context)
27
51
 
28
52
  switch (node.operator) {
29
53
 
@@ -1,38 +1,88 @@
1
1
  const fs = require("fs")
2
2
 
3
+ const KNOWN_KEYS = ["role", "action", "resource", "condition"]
4
+
3
5
  function parseRules(filePath) {
4
6
 
5
7
  const content = fs.readFileSync(filePath, "utf8")
6
-
7
- const lines = content.split("\n").map(l => l.trim())
8
+ const rawLines = content.split("\n")
8
9
 
9
10
  const rules = []
10
11
  let current = null
12
+ let ruleStartLine = null
13
+
14
+ rawLines.forEach((rawLine, idx) => {
11
15
 
12
- for (const line of lines) {
16
+ const lineNumber = idx + 1
17
+ const line = rawLine.trim()
13
18
 
14
- if (!line || line.startsWith("#")) continue
19
+ if (!line || line.startsWith("#")) return
15
20
 
16
21
  if (line === "rule") {
22
+
23
+ // Previously this silently overwrote `current`, discarding an
24
+ // already-open rule block that was missing its "end".
25
+ if (current) {
26
+ throw new Error(
27
+ `Policy parse error at line ${lineNumber}: found "rule" but the ` +
28
+ `rule opened at line ${ruleStartLine} is still unclosed (missing "end").`
29
+ )
30
+ }
31
+
17
32
  current = {}
33
+ ruleStartLine = lineNumber
34
+ return
18
35
  }
19
36
 
20
- else if (line === "end") {
37
+ if (line === "end") {
38
+
39
+ // Previously this pushed `current` (= null for a stray "end")
40
+ // straight into `rules`, which crashed later in validateRules
41
+ // with an unhelpful "Cannot read properties of null".
42
+ if (!current) {
43
+ throw new Error(
44
+ `Policy parse error at line ${lineNumber}: found "end" with no matching "rule".`
45
+ )
46
+ }
47
+
21
48
  rules.push(current)
22
49
  current = null
50
+ ruleStartLine = null
51
+ return
23
52
  }
24
53
 
25
- else if (current) {
54
+ if (!current) {
55
+ throw new Error(
56
+ `Policy parse error at line ${lineNumber}: "${line}" appears outside ` +
57
+ `of a rule/end block.`
58
+ )
59
+ }
26
60
 
27
- const [key, ...rest] = line.split(" ")
28
- const value = rest.join(" ")
61
+ const [key, ...rest] = line.split(" ")
62
+ const value = rest.join(" ")
29
63
 
30
- if (key === "role") current.role = value
31
- if (key === "action") current.action = value
32
- if (key === "resource") current.resource = value
33
- if (key === "condition") current.condition = value
64
+ // Previously an unrecognized key (e.g. "roel" typo'd for "role")
65
+ // was silently dropped, leaving the rule incomplete with no warning.
66
+ if (!KNOWN_KEYS.includes(key)) {
67
+ throw new Error(
68
+ `Policy parse error at line ${lineNumber}: unknown key "${key}" ` +
69
+ `(expected one of: ${KNOWN_KEYS.join(", ")}).`
70
+ )
71
+ }
34
72
 
73
+ if (!value) {
74
+ throw new Error(
75
+ `Policy parse error at line ${lineNumber}: "${key}" has no value.`
76
+ )
35
77
  }
78
+
79
+ current[key] = value
80
+ })
81
+
82
+ if (current) {
83
+ throw new Error(
84
+ `Policy parse error: rule opened at line ${ruleStartLine} was never closed with "end".`
85
+ )
36
86
  }
37
87
 
38
88
  return rules
@@ -1,3 +1,5 @@
1
+ const { compileCondition } = require("../engine/conditionCompiler")
2
+
1
3
  function validateRules(rules) {
2
4
 
3
5
  const errors = []
@@ -20,17 +22,21 @@ function validateRules(rules) {
20
22
 
21
23
  if (rule.condition) {
22
24
 
23
- const validOperators = [
24
- "==", "!=", ">", "<", ">=", "<="
25
- ]
26
-
27
- const hasOperator = validOperators.some(op =>
28
- rule.condition.includes(op)
29
- )
30
-
31
- if (!hasOperator) {
25
+ // Previously this only checked whether an operator substring
26
+ // (e.g. "==") appeared ANYWHERE in the condition text — it never
27
+ // actually verified the condition parses. A condition like
28
+ // "doc.status ==" (missing right-hand side) or one with mismatched
29
+ // AND/OR usage would pass this check and then either crash later
30
+ // deep inside the compiler with no rule number attached, or (worse)
31
+ // silently compile into a broken/always-false comparison.
32
+ //
33
+ // Actually attempting to compile it here catches real structural
34
+ // problems immediately, at the rule they belong to.
35
+ try {
36
+ compileCondition(rule.condition)
37
+ } catch (err) {
32
38
  errors.push(
33
- `Rule ${ruleNumber}: invalid condition "${rule.condition}"`
39
+ `Rule ${ruleNumber}: invalid condition "${rule.condition}" — ${err.message}`
34
40
  )
35
41
  }
36
42
  }
package/package.json CHANGED
@@ -1,50 +1,52 @@
1
- {
2
- "name": "simple-authz",
3
- "version": "1.1.0",
4
- "description": "Lightweight policy-based authorization engine for Node.js using TOON policy language",
5
- "main": "lib/index.js",
6
- "scripts": {
7
- "test": "jest",
8
- "dev": "node lib/core/authz.js"
9
- },
10
- "bin": {
11
- "simple-authz": "bin/simple-authz"
12
- },
13
- "devDependencies": {
14
- "chalk": "^5.0.0",
15
- "commander": "^11.0.0",
16
- "jest": "^30.3.0"
17
- },
18
- "keywords": [
19
- "authorization",
20
- "access-control",
21
- "auth",
22
- "policy-engine",
23
- "rbac",
24
- "abac",
25
- "permissions",
26
- "nodejs-auth",
27
- "authorization-library",
28
- "security",
29
- "policy-based-access",
30
- "authz",
31
- "role-based-access-control",
32
- "authorization-engine"
33
- ],
34
- "types": "types/index.d.ts",
35
- "files": [
36
- "lib/",
37
- "types/",
38
- "package.json"
39
- ],
40
- "author": "Dhruvil",
41
- "license": "Apache-2.0",
42
- "repository": {
43
- "type": "git",
44
- "url": "https://github.com/dhruvil05/simple-authz"
45
- },
46
- "bugs": {
47
- "url": "https://github.com/dhruvil05/simple-authz/issues"
48
- },
49
- "homepage": "https://github.com/dhruvil05/simple-authz#readme"
50
- }
1
+ {
2
+ "name": "simple-authz",
3
+ "version": "1.2.0",
4
+ "description": "Lightweight policy-based authorization engine for Node.js using TOON policy language",
5
+ "main": "lib/index.js",
6
+ "scripts": {
7
+ "test": "jest",
8
+ "dev": "node lib/core/authz.js"
9
+ },
10
+ "bin": {
11
+ "simple-authz": "bin/simple-authz/index.js"
12
+ },
13
+ "dependencies": {
14
+ "chalk": "^4.1.2",
15
+ "commander": "^11.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "jest": "^30.3.0"
19
+ },
20
+ "keywords": [
21
+ "authorization",
22
+ "access-control",
23
+ "auth",
24
+ "policy-engine",
25
+ "rbac",
26
+ "abac",
27
+ "permissions",
28
+ "nodejs-auth",
29
+ "authorization-library",
30
+ "security",
31
+ "policy-based-access",
32
+ "authz",
33
+ "role-based-access-control",
34
+ "authorization-engine"
35
+ ],
36
+ "types": "types/index.d.ts",
37
+ "files": [
38
+ "lib/",
39
+ "types/",
40
+ "package.json"
41
+ ],
42
+ "author": "Dhruvil",
43
+ "license": "Apache-2.0",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/dhruvil05/simple-authz"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/dhruvil05/simple-authz/issues"
50
+ },
51
+ "homepage": "https://github.com/dhruvil05/simple-authz#readme"
52
+ }