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 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,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const checkCommand: Command;
@@ -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,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const explainCommand: Command;
@@ -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,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const validateCommand: Command;
@@ -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,3 @@
1
+ import { Rights } from '@/rights';
2
+ export type ConfigFormat = 'json' | 'string' | 'auto';
3
+ export declare const loadConfig: (filePath: string, format?: ConfigFormat) => Rights;
@@ -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,3 @@
1
+ import { Flags } from '@/constants';
2
+ export declare const parseFlag: (input: string) => Flags;
3
+ export declare const parseFlags: (input: string) => number;
@@ -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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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,10 @@
1
+ export type CLIOptions = {
2
+ config: string;
3
+ context?: string;
4
+ flag: string;
5
+ json?: boolean;
6
+ path: string;
7
+ quiet?: boolean;
8
+ strict?: boolean;
9
+ time?: string;
10
+ };
@@ -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
- has(flag: Flags): boolean;
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
- has(flag) {
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
- return `${left}:${this.path}`;
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 idx = s.indexOf(':');
144
- if (idx === -1) {
145
- return new Right(s);
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 < groups.length) {
153
- const sign = groups[i];
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 < groups.length && groups[i] !== '+' && groups[i] !== '-') {
161
- letters += groups[i];
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 { lettersFromMask, normalizePath } from './utils';
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.add(r);
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.add(r);
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 r = new Right(p, { description: item.description });
141
- const allowStr = item.allow;
142
- if (allowStr === '*') {
143
- r.allow(Flags.ALL);
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
- else {
146
- for (const ch of allowStr) {
147
- switch (ch) {
148
- case 'r':
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
+ };
@@ -38,6 +38,9 @@ export class RoleRegistry {
38
38
  if (parent) {
39
39
  role.inheritsFrom(parent);
40
40
  }
41
+ else {
42
+ throw new Error(`Role ${item.name} inherits from missing role ${parentName}`);
43
+ }
41
44
  }
42
45
  }
43
46
  }
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
- private parents;
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
- private roles;
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
- const res = this._aggregate.explain(path, flag, context);
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.1.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
  }