simple-authz 1.0.5 → 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.
@@ -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 ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * LRU Cache for authorization decisions
3
+ * - Stores user + action + resource + objectId combinations
4
+ * - TTL-based expiration
5
+ * - LRU eviction when max size exceeded
6
+ * - Automatic invalidation on policy reload
7
+ */
8
+ class AuthzCache {
9
+ constructor(options = {}) {
10
+ this.maxSize = options.maxSize || 1000;
11
+ this.ttl = options.ttl || 3600; // seconds
12
+ this.enabled = options.enabled !== false;
13
+
14
+ this.cache = new Map();
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 = {
21
+ hits: 0,
22
+ misses: 0,
23
+ evictions: 0,
24
+ timeouts: 0
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Generate cache key from request parameters
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.
36
+ */
37
+ _key(user, action, resource, objectId) {
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'}`;
43
+ }
44
+
45
+ /**
46
+ * Get cached authorization result
47
+ * Returns null if:
48
+ * - Key not in cache
49
+ * - Entry expired (TTL exceeded)
50
+ */
51
+ get(user, action, resource, objectId) {
52
+ if (!this.enabled) return null;
53
+
54
+ const key = this._key(user, action, resource, objectId);
55
+ const entry = this.cache.get(key);
56
+
57
+ if (!entry) {
58
+ this._stats.misses++;
59
+ return null;
60
+ }
61
+
62
+ // Check TTL
63
+ const age = (Date.now() - entry.timestamp) / 1000;
64
+ if (age > this.ttl) {
65
+ this.cache.delete(key);
66
+ this._stats.timeouts++;
67
+ this._stats.misses++;
68
+ return null;
69
+ }
70
+
71
+ // Hit!
72
+ entry.lastAccess = Date.now();
73
+ this._stats.hits++;
74
+ return entry.result;
75
+ }
76
+
77
+ /**
78
+ * Store authorization result in cache
79
+ * Evicts oldest entry (LRU) if at capacity
80
+ */
81
+ set(user, action, resource, objectId, result) {
82
+ if (!this.enabled) return;
83
+
84
+ // Check if we need to evict
85
+ if (this.cache.size >= this.maxSize) {
86
+ // Find oldest entry by timestamp (not lastAccess, to keep simple)
87
+ let oldest = null;
88
+ let oldestKey = null;
89
+
90
+ for (const [key, entry] of this.cache) {
91
+ if (!oldest || entry.timestamp < oldest.timestamp) {
92
+ oldest = entry;
93
+ oldestKey = key;
94
+ }
95
+ }
96
+
97
+ if (oldestKey) {
98
+ this.cache.delete(oldestKey);
99
+ this._stats.evictions++;
100
+ }
101
+ }
102
+
103
+ // Store new entry
104
+ const key = this._key(user, action, resource, objectId);
105
+ this.cache.set(key, {
106
+ result,
107
+ timestamp: Date.now(),
108
+ lastAccess: Date.now()
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Clear entire cache
114
+ * Used on policy reload
115
+ */
116
+ clear() {
117
+ const before = this.cache.size;
118
+ this.cache.clear();
119
+ return { cleared: before, stats: this._stats };
120
+ }
121
+
122
+ /**
123
+ * Get cache statistics
124
+ */
125
+ stats() {
126
+ const total = this._stats.hits + this._stats.misses;
127
+ const hitRate = total === 0 ? 0 : ((this._stats.hits / total) * 100).toFixed(2);
128
+
129
+ return {
130
+ hits: this._stats.hits,
131
+ misses: this._stats.misses,
132
+ evictions: this._stats.evictions,
133
+ timeouts: this._stats.timeouts,
134
+ hitRate: hitRate + '%',
135
+ size: this.cache.size,
136
+ maxSize: this.maxSize,
137
+ utilizationRate: ((this.cache.size / this.maxSize) * 100).toFixed(2) + '%'
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Reset statistics (for testing/monitoring)
143
+ */
144
+ resetStats() {
145
+ this._stats = { hits: 0, misses: 0, evictions: 0, timeouts: 0 };
146
+ }
147
+ }
148
+
149
+ module.exports = AuthzCache;
package/lib/core/authz.js CHANGED
@@ -3,12 +3,30 @@ const { compileRules } = require("../rules/compiler")
3
3
  const { validateRules } = require("../rules/validator")
4
4
  const { normalizeUser } = require("../utils/normalizeUser")
5
5
  const { evaluateAST } = require("../engine/conditionExecutor")
6
+ const createMiddleware = require("./middleware")
7
+ const AuthzCache = require("../cache")
6
8
 
7
9
  class Authz {
8
10
 
9
- 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 = {}) {
10
18
  this.permissions = {}
11
19
  this.conditions = {}
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
+ })
12
30
  }
13
31
 
14
32
  load(filePath) {
@@ -18,14 +36,55 @@ class Authz {
18
36
 
19
37
  const compiled = compileRules(rules)
20
38
 
39
+ this.rules = rules
21
40
  this.permissions = compiled.permissions
22
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()
23
66
  }
24
67
 
25
68
  can(user, action, resourceType, data = {}) {
26
69
 
27
70
  const roles = normalizeUser(user)
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
83
+ }
84
+
85
+ let result = false
28
86
 
87
+ outer:
29
88
  for (const role of roles) {
30
89
 
31
90
  const rolePerm = this.permissions[role]
@@ -37,14 +96,16 @@ class Authz {
37
96
  if (resourcePerm) {
38
97
 
39
98
  if (resourcePerm.has("*") || resourcePerm.has(action)) {
40
- return true
99
+ result = true
100
+ break outer
41
101
  }
42
102
  }
43
103
 
44
104
  if (rolePerm["*"]) {
45
105
 
46
106
  if (rolePerm["*"].has("*") || rolePerm["*"].has(action)) {
47
- return true
107
+ result = true
108
+ break outer
48
109
  }
49
110
  }
50
111
  }
@@ -69,12 +130,14 @@ class Authz {
69
130
  }
70
131
 
71
132
  if (evaluateAST(cond, context)) {
72
- return true
133
+ result = true
134
+ break outer
73
135
  }
74
136
  }
75
137
  }
76
138
 
77
- return false
139
+ this.cache.set(user, action, resourceType, objectId, result)
140
+ return result
78
141
  }
79
142
 
80
143
  explain(user, action, resourceType, data = {}) {
@@ -167,4 +230,9 @@ class Authz {
167
230
  }
168
231
  }
169
232
 
233
+ Authz.prototype.middleware = function (action, resourceType, getResource) {
234
+ const middleware = createMiddleware(this)
235
+ return middleware(action, resourceType, getResource)
236
+ }
237
+
170
238
  module.exports = Authz
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @param {Authz} authz
3
+ */
4
+ module.exports = function (authz) {
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) => {
19
+ const user = req.user
20
+
21
+ if (!user) {
22
+ return res.status(401).json({ error: "Unauthorized" })
23
+ }
24
+
25
+ try {
26
+ const resource = typeof getResource === "function"
27
+ ? await getResource(req)
28
+ : (req.body || {})
29
+
30
+ const allowed = authz.can(user, action, resourceType, resource)
31
+
32
+ if (allowed) {
33
+ return next()
34
+ }
35
+
36
+ return res.status(403).json({
37
+ error: "Forbidden"
38
+ })
39
+ } catch (err) {
40
+ return next(err)
41
+ }
42
+ }
43
+ }
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