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.
- package/README.md +152 -348
- package/bin/simple-authz/index.js +132 -0
- package/lib/cache.js +149 -0
- package/lib/core/authz.js +73 -5
- package/lib/core/middleware.js +44 -0
- package/lib/engine/conditionCompiler.js +122 -21
- package/lib/engine/conditionExecutor.js +30 -6
- package/lib/rules/parser.js +62 -12
- package/lib/rules/validator.js +16 -10
- package/package.json +52 -36
- package/types/index.d.ts +31 -0
- package/simple-authz-1.0.1.tgz +0 -0
- package/simple-authz-1.0.2.tgz +0 -0
- package/simple-authz-1.0.3.tgz +0 -0
- package/simple-authz-1.0.4.tgz +0 -0
- package/simple-authz-1.0.5.tgz +0 -0
- package/simple-authz-v1.0.1.tgz +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
result = true
|
|
134
|
+
break outer
|
|
73
135
|
}
|
|
74
136
|
}
|
|
75
137
|
}
|
|
76
138
|
|
|
77
|
-
|
|
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
|
-
|
|
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:
|
|
96
|
+
parts: andParts
|
|
7
97
|
}
|
|
8
98
|
}
|
|
9
99
|
|
|
10
|
-
if (
|
|
100
|
+
if (hasOr) {
|
|
11
101
|
return {
|
|
12
102
|
operator: "OR",
|
|
13
|
-
parts:
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
118
|
+
if (!found) {
|
|
119
|
+
throw new Error(`Invalid condition: ${expr}`)
|
|
120
|
+
}
|
|
27
121
|
|
|
28
|
-
|
|
122
|
+
const left = expr.slice(0, found.index).trim()
|
|
123
|
+
const right = expr.slice(found.index + found.operator.length).trim()
|
|
29
124
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
right
|
|
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
|
-
|
|
46
|
+
const literal = parseLiteral(node.right)
|
|
21
47
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|