odgn-rights 0.1.0 → 0.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 +121 -0
- package/dist/cli/commands/check.d.ts +2 -0
- package/dist/cli/commands/check.js +38 -0
- package/dist/cli/commands/explain.d.ts +2 -0
- package/dist/cli/commands/explain.js +85 -0
- package/dist/cli/commands/validate.d.ts +2 -0
- package/dist/cli/commands/validate.js +177 -0
- package/dist/cli/helpers/config-loader.d.ts +3 -0
- package/dist/cli/helpers/config-loader.js +13 -0
- package/dist/cli/helpers/flag-parser.d.ts +3 -0
- package/dist/cli/helpers/flag-parser.js +40 -0
- package/dist/cli/helpers/output.d.ts +10 -0
- package/dist/cli/helpers/output.js +29 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +15 -0
- package/dist/cli/types.d.ts +10 -0
- package/dist/cli/types.js +1 -0
- package/dist/right.d.ts +18 -1
- package/dist/right.js +121 -12
- package/dist/rights.d.ts +12 -0
- package/dist/rights.js +114 -29
- package/dist/role-registry.js +3 -0
- package/dist/role.d.ts +3 -1
- package/dist/role.js +11 -0
- package/dist/subject.d.ts +17 -2
- package/dist/subject.js +48 -8
- package/package.json +25 -4
package/README.md
CHANGED
|
@@ -105,3 +105,124 @@ const json = rights.toJSON();
|
|
|
105
105
|
// [ { path: '/', allow: 'r' }, { path: '/*/device/**', allow: 'c' }, ... ]
|
|
106
106
|
const loaded = Rights.fromJSON(json);
|
|
107
107
|
```
|
|
108
|
+
|
|
109
|
+
## CLI Tool
|
|
110
|
+
|
|
111
|
+
The CLI tool helps test and debug permission configurations from the command line.
|
|
112
|
+
|
|
113
|
+
### Installation
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
# Install globally
|
|
117
|
+
npm install -g @odgn/rights
|
|
118
|
+
|
|
119
|
+
# Or use with npx
|
|
120
|
+
npx @odgn/rights --help
|
|
121
|
+
|
|
122
|
+
# Or run directly with bun
|
|
123
|
+
bun run src/cli/index.ts --help
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Commands
|
|
127
|
+
|
|
128
|
+
#### check
|
|
129
|
+
|
|
130
|
+
Test if a permission is allowed:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
# Basic usage
|
|
134
|
+
odgn-rights check -c config.json -p /users/123 -f READ
|
|
135
|
+
|
|
136
|
+
# With combined flags
|
|
137
|
+
odgn-rights check -c config.json -p /users/123 -f RW
|
|
138
|
+
|
|
139
|
+
# With comma-separated flags
|
|
140
|
+
odgn-rights check -c config.json -p /users/123 -f READ,WRITE
|
|
141
|
+
|
|
142
|
+
# Quiet mode for scripting (outputs 'true' or 'false')
|
|
143
|
+
odgn-rights check -c config.json -p /users/123 -f READ --quiet
|
|
144
|
+
|
|
145
|
+
# With context for conditional rights
|
|
146
|
+
odgn-rights check -c config.json -p /posts/1 -f WRITE --context '{"userId":"abc","ownerId":"abc"}'
|
|
147
|
+
|
|
148
|
+
# Override time for time-based rights
|
|
149
|
+
odgn-rights check -c config.json -p /scheduled -f READ --time 2025-06-15T12:00:00Z
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Exit codes: `0` = allowed, `1` = denied, `2` = error
|
|
153
|
+
|
|
154
|
+
#### explain
|
|
155
|
+
|
|
156
|
+
Understand why a permission is allowed or denied:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# Basic usage
|
|
160
|
+
odgn-rights explain -c config.json -p /users/123 -f WRITE
|
|
161
|
+
|
|
162
|
+
# JSON output
|
|
163
|
+
odgn-rights explain -c config.json -p /users/123 -f READ --json
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The explain command shows:
|
|
167
|
+
|
|
168
|
+
- Decision breakdown per flag
|
|
169
|
+
- Matching rules sorted by specificity
|
|
170
|
+
- Suggestions for granting denied permissions
|
|
171
|
+
|
|
172
|
+
#### validate
|
|
173
|
+
|
|
174
|
+
Validate a configuration file:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# Validate JSON config
|
|
178
|
+
odgn-rights validate config.json
|
|
179
|
+
|
|
180
|
+
# Validate string format config
|
|
181
|
+
odgn-rights validate config.txt
|
|
182
|
+
|
|
183
|
+
# Strict mode (warns on broad patterns like /**)
|
|
184
|
+
odgn-rights validate --strict config.json
|
|
185
|
+
|
|
186
|
+
# JSON output
|
|
187
|
+
odgn-rights validate --json config.json
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Exit codes: `0` = valid, `1` = validation errors, `2` = file error
|
|
191
|
+
|
|
192
|
+
### Configuration Formats
|
|
193
|
+
|
|
194
|
+
The CLI supports two configuration formats:
|
|
195
|
+
|
|
196
|
+
**JSON format** (`config.json`):
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
[
|
|
200
|
+
{ "path": "/", "allow": "r" },
|
|
201
|
+
{ "path": "/users/*", "allow": "rw" },
|
|
202
|
+
{ "path": "/admin/**", "allow": "*", "tags": ["admin"] },
|
|
203
|
+
{ "path": "/scheduled", "allow": "r", "validFrom": "2025-01-01T00:00:00Z" }
|
|
204
|
+
]
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**String format** (`config.txt`):
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
# Comments start with #
|
|
211
|
+
+r:/
|
|
212
|
+
+rw:/users/*
|
|
213
|
+
+*:/admin/**
|
|
214
|
+
-d+rw:/public
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Flag Reference
|
|
218
|
+
|
|
219
|
+
| Flag | Letter | Description |
|
|
220
|
+
| ------- | ------ | ------------------ |
|
|
221
|
+
| READ | R | Read permission |
|
|
222
|
+
| WRITE | W | Write permission |
|
|
223
|
+
| CREATE | C | Create permission |
|
|
224
|
+
| DELETE | D | Delete permission |
|
|
225
|
+
| EXECUTE | X | Execute permission |
|
|
226
|
+
| ALL | \* | All permissions |
|
|
227
|
+
|
|
228
|
+
Flags can be combined: `RW`, `READ,WRITE`, `RWCDX`
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { loadConfig } from '../helpers/config-loader';
|
|
4
|
+
import { parseFlags } from '../helpers/flag-parser';
|
|
5
|
+
import { colors, formatResult } from '../helpers/output';
|
|
6
|
+
export const checkCommand = new Command('check')
|
|
7
|
+
.description('Test a permission check against a configuration')
|
|
8
|
+
.requiredOption('-c, --config <file>', 'Path to rights configuration file')
|
|
9
|
+
.requiredOption('-p, --path <path>', 'Resource path to check')
|
|
10
|
+
.requiredOption('-f, --flag <flag>', 'Permission flag(s) to check (READ, WRITE, etc.)')
|
|
11
|
+
.option('--context <json>', 'JSON context for conditional rights')
|
|
12
|
+
.option('--time <iso-date>', 'Override current time for time-based rights')
|
|
13
|
+
.option('--quiet', 'Only output result (for scripting)')
|
|
14
|
+
.action(options => {
|
|
15
|
+
try {
|
|
16
|
+
const rights = loadConfig(options.config);
|
|
17
|
+
const flag = parseFlags(options.flag);
|
|
18
|
+
let context = undefined;
|
|
19
|
+
if (options.context) {
|
|
20
|
+
context = JSON.parse(options.context);
|
|
21
|
+
}
|
|
22
|
+
if (options.time) {
|
|
23
|
+
context = { ...context, _now: new Date(options.time) };
|
|
24
|
+
}
|
|
25
|
+
const result = rights.has(options.path, flag, context);
|
|
26
|
+
if (options.quiet) {
|
|
27
|
+
process.stdout.write(result ? 'true' : 'false');
|
|
28
|
+
process.exit(result ? 0 : 1);
|
|
29
|
+
}
|
|
30
|
+
console.log(`Checking ${colors.cyan(options.flag)} on ${colors.cyan(options.path)}...`);
|
|
31
|
+
console.log(`Result: ${formatResult(result)}`);
|
|
32
|
+
process.exit(result ? 0 : 1);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error(colors.red(`Error: ${error.message}`));
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { lettersFromMask } from '../../utils';
|
|
4
|
+
import { loadConfig } from '../helpers/config-loader';
|
|
5
|
+
import { parseFlags } from '../helpers/flag-parser';
|
|
6
|
+
import { colors, flagName, formatResult } from '../helpers/output';
|
|
7
|
+
export const explainCommand = new Command('explain')
|
|
8
|
+
.description('Explain why a permission is allowed or denied')
|
|
9
|
+
.requiredOption('-c, --config <file>', 'Path to rights configuration file')
|
|
10
|
+
.requiredOption('-p, --path <path>', 'Resource path to check')
|
|
11
|
+
.requiredOption('-f, --flag <flag>', 'Permission flag(s) to explain')
|
|
12
|
+
.option('--context <json>', 'JSON context for conditional rights')
|
|
13
|
+
.option('--time <iso-date>', 'Override current time for time-based rights')
|
|
14
|
+
.option('--json', 'Output as JSON')
|
|
15
|
+
.action(options => {
|
|
16
|
+
try {
|
|
17
|
+
const rights = loadConfig(options.config);
|
|
18
|
+
const flag = parseFlags(options.flag);
|
|
19
|
+
let context = undefined;
|
|
20
|
+
if (options.context) {
|
|
21
|
+
context = JSON.parse(options.context);
|
|
22
|
+
}
|
|
23
|
+
if (options.time) {
|
|
24
|
+
context = { ...context, _now: new Date(options.time) };
|
|
25
|
+
}
|
|
26
|
+
const explanation = rights.explain(options.path, flag, context);
|
|
27
|
+
if (options.json) {
|
|
28
|
+
console.log(JSON.stringify({
|
|
29
|
+
allowed: explanation.allowed,
|
|
30
|
+
details: explanation.details.map(d => ({
|
|
31
|
+
allowed: d.allowed,
|
|
32
|
+
flag: flagName(d.bit),
|
|
33
|
+
rule: d.right?.toString()
|
|
34
|
+
})),
|
|
35
|
+
flag: options.flag,
|
|
36
|
+
path: options.path
|
|
37
|
+
}, null, 2));
|
|
38
|
+
process.exit(explanation.allowed ? 0 : 1);
|
|
39
|
+
}
|
|
40
|
+
console.log(`\nExplaining ${colors.cyan(options.flag)} on ${colors.cyan(options.path)}...\n`);
|
|
41
|
+
console.log(`Result: ${formatResult(explanation.allowed)}\n`);
|
|
42
|
+
console.log(colors.bold('Decision breakdown:'));
|
|
43
|
+
for (const detail of explanation.details) {
|
|
44
|
+
const status = detail.allowed
|
|
45
|
+
? colors.green('ALLOWED')
|
|
46
|
+
: colors.red('DENIED');
|
|
47
|
+
console.log(` ${flagName(detail.bit)}: ${status}`);
|
|
48
|
+
if (detail.right) {
|
|
49
|
+
console.log(` Matched by: ${colors.dim(detail.right.toString())}`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.log(` ${colors.dim('No matching rule grants this permission')}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Show all matching rules
|
|
56
|
+
const allRights = rights.allRights().filter(r => r.matches(options.path));
|
|
57
|
+
if (allRights.length > 0) {
|
|
58
|
+
console.log(`\n${colors.bold('Matching rules (by specificity):')}`);
|
|
59
|
+
allRights
|
|
60
|
+
.sort((a, b) => b.specificity() - a.specificity())
|
|
61
|
+
.forEach((r, i) => {
|
|
62
|
+
console.log(` ${i + 1}. ${colors.cyan(r.toString())}`);
|
|
63
|
+
console.log(` Specificity: ${r.specificity()}`);
|
|
64
|
+
if (r.tags.length > 0) {
|
|
65
|
+
console.log(` Tags: ${r.tags.join(', ')}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Provide suggestions if denied
|
|
70
|
+
if (!explanation.allowed) {
|
|
71
|
+
console.log(`\n${colors.yellow('Suggestion:')} To grant ${options.flag} access, add:`);
|
|
72
|
+
const flagLetters = lettersFromMask(flag);
|
|
73
|
+
console.log(` +${flagLetters}:${options.path} ${colors.dim('(exact path)')}`);
|
|
74
|
+
const wildcardPath = options.path.replace(/\/[^/]+$/, '/*');
|
|
75
|
+
if (wildcardPath !== options.path) {
|
|
76
|
+
console.log(` +${flagLetters}:${wildcardPath} ${colors.dim('(wildcard)')}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
process.exit(explanation.allowed ? 0 : 1);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
console.error(colors.red(`Error: ${error.message}`));
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { Right } from '@/right';
|
|
5
|
+
import { Rights } from '@/rights';
|
|
6
|
+
import { colors } from '../helpers/output';
|
|
7
|
+
export const validateCommand = new Command('validate')
|
|
8
|
+
.description('Validate a rights configuration file')
|
|
9
|
+
.argument('<file>', 'Configuration file to validate')
|
|
10
|
+
.option('--strict', 'Enable strict validation (warn on unusual patterns)')
|
|
11
|
+
.option('--json', 'Output as JSON')
|
|
12
|
+
.action((file, options) => {
|
|
13
|
+
const errors = [];
|
|
14
|
+
const warnings = [];
|
|
15
|
+
try {
|
|
16
|
+
const content = readFileSync(file, 'utf8');
|
|
17
|
+
const isJson = file.endsWith('.json');
|
|
18
|
+
if (isJson) {
|
|
19
|
+
validateJsonConfig(content, errors, warnings, options.strict);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
validateStringConfig(content, errors, warnings, options.strict);
|
|
23
|
+
}
|
|
24
|
+
if (options.json) {
|
|
25
|
+
console.log(JSON.stringify({
|
|
26
|
+
errors,
|
|
27
|
+
valid: errors.length === 0,
|
|
28
|
+
warnings
|
|
29
|
+
}, null, 2));
|
|
30
|
+
process.exit(errors.length > 0 ? 1 : 0);
|
|
31
|
+
}
|
|
32
|
+
console.log(`Validating ${colors.cyan(file)}...\n`);
|
|
33
|
+
if (errors.length > 0) {
|
|
34
|
+
console.log(colors.red('Errors found:\n'));
|
|
35
|
+
for (const err of errors) {
|
|
36
|
+
const loc = err.index !== undefined
|
|
37
|
+
? `Rule ${err.index + 1}`
|
|
38
|
+
: `Line ${err.line}`;
|
|
39
|
+
console.log(` ${colors.red(loc)}: ${err.message}`);
|
|
40
|
+
if (err.detail) {
|
|
41
|
+
console.log(` ${colors.dim(err.detail)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
console.log(`\n${colors.red(`Validation failed with ${errors.length} error(s).`)}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
if (warnings.length > 0) {
|
|
48
|
+
console.log(colors.yellow('Warnings:\n'));
|
|
49
|
+
for (const warn of warnings) {
|
|
50
|
+
const loc = warn.index !== undefined
|
|
51
|
+
? `Rule ${warn.index + 1}`
|
|
52
|
+
: `Line ${warn.line}`;
|
|
53
|
+
console.log(` ${colors.yellow(loc)}: ${warn.message}`);
|
|
54
|
+
if (warn.detail) {
|
|
55
|
+
console.log(` ${colors.dim(warn.detail)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
console.log('');
|
|
59
|
+
}
|
|
60
|
+
// Parse and show summary
|
|
61
|
+
const rights = isJson
|
|
62
|
+
? Rights.fromJSON(JSON.parse(content))
|
|
63
|
+
: Rights.parse(content);
|
|
64
|
+
const allRights = rights.allRights();
|
|
65
|
+
const withTags = allRights.filter(r => r.tags.length > 0).length;
|
|
66
|
+
const timeBased = allRights.filter(r => r.validFrom || r.validUntil).length;
|
|
67
|
+
console.log(colors.green('All rules are valid.\n'));
|
|
68
|
+
console.log(colors.bold('Summary:'));
|
|
69
|
+
console.log(` Paths: ${allRights.length}`);
|
|
70
|
+
console.log(` With tags: ${withTags}`);
|
|
71
|
+
console.log(` Time-based: ${timeBased}`);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (!options.json) {
|
|
76
|
+
console.error(colors.red(`Error: ${error.message}`));
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
console.log(JSON.stringify({
|
|
80
|
+
errors: [{ message: error.message }],
|
|
81
|
+
valid: false,
|
|
82
|
+
warnings: []
|
|
83
|
+
}, null, 2));
|
|
84
|
+
}
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const validateJsonConfig = (content, errors, warnings, strict) => {
|
|
89
|
+
let data;
|
|
90
|
+
try {
|
|
91
|
+
data = JSON.parse(content);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
errors.push({ detail: error.message, message: 'Invalid JSON' });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(data)) {
|
|
98
|
+
errors.push({ message: 'Configuration must be an array of rights' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
data.forEach((item, index) => {
|
|
102
|
+
// Check required fields
|
|
103
|
+
if (!item.path) {
|
|
104
|
+
errors.push({ index, message: 'Missing required field: path' });
|
|
105
|
+
}
|
|
106
|
+
if (!item.allow && !item.deny) {
|
|
107
|
+
errors.push({
|
|
108
|
+
index,
|
|
109
|
+
message: 'Must specify at least "allow" or "deny"'
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Validate time range
|
|
113
|
+
if (item.validFrom && item.validUntil) {
|
|
114
|
+
const from = new Date(item.validFrom);
|
|
115
|
+
const until = new Date(item.validUntil);
|
|
116
|
+
if (from > until) {
|
|
117
|
+
errors.push({
|
|
118
|
+
detail: `validFrom (${item.validFrom}) is after validUntil (${item.validUntil})`,
|
|
119
|
+
index,
|
|
120
|
+
message: 'Invalid time range'
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Try to parse the right
|
|
125
|
+
try {
|
|
126
|
+
const init = {};
|
|
127
|
+
if (item.validFrom) {
|
|
128
|
+
init.validFrom = new Date(item.validFrom);
|
|
129
|
+
}
|
|
130
|
+
if (item.validUntil) {
|
|
131
|
+
init.validUntil = new Date(item.validUntil);
|
|
132
|
+
}
|
|
133
|
+
if (item.tags) {
|
|
134
|
+
init.tags = item.tags;
|
|
135
|
+
}
|
|
136
|
+
new Right(item.path, init);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
errors.push({ index, message: error.message });
|
|
140
|
+
}
|
|
141
|
+
// Strict mode warnings
|
|
142
|
+
if (strict) {
|
|
143
|
+
if (item.path === '/**') {
|
|
144
|
+
warnings.push({
|
|
145
|
+
detail: '"/**" matches everything - ensure this is intentional',
|
|
146
|
+
index,
|
|
147
|
+
message: 'Overly broad pattern'
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
const validateStringConfig = (content, errors, warnings, strict) => {
|
|
154
|
+
const lines = content.split(/\r?\n/);
|
|
155
|
+
lines.forEach((line, lineNum) => {
|
|
156
|
+
const trimmed = line.trim();
|
|
157
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
158
|
+
return;
|
|
159
|
+
} // Skip empty/comments
|
|
160
|
+
try {
|
|
161
|
+
Right.parse(trimmed);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
errors.push({
|
|
165
|
+
detail: error.message,
|
|
166
|
+
line: lineNum + 1,
|
|
167
|
+
message: 'Invalid right definition'
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (strict && trimmed.includes('/**')) {
|
|
171
|
+
warnings.push({
|
|
172
|
+
line: lineNum + 1,
|
|
173
|
+
message: 'Overly broad pattern detected'
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { Rights } from '@/rights';
|
|
3
|
+
export const loadConfig = (filePath, format = 'auto') => {
|
|
4
|
+
const content = readFileSync(filePath, 'utf8');
|
|
5
|
+
if (format === 'auto') {
|
|
6
|
+
format = filePath.endsWith('.json') ? 'json' : 'string';
|
|
7
|
+
}
|
|
8
|
+
if (format === 'json') {
|
|
9
|
+
const data = JSON.parse(content);
|
|
10
|
+
return Rights.fromJSON(data);
|
|
11
|
+
}
|
|
12
|
+
return Rights.parse(content);
|
|
13
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Flags } from '@/constants';
|
|
2
|
+
const FLAG_MAP = {
|
|
3
|
+
'*': Flags.ALL,
|
|
4
|
+
ALL: Flags.ALL,
|
|
5
|
+
C: Flags.CREATE,
|
|
6
|
+
CREATE: Flags.CREATE,
|
|
7
|
+
D: Flags.DELETE,
|
|
8
|
+
DELETE: Flags.DELETE,
|
|
9
|
+
EXECUTE: Flags.EXECUTE,
|
|
10
|
+
R: Flags.READ,
|
|
11
|
+
READ: Flags.READ,
|
|
12
|
+
W: Flags.WRITE,
|
|
13
|
+
WRITE: Flags.WRITE,
|
|
14
|
+
X: Flags.EXECUTE
|
|
15
|
+
};
|
|
16
|
+
export const parseFlag = (input) => {
|
|
17
|
+
const normalized = input.toUpperCase().trim();
|
|
18
|
+
const flag = FLAG_MAP[normalized];
|
|
19
|
+
if (flag === undefined) {
|
|
20
|
+
throw new Error(`Unknown flag: ${input}. Valid flags: READ, WRITE, CREATE, DELETE, EXECUTE, ALL`);
|
|
21
|
+
}
|
|
22
|
+
return flag;
|
|
23
|
+
};
|
|
24
|
+
export const parseFlags = (input) => {
|
|
25
|
+
if (input.includes(',')) {
|
|
26
|
+
return input
|
|
27
|
+
.split(',')
|
|
28
|
+
.reduce((acc, f) => acc | parseFlag(f.trim()), 0);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return parseFlag(input);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
let combined = 0;
|
|
35
|
+
for (const ch of input.toUpperCase()) {
|
|
36
|
+
combined |= parseFlag(ch);
|
|
37
|
+
}
|
|
38
|
+
return combined;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const colors: {
|
|
2
|
+
bold: (s: string) => string;
|
|
3
|
+
cyan: (s: string) => string;
|
|
4
|
+
dim: (s: string) => string;
|
|
5
|
+
green: (s: string) => string;
|
|
6
|
+
red: (s: string) => string;
|
|
7
|
+
yellow: (s: string) => string;
|
|
8
|
+
};
|
|
9
|
+
export declare const formatResult: (allowed: boolean) => string;
|
|
10
|
+
export declare const flagName: (flag: number) => string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Flags } from '../../constants';
|
|
2
|
+
export const colors = {
|
|
3
|
+
bold: (s) => `\u001b[1m${s}\u001b[0m`,
|
|
4
|
+
cyan: (s) => `\u001b[36m${s}\u001b[0m`,
|
|
5
|
+
dim: (s) => `\u001b[2m${s}\u001b[0m`,
|
|
6
|
+
green: (s) => `\u001b[32m${s}\u001b[0m`,
|
|
7
|
+
red: (s) => `\u001b[31m${s}\u001b[0m`,
|
|
8
|
+
yellow: (s) => `\u001b[33m${s}\u001b[0m`
|
|
9
|
+
};
|
|
10
|
+
export const formatResult = (allowed) => allowed ? colors.green('ALLOWED') : colors.red('DENIED');
|
|
11
|
+
export const flagName = (flag) => {
|
|
12
|
+
const names = [];
|
|
13
|
+
if (flag & Flags.READ) {
|
|
14
|
+
names.push('READ');
|
|
15
|
+
}
|
|
16
|
+
if (flag & Flags.WRITE) {
|
|
17
|
+
names.push('WRITE');
|
|
18
|
+
}
|
|
19
|
+
if (flag & Flags.CREATE) {
|
|
20
|
+
names.push('CREATE');
|
|
21
|
+
}
|
|
22
|
+
if (flag & Flags.DELETE) {
|
|
23
|
+
names.push('DELETE');
|
|
24
|
+
}
|
|
25
|
+
if (flag & Flags.EXECUTE) {
|
|
26
|
+
names.push('EXECUTE');
|
|
27
|
+
}
|
|
28
|
+
return names.join(' | ') || 'NONE';
|
|
29
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import pkg from '../../package.json';
|
|
4
|
+
import { checkCommand } from './commands/check';
|
|
5
|
+
import { explainCommand } from './commands/explain';
|
|
6
|
+
import { validateCommand } from './commands/validate';
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name('odgn-rights')
|
|
10
|
+
.description('CLI tool for testing and debugging permission configurations')
|
|
11
|
+
.version(pkg.version);
|
|
12
|
+
program.addCommand(checkCommand);
|
|
13
|
+
program.addCommand(explainCommand);
|
|
14
|
+
program.addCommand(validateCommand);
|
|
15
|
+
program.parse();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/right.d.ts
CHANGED
|
@@ -6,6 +6,9 @@ export type RightInit = {
|
|
|
6
6
|
condition?: Condition;
|
|
7
7
|
deny?: Flags[];
|
|
8
8
|
description?: string;
|
|
9
|
+
tags?: string[];
|
|
10
|
+
validFrom?: Date;
|
|
11
|
+
validUntil?: Date;
|
|
9
12
|
};
|
|
10
13
|
export declare class Right {
|
|
11
14
|
readonly path: string;
|
|
@@ -13,20 +16,34 @@ export declare class Right {
|
|
|
13
16
|
private denyMask;
|
|
14
17
|
readonly description?: string;
|
|
15
18
|
readonly condition?: Condition;
|
|
19
|
+
readonly validFrom?: Date;
|
|
20
|
+
readonly validUntil?: Date;
|
|
21
|
+
private readonly _tags;
|
|
16
22
|
private readonly _specificity;
|
|
17
23
|
private readonly _re?;
|
|
18
24
|
constructor(path: string, init?: RightInit);
|
|
19
25
|
allow(flag: Flags): this;
|
|
20
26
|
deny(flag: Flags): this;
|
|
21
27
|
clear(): this;
|
|
22
|
-
|
|
28
|
+
get tags(): string[];
|
|
29
|
+
hasTag(tag: string): boolean;
|
|
30
|
+
hasTags(tags: string[], mode?: 'and' | 'or'): boolean;
|
|
31
|
+
addTag(tag: string): this;
|
|
32
|
+
removeTag(tag: string): this;
|
|
33
|
+
has(flag: Flags, now?: Date): boolean;
|
|
34
|
+
isValidAt(now?: Date): boolean;
|
|
35
|
+
isExpired(now?: Date): boolean;
|
|
23
36
|
get allowMaskValue(): number;
|
|
24
37
|
get denyMaskValue(): number;
|
|
25
38
|
toString(): string;
|
|
26
39
|
toJSON(): {
|
|
27
40
|
allow: string;
|
|
41
|
+
deny?: string;
|
|
28
42
|
description?: string;
|
|
29
43
|
path: string;
|
|
44
|
+
tags?: string[];
|
|
45
|
+
validFrom?: string;
|
|
46
|
+
validUntil?: string;
|
|
30
47
|
};
|
|
31
48
|
matches(targetPath: string): boolean;
|
|
32
49
|
specificity(): number;
|
package/dist/right.js
CHANGED
|
@@ -7,6 +7,12 @@ export class Right {
|
|
|
7
7
|
this.path = normalizePath(path);
|
|
8
8
|
this.description = init?.description;
|
|
9
9
|
this.condition = init?.condition;
|
|
10
|
+
this.validFrom = init?.validFrom;
|
|
11
|
+
this.validUntil = init?.validUntil;
|
|
12
|
+
this._tags = new Set(init?.tags);
|
|
13
|
+
if (this.validFrom && this.validUntil && this.validFrom > this.validUntil) {
|
|
14
|
+
throw new Error('validFrom must be before validUntil');
|
|
15
|
+
}
|
|
10
16
|
this._specificity = this.calculateSpecificity();
|
|
11
17
|
if (this.path.includes('*') || this.path.includes('?')) {
|
|
12
18
|
this._re = Right.globToRegExp(this.path);
|
|
@@ -35,7 +41,33 @@ export class Right {
|
|
|
35
41
|
this.denyMask = 0;
|
|
36
42
|
return this;
|
|
37
43
|
}
|
|
38
|
-
|
|
44
|
+
get tags() {
|
|
45
|
+
return [...this._tags].sort();
|
|
46
|
+
}
|
|
47
|
+
hasTag(tag) {
|
|
48
|
+
return this._tags.has(tag);
|
|
49
|
+
}
|
|
50
|
+
hasTags(tags, mode = 'and') {
|
|
51
|
+
if (mode === 'and') {
|
|
52
|
+
return tags.every(t => this._tags.has(t));
|
|
53
|
+
}
|
|
54
|
+
return tags.some(t => this._tags.has(t));
|
|
55
|
+
}
|
|
56
|
+
addTag(tag) {
|
|
57
|
+
this._tags.add(tag);
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
removeTag(tag) {
|
|
61
|
+
this._tags.delete(tag);
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
has(flag, now = new Date()) {
|
|
65
|
+
if (this.validFrom && now < this.validFrom) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (this.validUntil && now > this.validUntil) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
39
71
|
// For composite masks, require all bits
|
|
40
72
|
let remaining = flag;
|
|
41
73
|
for (const bit of ALL_BITS) {
|
|
@@ -52,6 +84,18 @@ export class Right {
|
|
|
52
84
|
}
|
|
53
85
|
return true;
|
|
54
86
|
}
|
|
87
|
+
isValidAt(now = new Date()) {
|
|
88
|
+
if (this.validFrom && now < this.validFrom) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (this.validUntil && now > this.validUntil) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
isExpired(now = new Date()) {
|
|
97
|
+
return this.validUntil !== undefined && now > this.validUntil;
|
|
98
|
+
}
|
|
55
99
|
get allowMaskValue() {
|
|
56
100
|
return this.allowMask;
|
|
57
101
|
}
|
|
@@ -69,17 +113,39 @@ export class Right {
|
|
|
69
113
|
parts.push(`+${allowLetters}`);
|
|
70
114
|
}
|
|
71
115
|
const left = parts.join('');
|
|
72
|
-
|
|
116
|
+
let res = `${left}:${this.path}`;
|
|
117
|
+
if (this._tags.size > 0) {
|
|
118
|
+
res += `#${this.tags.join(',')}`;
|
|
119
|
+
}
|
|
120
|
+
if (this.validFrom || this.validUntil) {
|
|
121
|
+
const from = this.validFrom ? this.validFrom.toISOString() : '*';
|
|
122
|
+
const until = this.validUntil ? this.validUntil.toISOString() : '*';
|
|
123
|
+
res += `@${from}/${until}`;
|
|
124
|
+
}
|
|
125
|
+
return res;
|
|
73
126
|
}
|
|
74
127
|
toJSON() {
|
|
75
128
|
const allow = lettersFromMask(this.allowMask);
|
|
129
|
+
const deny = lettersFromMask(this.denyMask);
|
|
76
130
|
const out = {
|
|
77
131
|
allow,
|
|
78
132
|
path: this.path
|
|
79
133
|
};
|
|
134
|
+
if (deny) {
|
|
135
|
+
out.deny = deny;
|
|
136
|
+
}
|
|
80
137
|
if (this.description) {
|
|
81
138
|
out.description = this.description;
|
|
82
139
|
}
|
|
140
|
+
if (this._tags.size > 0) {
|
|
141
|
+
out.tags = this.tags;
|
|
142
|
+
}
|
|
143
|
+
if (this.validFrom) {
|
|
144
|
+
out.validFrom = this.validFrom.toISOString();
|
|
145
|
+
}
|
|
146
|
+
if (this.validUntil) {
|
|
147
|
+
out.validUntil = this.validUntil.toISOString();
|
|
148
|
+
}
|
|
83
149
|
return out;
|
|
84
150
|
}
|
|
85
151
|
// Pattern match helper
|
|
@@ -140,25 +206,68 @@ export class Right {
|
|
|
140
206
|
}
|
|
141
207
|
static parse(input) {
|
|
142
208
|
const s = input.trim();
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
209
|
+
const colonIdx = s.indexOf(':');
|
|
210
|
+
const hashIdx = s.lastIndexOf('#');
|
|
211
|
+
const atIdx = s.lastIndexOf('@');
|
|
212
|
+
let flagsStr = '';
|
|
213
|
+
let pathStr = '';
|
|
214
|
+
let tagsStr = '';
|
|
215
|
+
let timeStr = '';
|
|
216
|
+
let pathEndIdx = s.length;
|
|
217
|
+
if (atIdx !== -1) {
|
|
218
|
+
timeStr = s.slice(atIdx + 1);
|
|
219
|
+
pathEndIdx = atIdx;
|
|
220
|
+
}
|
|
221
|
+
if (hashIdx !== -1 && (atIdx === -1 || hashIdx < atIdx)) {
|
|
222
|
+
tagsStr = s.slice(hashIdx + 1, pathEndIdx);
|
|
223
|
+
pathEndIdx = hashIdx;
|
|
224
|
+
}
|
|
225
|
+
if (colonIdx === -1) {
|
|
226
|
+
pathStr = s.slice(0, pathEndIdx);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
flagsStr = s.slice(0, colonIdx);
|
|
230
|
+
pathStr = s.slice(colonIdx + 1, pathEndIdx);
|
|
231
|
+
}
|
|
232
|
+
const init = {};
|
|
233
|
+
if (timeStr) {
|
|
234
|
+
const parts = timeStr.split('/');
|
|
235
|
+
const from = parts[0];
|
|
236
|
+
const until = parts[1];
|
|
237
|
+
if (from && from !== '*') {
|
|
238
|
+
const d = new Date(from);
|
|
239
|
+
if (!Number.isNaN(d.getTime())) {
|
|
240
|
+
init.validFrom = d;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (until && until !== '*') {
|
|
244
|
+
const d = new Date(until);
|
|
245
|
+
if (!Number.isNaN(d.getTime())) {
|
|
246
|
+
init.validUntil = d;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (tagsStr) {
|
|
251
|
+
init.tags = tagsStr.split(',').map(t => t.trim());
|
|
252
|
+
}
|
|
253
|
+
const r = new Right(pathStr, init);
|
|
254
|
+
if (!flagsStr) {
|
|
255
|
+
return r;
|
|
146
256
|
}
|
|
147
|
-
const groups = s.slice(0, idx);
|
|
148
|
-
const path = s.slice(idx + 1);
|
|
149
|
-
const r = new Right(path);
|
|
150
257
|
// parse groups like '-abc+xyz' or '+xyz-abc'
|
|
151
258
|
let i = 0;
|
|
152
|
-
while (i <
|
|
153
|
-
const sign =
|
|
259
|
+
while (i < flagsStr.length) {
|
|
260
|
+
const sign = flagsStr[i];
|
|
154
261
|
if (sign !== '+' && sign !== '-') {
|
|
155
262
|
i++;
|
|
156
263
|
continue;
|
|
157
264
|
}
|
|
158
265
|
i++;
|
|
159
266
|
let letters = '';
|
|
160
|
-
while (i <
|
|
161
|
-
|
|
267
|
+
while (i < flagsStr.length &&
|
|
268
|
+
flagsStr[i] !== '+' &&
|
|
269
|
+
flagsStr[i] !== '-') {
|
|
270
|
+
letters += flagsStr[i];
|
|
162
271
|
i++;
|
|
163
272
|
}
|
|
164
273
|
const apply = sign === '+' ? (f) => r.allow(f) : (f) => r.deny(f);
|
package/dist/rights.d.ts
CHANGED
|
@@ -2,13 +2,25 @@ import { Flags } from './constants';
|
|
|
2
2
|
import { Right, type ConditionContext } from './right';
|
|
3
3
|
export type RightJSON = {
|
|
4
4
|
allow: string;
|
|
5
|
+
deny?: string;
|
|
5
6
|
description?: string;
|
|
6
7
|
path: string;
|
|
8
|
+
tags?: string[];
|
|
9
|
+
validFrom?: string;
|
|
10
|
+
validUntil?: string;
|
|
7
11
|
};
|
|
8
12
|
export declare class Rights {
|
|
9
13
|
private list;
|
|
10
14
|
private matchCache;
|
|
15
|
+
private _onChange?;
|
|
16
|
+
set onChange(cb: () => void | undefined);
|
|
17
|
+
private notify;
|
|
11
18
|
add(right: Right): this;
|
|
19
|
+
findByTag(tag: string): Right[];
|
|
20
|
+
findByTags(tags: string[], mode?: 'and' | 'or'): Right[];
|
|
21
|
+
revokeByTag(tag: string): this;
|
|
22
|
+
allowByTag(tag: string, ...flags: Flags[]): this;
|
|
23
|
+
prune(now?: Date): this;
|
|
12
24
|
allRights(): Right[];
|
|
13
25
|
allow(path: string, ...flags: Flags[]): this;
|
|
14
26
|
deny(path: string, flag: Flags): this;
|
package/dist/rights.js
CHANGED
|
@@ -1,14 +1,60 @@
|
|
|
1
1
|
import { ALL_BITS, Flags, hasBit } from './constants';
|
|
2
2
|
import { Right } from './right';
|
|
3
|
-
import {
|
|
3
|
+
import { normalizePath } from './utils';
|
|
4
4
|
export class Rights {
|
|
5
5
|
constructor() {
|
|
6
6
|
this.list = [];
|
|
7
7
|
this.matchCache = new Map();
|
|
8
8
|
}
|
|
9
|
+
set onChange(cb) {
|
|
10
|
+
this._onChange = cb;
|
|
11
|
+
}
|
|
12
|
+
notify() {
|
|
13
|
+
this._onChange?.();
|
|
14
|
+
}
|
|
9
15
|
add(right) {
|
|
10
16
|
this.list.push(right);
|
|
11
17
|
this.matchCache.clear();
|
|
18
|
+
this.notify();
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
findByTag(tag) {
|
|
22
|
+
return this.list.filter(r => r.hasTag(tag));
|
|
23
|
+
}
|
|
24
|
+
findByTags(tags, mode = 'and') {
|
|
25
|
+
return this.list.filter(r => r.hasTags(tags, mode));
|
|
26
|
+
}
|
|
27
|
+
revokeByTag(tag) {
|
|
28
|
+
const toRemove = this.findByTag(tag);
|
|
29
|
+
if (toRemove.length > 0) {
|
|
30
|
+
this.list = this.list.filter(r => !toRemove.includes(r));
|
|
31
|
+
this.matchCache.clear();
|
|
32
|
+
this.notify();
|
|
33
|
+
}
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
allowByTag(tag, ...flags) {
|
|
37
|
+
const matches = this.findByTag(tag);
|
|
38
|
+
if (matches.length > 0) {
|
|
39
|
+
// Support spreading an array
|
|
40
|
+
const flat = [].concat(...flags);
|
|
41
|
+
for (const r of matches) {
|
|
42
|
+
for (const f of flat) {
|
|
43
|
+
r.allow(f);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
this.matchCache.clear();
|
|
47
|
+
this.notify();
|
|
48
|
+
}
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
prune(now = new Date()) {
|
|
52
|
+
const originalCount = this.list.length;
|
|
53
|
+
this.list = this.list.filter(r => !r.isExpired(now));
|
|
54
|
+
if (this.list.length !== originalCount) {
|
|
55
|
+
this.matchCache.clear();
|
|
56
|
+
this.notify();
|
|
57
|
+
}
|
|
12
58
|
return this;
|
|
13
59
|
}
|
|
14
60
|
allRights() {
|
|
@@ -19,7 +65,7 @@ export class Rights {
|
|
|
19
65
|
let r = this.list.find(x => x.path === p);
|
|
20
66
|
if (!r) {
|
|
21
67
|
r = new Right(p);
|
|
22
|
-
this.
|
|
68
|
+
this.list.push(r);
|
|
23
69
|
}
|
|
24
70
|
else {
|
|
25
71
|
// Invalidate cache if we update an existing right
|
|
@@ -30,6 +76,7 @@ export class Rights {
|
|
|
30
76
|
for (const f of flat) {
|
|
31
77
|
r.allow(f);
|
|
32
78
|
}
|
|
79
|
+
this.notify();
|
|
33
80
|
return this;
|
|
34
81
|
}
|
|
35
82
|
deny(path, flag) {
|
|
@@ -37,13 +84,14 @@ export class Rights {
|
|
|
37
84
|
let r = this.list.find(x => x.path === p);
|
|
38
85
|
if (!r) {
|
|
39
86
|
r = new Right(p);
|
|
40
|
-
this.
|
|
87
|
+
this.list.push(r);
|
|
41
88
|
}
|
|
42
89
|
else {
|
|
43
90
|
// Invalidate cache if we update an existing right
|
|
44
91
|
this.matchCache.clear();
|
|
45
92
|
}
|
|
46
93
|
r.deny(flag);
|
|
94
|
+
this.notify();
|
|
47
95
|
return this;
|
|
48
96
|
}
|
|
49
97
|
matchOrdered(path) {
|
|
@@ -92,8 +140,12 @@ export class Rights {
|
|
|
92
140
|
return this.explainSingle(path, bit, context).allowed;
|
|
93
141
|
}
|
|
94
142
|
explainSingle(path, bit, context) {
|
|
143
|
+
const now = getNow(context);
|
|
95
144
|
const matches = this.matchOrdered(normalizePath(path));
|
|
96
145
|
for (const r of matches) {
|
|
146
|
+
if (!r.isValidAt(now)) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
97
149
|
if (r.condition && !r.condition(context)) {
|
|
98
150
|
continue;
|
|
99
151
|
}
|
|
@@ -126,9 +178,7 @@ export class Rights {
|
|
|
126
178
|
return this.has(path, Flags.EXECUTE, context);
|
|
127
179
|
}
|
|
128
180
|
toString() {
|
|
129
|
-
return this.list
|
|
130
|
-
.map(r => `+${lettersFromMask(r.allowMaskValue)}:${r.path}`)
|
|
131
|
-
.join(', ');
|
|
181
|
+
return this.list.map(r => r.toString()).join(', ');
|
|
132
182
|
}
|
|
133
183
|
toJSON() {
|
|
134
184
|
return this.list.map(r => r.toJSON());
|
|
@@ -137,32 +187,31 @@ export class Rights {
|
|
|
137
187
|
const rights = new Rights();
|
|
138
188
|
for (const item of arr) {
|
|
139
189
|
const p = normalizePath(item.path);
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
190
|
+
const init = {
|
|
191
|
+
description: item.description,
|
|
192
|
+
tags: item.tags
|
|
193
|
+
};
|
|
194
|
+
if (item.validFrom) {
|
|
195
|
+
const d = new Date(item.validFrom);
|
|
196
|
+
if (!Number.isNaN(d.getTime())) {
|
|
197
|
+
init.validFrom = d;
|
|
198
|
+
}
|
|
144
199
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
r.allow(Flags.READ);
|
|
150
|
-
break;
|
|
151
|
-
case 'w':
|
|
152
|
-
r.allow(Flags.WRITE);
|
|
153
|
-
break;
|
|
154
|
-
case 'c':
|
|
155
|
-
r.allow(Flags.CREATE);
|
|
156
|
-
break;
|
|
157
|
-
case 'd':
|
|
158
|
-
r.allow(Flags.DELETE);
|
|
159
|
-
break;
|
|
160
|
-
case 'x':
|
|
161
|
-
r.allow(Flags.EXECUTE);
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
200
|
+
if (item.validUntil) {
|
|
201
|
+
const d = new Date(item.validUntil);
|
|
202
|
+
if (!Number.isNaN(d.getTime())) {
|
|
203
|
+
init.validUntil = d;
|
|
164
204
|
}
|
|
165
205
|
}
|
|
206
|
+
const r = new Right(p, init);
|
|
207
|
+
const allowStr = item.allow;
|
|
208
|
+
const denyStr = item.deny;
|
|
209
|
+
if (allowStr) {
|
|
210
|
+
applyFlags(allowStr, f => r.allow(f));
|
|
211
|
+
}
|
|
212
|
+
if (denyStr) {
|
|
213
|
+
applyFlags(denyStr, f => r.deny(f));
|
|
214
|
+
}
|
|
166
215
|
rights.add(r);
|
|
167
216
|
}
|
|
168
217
|
return rights;
|
|
@@ -187,3 +236,39 @@ export class Rights {
|
|
|
187
236
|
return this.list.map(r => r.toString()).join(separator);
|
|
188
237
|
}
|
|
189
238
|
}
|
|
239
|
+
const getNow = (context) => {
|
|
240
|
+
if (context &&
|
|
241
|
+
typeof context === 'object' &&
|
|
242
|
+
'_now' in context &&
|
|
243
|
+
// eslint-disable-next-line @nkzw/no-instanceof
|
|
244
|
+
context._now instanceof Date) {
|
|
245
|
+
return context._now;
|
|
246
|
+
}
|
|
247
|
+
return new Date();
|
|
248
|
+
};
|
|
249
|
+
const applyFlags = (str, apply) => {
|
|
250
|
+
if (str === '*') {
|
|
251
|
+
apply(Flags.ALL);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
for (const ch of str) {
|
|
255
|
+
switch (ch) {
|
|
256
|
+
case 'r':
|
|
257
|
+
apply(Flags.READ);
|
|
258
|
+
break;
|
|
259
|
+
case 'w':
|
|
260
|
+
apply(Flags.WRITE);
|
|
261
|
+
break;
|
|
262
|
+
case 'c':
|
|
263
|
+
apply(Flags.CREATE);
|
|
264
|
+
break;
|
|
265
|
+
case 'd':
|
|
266
|
+
apply(Flags.DELETE);
|
|
267
|
+
break;
|
|
268
|
+
case 'x':
|
|
269
|
+
apply(Flags.EXECUTE);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
package/dist/role-registry.js
CHANGED
package/dist/role.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { RoleJSON } from './role-registry';
|
|
|
4
4
|
export declare class Role {
|
|
5
5
|
readonly name: string;
|
|
6
6
|
readonly rights: Rights;
|
|
7
|
-
|
|
7
|
+
readonly parents: Role[];
|
|
8
|
+
private children;
|
|
8
9
|
private _cachedAllRights;
|
|
9
10
|
constructor(name: string, rights?: Rights);
|
|
10
11
|
inheritsFrom(role: Role): this;
|
|
@@ -18,6 +19,7 @@ export declare class Role {
|
|
|
18
19
|
type: 'role';
|
|
19
20
|
};
|
|
20
21
|
}>;
|
|
22
|
+
findRightsByTag(tag: string): Right[];
|
|
21
23
|
invalidateCache(): void;
|
|
22
24
|
toJSON(): RoleJSON;
|
|
23
25
|
}
|
package/dist/role.js
CHANGED
|
@@ -3,9 +3,11 @@ import { Rights } from './rights';
|
|
|
3
3
|
export class Role {
|
|
4
4
|
constructor(name, rights) {
|
|
5
5
|
this.parents = [];
|
|
6
|
+
this.children = [];
|
|
6
7
|
this._cachedAllRights = null;
|
|
7
8
|
this.name = name;
|
|
8
9
|
this.rights = rights ?? new Rights();
|
|
10
|
+
this.rights.onChange = () => this.invalidateCache();
|
|
9
11
|
}
|
|
10
12
|
inheritsFrom(role) {
|
|
11
13
|
if (role === this) {
|
|
@@ -13,6 +15,7 @@ export class Role {
|
|
|
13
15
|
}
|
|
14
16
|
if (!this.parents.includes(role)) {
|
|
15
17
|
this.parents.push(role);
|
|
18
|
+
role.children.push(this);
|
|
16
19
|
this.invalidateCache();
|
|
17
20
|
}
|
|
18
21
|
return this;
|
|
@@ -34,8 +37,16 @@ export class Role {
|
|
|
34
37
|
this._cachedAllRights = list;
|
|
35
38
|
return list;
|
|
36
39
|
}
|
|
40
|
+
findRightsByTag(tag) {
|
|
41
|
+
return this.allRights()
|
|
42
|
+
.map(r => r.right)
|
|
43
|
+
.filter(r => r.hasTag(tag));
|
|
44
|
+
}
|
|
37
45
|
invalidateCache() {
|
|
38
46
|
this._cachedAllRights = null;
|
|
47
|
+
for (const child of this.children) {
|
|
48
|
+
child.invalidateCache();
|
|
49
|
+
}
|
|
39
50
|
}
|
|
40
51
|
toJSON() {
|
|
41
52
|
const out = {
|
package/dist/subject.d.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { Flags } from './constants';
|
|
2
2
|
import { Right, type ConditionContext } from './right';
|
|
3
|
-
import { Rights } from './rights';
|
|
3
|
+
import { Rights, type RightJSON } from './rights';
|
|
4
4
|
import { Role } from './role';
|
|
5
|
+
import type { RoleRegistry } from './role-registry';
|
|
6
|
+
export type SubjectJSON = {
|
|
7
|
+
rights?: RightJSON[];
|
|
8
|
+
roles?: string[];
|
|
9
|
+
};
|
|
5
10
|
export declare class Subject {
|
|
6
|
-
|
|
11
|
+
readonly roles: Role[];
|
|
7
12
|
readonly rights: Rights;
|
|
8
13
|
private _aggregate;
|
|
9
14
|
private _aggregateMeta;
|
|
15
|
+
toJSON(): SubjectJSON;
|
|
16
|
+
static fromJSON(data: SubjectJSON, registry?: RoleRegistry): Subject;
|
|
10
17
|
memberOf(role: Role): this;
|
|
11
18
|
invalidateCache(): void;
|
|
12
19
|
has(path: string, flag: Flags, context?: ConditionContext): boolean;
|
|
@@ -22,6 +29,14 @@ export declare class Subject {
|
|
|
22
29
|
};
|
|
23
30
|
}>;
|
|
24
31
|
};
|
|
32
|
+
allRights(): Array<{
|
|
33
|
+
right: Right;
|
|
34
|
+
source?: {
|
|
35
|
+
name?: string;
|
|
36
|
+
type: 'direct' | 'role';
|
|
37
|
+
};
|
|
38
|
+
}>;
|
|
39
|
+
private ensureAggregate;
|
|
25
40
|
all(path: string, context?: ConditionContext): boolean;
|
|
26
41
|
read(path: string, context?: ConditionContext): boolean;
|
|
27
42
|
write(path: string, context?: ConditionContext): boolean;
|
package/dist/subject.js
CHANGED
|
@@ -9,6 +9,35 @@ export class Subject {
|
|
|
9
9
|
this._aggregate = null;
|
|
10
10
|
this._aggregateMeta = null;
|
|
11
11
|
}
|
|
12
|
+
toJSON() {
|
|
13
|
+
const out = {};
|
|
14
|
+
if (this.roles.length > 0) {
|
|
15
|
+
out.roles = this.roles.map(r => r.name);
|
|
16
|
+
}
|
|
17
|
+
const rights = this.rights.toJSON();
|
|
18
|
+
if (rights.length > 0) {
|
|
19
|
+
out.rights = rights;
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
static fromJSON(data, registry) {
|
|
24
|
+
const subject = new Subject();
|
|
25
|
+
if (data.roles && registry) {
|
|
26
|
+
for (const roleName of data.roles) {
|
|
27
|
+
const role = registry.get(roleName);
|
|
28
|
+
if (role) {
|
|
29
|
+
subject.memberOf(role);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (data.rights) {
|
|
34
|
+
const rights = Rights.fromJSON(data.rights);
|
|
35
|
+
for (const r of rights.allRights()) {
|
|
36
|
+
subject.rights.add(r);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return subject;
|
|
40
|
+
}
|
|
12
41
|
memberOf(role) {
|
|
13
42
|
if (!this.roles.includes(role)) {
|
|
14
43
|
this.roles.push(role);
|
|
@@ -24,6 +53,24 @@ export class Subject {
|
|
|
24
53
|
return this.explain(path, flag, context).allowed;
|
|
25
54
|
}
|
|
26
55
|
explain(path, flag, context) {
|
|
56
|
+
const { meta, rights } = this.ensureAggregate();
|
|
57
|
+
const res = rights.explain(path, flag, context);
|
|
58
|
+
return {
|
|
59
|
+
allowed: res.allowed,
|
|
60
|
+
details: res.details.map(d => ({
|
|
61
|
+
...d,
|
|
62
|
+
source: d.right ? meta.get(d.right) : undefined
|
|
63
|
+
}))
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
allRights() {
|
|
67
|
+
const { meta, rights } = this.ensureAggregate();
|
|
68
|
+
return rights.allRights().map(right => ({
|
|
69
|
+
right,
|
|
70
|
+
source: meta.get(right)
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
ensureAggregate() {
|
|
27
74
|
if (!this._aggregate) {
|
|
28
75
|
this._aggregate = new Rights();
|
|
29
76
|
this._aggregateMeta = new Map();
|
|
@@ -42,14 +89,7 @@ export class Subject {
|
|
|
42
89
|
this._aggregateMeta.set(r, { type: 'direct' });
|
|
43
90
|
}
|
|
44
91
|
}
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
allowed: res.allowed,
|
|
48
|
-
details: res.details.map(d => ({
|
|
49
|
-
...d,
|
|
50
|
-
source: d.right ? this._aggregateMeta.get(d.right) : undefined
|
|
51
|
-
}))
|
|
52
|
-
};
|
|
92
|
+
return { meta: this._aggregateMeta, rights: this._aggregate };
|
|
53
93
|
}
|
|
54
94
|
// Convenience helpers
|
|
55
95
|
all(path, context) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "odgn-rights",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Tiny TypeScript library for expressing and evaluating hierarchical rights with simple glob patterns.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rights",
|
|
@@ -16,10 +16,18 @@
|
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"main": "dist/index.js",
|
|
18
18
|
"types": "dist/index.d.ts",
|
|
19
|
+
"bin": {
|
|
20
|
+
"odgn-rights": "./dist/cli/index.js",
|
|
21
|
+
"rights": "./dist/cli/index.js"
|
|
22
|
+
},
|
|
19
23
|
"exports": {
|
|
20
24
|
".": {
|
|
21
25
|
"types": "./dist/index.d.ts",
|
|
22
26
|
"import": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./cli": {
|
|
29
|
+
"types": "./dist/cli/index.d.ts",
|
|
30
|
+
"import": "./dist/cli/index.js"
|
|
23
31
|
}
|
|
24
32
|
},
|
|
25
33
|
"files": [
|
|
@@ -32,17 +40,24 @@
|
|
|
32
40
|
"lint": "eslint --cache .",
|
|
33
41
|
"lint:fix": "eslint --fix .",
|
|
34
42
|
"outdated": "bunx npm-check-updates --interactive --format group",
|
|
35
|
-
"test": "bun test",
|
|
36
|
-
"test:coverage": "bun test --coverage",
|
|
43
|
+
"test": "bun test src",
|
|
44
|
+
"test:coverage": "bun test --coverage src",
|
|
45
|
+
"test:e2e": "playwright test",
|
|
46
|
+
"test:e2e:ui": "playwright test --ui",
|
|
37
47
|
"unused": "knip",
|
|
38
48
|
"clean": "rm -rf dist",
|
|
39
49
|
"build": "tsc -p tsconfig.build.json",
|
|
50
|
+
"playground": "bun ./playground/index.html",
|
|
51
|
+
"playground:build": "bun run ./playground/build.ts",
|
|
40
52
|
"prepublishOnly": "bun run clean && bun run build && bun run test && bun run lint:fix && bun run format:check"
|
|
41
53
|
},
|
|
42
54
|
"devDependencies": {
|
|
43
55
|
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
|
44
56
|
"@nkzw/eslint-config": "^3.3.0",
|
|
57
|
+
"@playwright/test": "^1.57.0",
|
|
45
58
|
"@types/bun": "latest",
|
|
59
|
+
"@types/react": "^19.2.7",
|
|
60
|
+
"@types/react-dom": "^19.2.3",
|
|
46
61
|
"eslint": "^9",
|
|
47
62
|
"knip": "^5.76.3",
|
|
48
63
|
"prettier": "^3"
|
|
@@ -53,5 +68,11 @@
|
|
|
53
68
|
"publishConfig": {
|
|
54
69
|
"access": "public"
|
|
55
70
|
},
|
|
56
|
-
"sideEffects": false
|
|
71
|
+
"sideEffects": false,
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"commander": "^14.0.2",
|
|
74
|
+
"jotai": "^2.16.0",
|
|
75
|
+
"react": "^19.2.3",
|
|
76
|
+
"react-dom": "^19.2.3"
|
|
77
|
+
}
|
|
57
78
|
}
|