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 +70 -29
- package/bin/simple-authz/index.js +132 -0
- package/lib/cache.js +30 -16
- package/lib/core/authz.js +68 -12
- package/lib/core/middleware.js +32 -10
- 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 -50
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
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
53
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
113
|
-
const hitRate = total === 0 ? 0 : ((this.
|
|
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.
|
|
117
|
-
misses: this.
|
|
118
|
-
evictions: this.
|
|
119
|
-
timeouts: this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
result = true
|
|
134
|
+
break outer
|
|
80
135
|
}
|
|
81
136
|
}
|
|
82
137
|
}
|
|
83
138
|
|
|
84
|
-
|
|
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
|
package/lib/core/middleware.js
CHANGED
|
@@ -1,22 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {Authz} authz
|
|
3
|
+
*/
|
|
1
4
|
module.exports = function (authz) {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
25
|
+
try {
|
|
26
|
+
const resource = typeof getResource === "function"
|
|
27
|
+
? await getResource(req)
|
|
28
|
+
: (req.body || {})
|
|
12
29
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
30
|
+
const allowed = authz.can(user, action, resourceType, resource)
|
|
31
|
+
|
|
32
|
+
if (allowed) {
|
|
33
|
+
return next()
|
|
34
|
+
}
|
|
16
35
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/rules/parser.js
CHANGED
|
@@ -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
|
-
|
|
16
|
+
const lineNumber = idx + 1
|
|
17
|
+
const line = rawLine.trim()
|
|
13
18
|
|
|
14
|
-
if (!line || line.startsWith("#"))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
61
|
+
const [key, ...rest] = line.split(" ")
|
|
62
|
+
const value = rest.join(" ")
|
|
29
63
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
package/lib/rules/validator.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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
|
-
"
|
|
14
|
-
"chalk": "^
|
|
15
|
-
"commander": "^11.0.0"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
}
|