odgn-rights 0.1.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 +107 -0
- package/dist/constants.d.ts +11 -0
- package/dist/constants.js +17 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/right.d.ts +36 -0
- package/dist/right.js +191 -0
- package/dist/rights.d.ts +38 -0
- package/dist/rights.js +189 -0
- package/dist/role-registry.d.ts +14 -0
- package/dist/role-registry.js +46 -0
- package/dist/role.d.ts +23 -0
- package/dist/role.js +50 -0
- package/dist/subject.d.ts +31 -0
- package/dist/subject.js +73 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +38 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# ODGN Rights
|
|
2
|
+
|
|
3
|
+
Tiny TypeScript library for expressing and evaluating hierarchical rights with simple glob patterns.
|
|
4
|
+
|
|
5
|
+
## Install & Dev
|
|
6
|
+
|
|
7
|
+
- Install: `bun install`
|
|
8
|
+
- Test: `bun test` (watch: `bun test --watch`)
|
|
9
|
+
|
|
10
|
+
Use in TS:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { Flags, Right, Rights } from 'odgn-rights'; // when packaged
|
|
14
|
+
|
|
15
|
+
// or locally: import { Rights, Right, Flags } from './src/index.ts';
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const rights = new Rights();
|
|
22
|
+
rights.allow('/', Flags.READ);
|
|
23
|
+
|
|
24
|
+
// Deny read, allow create anywhere matching /*/device/**
|
|
25
|
+
rights.add(
|
|
26
|
+
new Right('/*/device/**', { allow: [Flags.CREATE], deny: [Flags.READ] })
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Grant everything under /system/user/*
|
|
30
|
+
rights.add(new Right('/system/user/*', { allow: [Flags.ALL] }));
|
|
31
|
+
|
|
32
|
+
rights.read('/system/user/1'); // true
|
|
33
|
+
rights.write('/system/user/1'); // true
|
|
34
|
+
rights.create('/system/device/a'); // true
|
|
35
|
+
rights.read('/system/device/a'); // false (denied by more specific rule)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## API Overview
|
|
39
|
+
|
|
40
|
+
- `Right(path, {allow?, deny?, description?})`
|
|
41
|
+
- Flags: `Flags.READ | WRITE | DELETE | CREATE | EXECUTE | ALL`
|
|
42
|
+
- Methods: `allow(flag)`, `deny(flag)`, `clear()`, `has(mask)`
|
|
43
|
+
- String form: `-denies+allows:/path` (e.g., `-d+rw:/system`)
|
|
44
|
+
- `Right.parse(str)` creates a Right from its string form
|
|
45
|
+
- `Rights`
|
|
46
|
+
- `add(right)`, `allow(path, ...flags)`, `deny(path, flag)`
|
|
47
|
+
- Checks: `read|write|delete|create|execute|all(path)`
|
|
48
|
+
- Serialization: `format(separator?)`, `toJSON()`, `Rights.parse(str)`, `Rights.fromJSON(json)`
|
|
49
|
+
- `Role(name, rights?)`
|
|
50
|
+
- `inheritsFrom(role)`: Inherit rights from another role.
|
|
51
|
+
- `Subject`
|
|
52
|
+
- `memberOf(role)`: Assign a role to the subject.
|
|
53
|
+
- `rights`: Direct `Rights` assigned to the subject.
|
|
54
|
+
- `has(path, flag)`: Evaluates permissions across all roles and direct rights.
|
|
55
|
+
- `RoleRegistry`
|
|
56
|
+
- `define(name, rights?)`: Define or update a role.
|
|
57
|
+
- `toJSON() / RoleRegistry.fromJSON(json)`: Serialization with inheritance.
|
|
58
|
+
|
|
59
|
+
Matching precedence: the most specific matching rule wins; within a rule, a denied flag blocks that flag even if allowed by a less specific rule.
|
|
60
|
+
|
|
61
|
+
## RBAC Example
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const registry = new RoleRegistry();
|
|
65
|
+
const viewer = registry.define('viewer', new Rights().allow('/', Flags.READ));
|
|
66
|
+
const editor = registry.define(
|
|
67
|
+
'editor',
|
|
68
|
+
new Rights().allow('/posts', Flags.WRITE)
|
|
69
|
+
);
|
|
70
|
+
editor.inheritsFrom(viewer);
|
|
71
|
+
|
|
72
|
+
const user = new Subject().memberOf(editor);
|
|
73
|
+
user.read('/posts/1'); // true (inherited from viewer)
|
|
74
|
+
user.write('/posts/1'); // true (from editor)
|
|
75
|
+
user.write('/config'); // false
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Contextual Rights (ABAC)
|
|
79
|
+
|
|
80
|
+
Rights can include a condition predicate that is evaluated at runtime with a provided context.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
rights.add(
|
|
84
|
+
new Right('/posts/*', {
|
|
85
|
+
allow: [Flags.WRITE],
|
|
86
|
+
condition: ctx => ctx.userId === ctx.ownerId
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Provide context to the check
|
|
91
|
+
rights.write('/posts/1', { userId: 'abc', ownerId: 'abc' }); // true
|
|
92
|
+
rights.write('/posts/1', { userId: 'abc', ownerId: 'xyz' }); // false
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Glob Patterns
|
|
96
|
+
|
|
97
|
+
- `*` matches within a single path segment (`/system/*/id`)
|
|
98
|
+
- `**` matches across segments (`/*/device/**`)
|
|
99
|
+
- `?` matches a single character (no slash)
|
|
100
|
+
|
|
101
|
+
## JSON Round‑Trip
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const json = rights.toJSON();
|
|
105
|
+
// [ { path: '/', allow: 'r' }, { path: '/*/device/**', allow: 'c' }, ... ]
|
|
106
|
+
const loaded = Rights.fromJSON(json);
|
|
107
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const Flags: {
|
|
2
|
+
readonly EXECUTE: 16;
|
|
3
|
+
readonly READ: 1;
|
|
4
|
+
readonly DELETE: 4;
|
|
5
|
+
readonly CREATE: 8;
|
|
6
|
+
readonly WRITE: 2;
|
|
7
|
+
readonly ALL: 31;
|
|
8
|
+
};
|
|
9
|
+
export type Flags = (typeof Flags)[keyof typeof Flags];
|
|
10
|
+
export declare const ALL_BITS: Flags[];
|
|
11
|
+
export declare const hasBit: (mask: number, bit: number) => boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* eslint-disable perfectionist/sort-objects */
|
|
2
|
+
export const Flags = {
|
|
3
|
+
EXECUTE: 16,
|
|
4
|
+
READ: 1,
|
|
5
|
+
DELETE: 4,
|
|
6
|
+
CREATE: 8,
|
|
7
|
+
WRITE: 2,
|
|
8
|
+
ALL: 31
|
|
9
|
+
};
|
|
10
|
+
export const ALL_BITS = [
|
|
11
|
+
Flags.READ,
|
|
12
|
+
Flags.WRITE,
|
|
13
|
+
Flags.DELETE,
|
|
14
|
+
Flags.CREATE,
|
|
15
|
+
Flags.EXECUTE
|
|
16
|
+
];
|
|
17
|
+
export const hasBit = (mask, bit) => (mask & bit) === bit;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/right.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Flags } from './constants';
|
|
2
|
+
export type ConditionContext = unknown;
|
|
3
|
+
export type Condition = (context?: ConditionContext) => boolean;
|
|
4
|
+
export type RightInit = {
|
|
5
|
+
allow?: Flags[];
|
|
6
|
+
condition?: Condition;
|
|
7
|
+
deny?: Flags[];
|
|
8
|
+
description?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare class Right {
|
|
11
|
+
readonly path: string;
|
|
12
|
+
private allowMask;
|
|
13
|
+
private denyMask;
|
|
14
|
+
readonly description?: string;
|
|
15
|
+
readonly condition?: Condition;
|
|
16
|
+
private readonly _specificity;
|
|
17
|
+
private readonly _re?;
|
|
18
|
+
constructor(path: string, init?: RightInit);
|
|
19
|
+
allow(flag: Flags): this;
|
|
20
|
+
deny(flag: Flags): this;
|
|
21
|
+
clear(): this;
|
|
22
|
+
has(flag: Flags): boolean;
|
|
23
|
+
get allowMaskValue(): number;
|
|
24
|
+
get denyMaskValue(): number;
|
|
25
|
+
toString(): string;
|
|
26
|
+
toJSON(): {
|
|
27
|
+
allow: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
path: string;
|
|
30
|
+
};
|
|
31
|
+
matches(targetPath: string): boolean;
|
|
32
|
+
specificity(): number;
|
|
33
|
+
private calculateSpecificity;
|
|
34
|
+
private static globToRegExp;
|
|
35
|
+
static parse(input: string): Right;
|
|
36
|
+
}
|
package/dist/right.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { ALL_BITS, Flags, hasBit } from './constants';
|
|
2
|
+
import { lettersFromMask, normalizePath } from './utils';
|
|
3
|
+
export class Right {
|
|
4
|
+
constructor(path, init) {
|
|
5
|
+
this.allowMask = 0;
|
|
6
|
+
this.denyMask = 0;
|
|
7
|
+
this.path = normalizePath(path);
|
|
8
|
+
this.description = init?.description;
|
|
9
|
+
this.condition = init?.condition;
|
|
10
|
+
this._specificity = this.calculateSpecificity();
|
|
11
|
+
if (this.path.includes('*') || this.path.includes('?')) {
|
|
12
|
+
this._re = Right.globToRegExp(this.path);
|
|
13
|
+
}
|
|
14
|
+
if (init?.allow) {
|
|
15
|
+
init.allow.forEach(f => this.allow(f));
|
|
16
|
+
}
|
|
17
|
+
if (init?.deny) {
|
|
18
|
+
init.deny.forEach(f => this.deny(f));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
allow(flag) {
|
|
22
|
+
this.allowMask |= flag;
|
|
23
|
+
// allowing a flag clears it from deny
|
|
24
|
+
this.denyMask &= ~flag;
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
deny(flag) {
|
|
28
|
+
this.denyMask |= flag;
|
|
29
|
+
// denying a flag clears it from allow
|
|
30
|
+
this.allowMask &= ~flag;
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
clear() {
|
|
34
|
+
this.allowMask = 0;
|
|
35
|
+
this.denyMask = 0;
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
has(flag) {
|
|
39
|
+
// For composite masks, require all bits
|
|
40
|
+
let remaining = flag;
|
|
41
|
+
for (const bit of ALL_BITS) {
|
|
42
|
+
if (!hasBit(remaining, bit)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (hasBit(this.denyMask, bit)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (!hasBit(this.allowMask, bit)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
remaining &= ~bit;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
get allowMaskValue() {
|
|
56
|
+
return this.allowMask;
|
|
57
|
+
}
|
|
58
|
+
get denyMaskValue() {
|
|
59
|
+
return this.denyMask;
|
|
60
|
+
}
|
|
61
|
+
toString() {
|
|
62
|
+
const denyLetters = lettersFromMask(this.denyMask);
|
|
63
|
+
const allowLetters = lettersFromMask(this.allowMask);
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (denyLetters) {
|
|
66
|
+
parts.push(`-${denyLetters}`);
|
|
67
|
+
}
|
|
68
|
+
if (allowLetters) {
|
|
69
|
+
parts.push(`+${allowLetters}`);
|
|
70
|
+
}
|
|
71
|
+
const left = parts.join('');
|
|
72
|
+
return `${left}:${this.path}`;
|
|
73
|
+
}
|
|
74
|
+
toJSON() {
|
|
75
|
+
const allow = lettersFromMask(this.allowMask);
|
|
76
|
+
const out = {
|
|
77
|
+
allow,
|
|
78
|
+
path: this.path
|
|
79
|
+
};
|
|
80
|
+
if (this.description) {
|
|
81
|
+
out.description = this.description;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
// Pattern match helper
|
|
86
|
+
matches(targetPath) {
|
|
87
|
+
const t = normalizePath(targetPath);
|
|
88
|
+
if (this._re) {
|
|
89
|
+
return this._re.test(t);
|
|
90
|
+
}
|
|
91
|
+
// No wildcard: segment-aware prefix match
|
|
92
|
+
if (this.path === '/') {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return t === this.path || t.startsWith(this.path + '/');
|
|
96
|
+
}
|
|
97
|
+
// Specificity score: more non-wildcard chars => more specific
|
|
98
|
+
specificity() {
|
|
99
|
+
return this._specificity;
|
|
100
|
+
}
|
|
101
|
+
calculateSpecificity() {
|
|
102
|
+
const parts = this.path.split('/').filter(p => p.length > 0);
|
|
103
|
+
let literalCount = 0;
|
|
104
|
+
let literalLen = 0;
|
|
105
|
+
for (const p of parts) {
|
|
106
|
+
if (!p.includes('*')) {
|
|
107
|
+
literalCount += 1;
|
|
108
|
+
literalLen += p.length;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Prioritize by number of literal segments, then by total literal length
|
|
112
|
+
return literalCount * 1000 + literalLen;
|
|
113
|
+
}
|
|
114
|
+
static globToRegExp(pattern) {
|
|
115
|
+
// Convert a path glob with '**', '*', '?' into a safe anchored RegExp
|
|
116
|
+
let out = '^';
|
|
117
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
118
|
+
const ch = pattern[i];
|
|
119
|
+
if (!ch) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (ch === '*') {
|
|
123
|
+
let starCount = 1;
|
|
124
|
+
while (i + 1 < pattern.length && pattern[i + 1] === '*') {
|
|
125
|
+
starCount++;
|
|
126
|
+
i++;
|
|
127
|
+
}
|
|
128
|
+
out += starCount >= 2 ? '.*' : '[^/]*';
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (ch === '?') {
|
|
132
|
+
out += '[^/]';
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// Escape regex specials
|
|
136
|
+
out += String.raw `\^$+.?()|{}[]`.includes(ch) ? '\\' + ch : ch;
|
|
137
|
+
}
|
|
138
|
+
out += '$';
|
|
139
|
+
return new RegExp(out);
|
|
140
|
+
}
|
|
141
|
+
static parse(input) {
|
|
142
|
+
const s = input.trim();
|
|
143
|
+
const idx = s.indexOf(':');
|
|
144
|
+
if (idx === -1) {
|
|
145
|
+
return new Right(s);
|
|
146
|
+
}
|
|
147
|
+
const groups = s.slice(0, idx);
|
|
148
|
+
const path = s.slice(idx + 1);
|
|
149
|
+
const r = new Right(path);
|
|
150
|
+
// parse groups like '-abc+xyz' or '+xyz-abc'
|
|
151
|
+
let i = 0;
|
|
152
|
+
while (i < groups.length) {
|
|
153
|
+
const sign = groups[i];
|
|
154
|
+
if (sign !== '+' && sign !== '-') {
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
i++;
|
|
159
|
+
let letters = '';
|
|
160
|
+
while (i < groups.length && groups[i] !== '+' && groups[i] !== '-') {
|
|
161
|
+
letters += groups[i];
|
|
162
|
+
i++;
|
|
163
|
+
}
|
|
164
|
+
const apply = sign === '+' ? (f) => r.allow(f) : (f) => r.deny(f);
|
|
165
|
+
if (letters === '*') {
|
|
166
|
+
apply(Flags.ALL);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const ch of letters) {
|
|
170
|
+
switch (ch) {
|
|
171
|
+
case 'r':
|
|
172
|
+
apply(Flags.READ);
|
|
173
|
+
break;
|
|
174
|
+
case 'w':
|
|
175
|
+
apply(Flags.WRITE);
|
|
176
|
+
break;
|
|
177
|
+
case 'c':
|
|
178
|
+
apply(Flags.CREATE);
|
|
179
|
+
break;
|
|
180
|
+
case 'd':
|
|
181
|
+
apply(Flags.DELETE);
|
|
182
|
+
break;
|
|
183
|
+
case 'x':
|
|
184
|
+
apply(Flags.EXECUTE);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return r;
|
|
190
|
+
}
|
|
191
|
+
}
|
package/dist/rights.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Flags } from './constants';
|
|
2
|
+
import { Right, type ConditionContext } from './right';
|
|
3
|
+
export type RightJSON = {
|
|
4
|
+
allow: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
path: string;
|
|
7
|
+
};
|
|
8
|
+
export declare class Rights {
|
|
9
|
+
private list;
|
|
10
|
+
private matchCache;
|
|
11
|
+
add(right: Right): this;
|
|
12
|
+
allRights(): Right[];
|
|
13
|
+
allow(path: string, ...flags: Flags[]): this;
|
|
14
|
+
deny(path: string, flag: Flags): this;
|
|
15
|
+
private matchOrdered;
|
|
16
|
+
has(path: string, flag: Flags, context?: ConditionContext): boolean;
|
|
17
|
+
explain(path: string, flag: Flags, context?: ConditionContext): {
|
|
18
|
+
allowed: boolean;
|
|
19
|
+
details: Array<{
|
|
20
|
+
allowed: boolean;
|
|
21
|
+
bit: Flags;
|
|
22
|
+
right?: Right;
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
private hasSingle;
|
|
26
|
+
private explainSingle;
|
|
27
|
+
all(path: string, context?: ConditionContext): boolean;
|
|
28
|
+
read(path: string, context?: ConditionContext): boolean;
|
|
29
|
+
write(path: string, context?: ConditionContext): boolean;
|
|
30
|
+
delete(path: string, context?: ConditionContext): boolean;
|
|
31
|
+
create(path: string, context?: ConditionContext): boolean;
|
|
32
|
+
execute(path: string, context?: ConditionContext): boolean;
|
|
33
|
+
toString(): string;
|
|
34
|
+
toJSON(): RightJSON[];
|
|
35
|
+
static fromJSON(arr: RightJSON[]): Rights;
|
|
36
|
+
static parse(input: string): Rights;
|
|
37
|
+
format(separator?: string): string;
|
|
38
|
+
}
|
package/dist/rights.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { ALL_BITS, Flags, hasBit } from './constants';
|
|
2
|
+
import { Right } from './right';
|
|
3
|
+
import { lettersFromMask, normalizePath } from './utils';
|
|
4
|
+
export class Rights {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.list = [];
|
|
7
|
+
this.matchCache = new Map();
|
|
8
|
+
}
|
|
9
|
+
add(right) {
|
|
10
|
+
this.list.push(right);
|
|
11
|
+
this.matchCache.clear();
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
allRights() {
|
|
15
|
+
return [...this.list];
|
|
16
|
+
}
|
|
17
|
+
allow(path, ...flags) {
|
|
18
|
+
const p = normalizePath(path);
|
|
19
|
+
let r = this.list.find(x => x.path === p);
|
|
20
|
+
if (!r) {
|
|
21
|
+
r = new Right(p);
|
|
22
|
+
this.add(r);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
// Invalidate cache if we update an existing right
|
|
26
|
+
this.matchCache.clear();
|
|
27
|
+
}
|
|
28
|
+
// Support spreading an array: allow(path, [Flags.READ] as any)
|
|
29
|
+
const flat = [].concat(...flags);
|
|
30
|
+
for (const f of flat) {
|
|
31
|
+
r.allow(f);
|
|
32
|
+
}
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
deny(path, flag) {
|
|
36
|
+
const p = normalizePath(path);
|
|
37
|
+
let r = this.list.find(x => x.path === p);
|
|
38
|
+
if (!r) {
|
|
39
|
+
r = new Right(p);
|
|
40
|
+
this.add(r);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
// Invalidate cache if we update an existing right
|
|
44
|
+
this.matchCache.clear();
|
|
45
|
+
}
|
|
46
|
+
r.deny(flag);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
matchOrdered(path) {
|
|
50
|
+
const cached = this.matchCache.get(path);
|
|
51
|
+
if (cached) {
|
|
52
|
+
return cached;
|
|
53
|
+
}
|
|
54
|
+
const result = this.list
|
|
55
|
+
.filter(r => r.matches(path))
|
|
56
|
+
.sort((a, b) => b.specificity() - a.specificity());
|
|
57
|
+
this.matchCache.set(path, result);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
has(path, flag, context) {
|
|
61
|
+
// For composite masks, all bits must succeed
|
|
62
|
+
let remaining = flag;
|
|
63
|
+
for (const bit of ALL_BITS) {
|
|
64
|
+
if (!hasBit(remaining, bit)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const ok = this.hasSingle(path, bit, context);
|
|
68
|
+
if (!ok) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
remaining &= ~bit;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
explain(path, flag, context) {
|
|
76
|
+
const p = normalizePath(path);
|
|
77
|
+
const details = [];
|
|
78
|
+
let allAllowed = true;
|
|
79
|
+
for (const bit of ALL_BITS) {
|
|
80
|
+
if (!hasBit(flag, bit)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const res = this.explainSingle(p, bit, context);
|
|
84
|
+
if (!res.allowed) {
|
|
85
|
+
allAllowed = false;
|
|
86
|
+
}
|
|
87
|
+
details.push({ bit, ...res });
|
|
88
|
+
}
|
|
89
|
+
return { allowed: allAllowed, details };
|
|
90
|
+
}
|
|
91
|
+
hasSingle(path, bit, context) {
|
|
92
|
+
return this.explainSingle(path, bit, context).allowed;
|
|
93
|
+
}
|
|
94
|
+
explainSingle(path, bit, context) {
|
|
95
|
+
const matches = this.matchOrdered(normalizePath(path));
|
|
96
|
+
for (const r of matches) {
|
|
97
|
+
if (r.condition && !r.condition(context)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (hasBit(r.denyMaskValue, bit)) {
|
|
101
|
+
return { allowed: false, right: r };
|
|
102
|
+
}
|
|
103
|
+
if (hasBit(r.allowMaskValue, bit)) {
|
|
104
|
+
return { allowed: true, right: r };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { allowed: false };
|
|
108
|
+
}
|
|
109
|
+
// Convenience helpers
|
|
110
|
+
all(path, context) {
|
|
111
|
+
return this.has(path, Flags.ALL, context);
|
|
112
|
+
}
|
|
113
|
+
read(path, context) {
|
|
114
|
+
return this.has(path, Flags.READ, context);
|
|
115
|
+
}
|
|
116
|
+
write(path, context) {
|
|
117
|
+
return this.has(path, Flags.WRITE, context);
|
|
118
|
+
}
|
|
119
|
+
delete(path, context) {
|
|
120
|
+
return this.has(path, Flags.DELETE, context);
|
|
121
|
+
}
|
|
122
|
+
create(path, context) {
|
|
123
|
+
return this.has(path, Flags.CREATE, context);
|
|
124
|
+
}
|
|
125
|
+
execute(path, context) {
|
|
126
|
+
return this.has(path, Flags.EXECUTE, context);
|
|
127
|
+
}
|
|
128
|
+
toString() {
|
|
129
|
+
return this.list
|
|
130
|
+
.map(r => `+${lettersFromMask(r.allowMaskValue)}:${r.path}`)
|
|
131
|
+
.join(', ');
|
|
132
|
+
}
|
|
133
|
+
toJSON() {
|
|
134
|
+
return this.list.map(r => r.toJSON());
|
|
135
|
+
}
|
|
136
|
+
static fromJSON(arr) {
|
|
137
|
+
const rights = new Rights();
|
|
138
|
+
for (const item of arr) {
|
|
139
|
+
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);
|
|
144
|
+
}
|
|
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
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
rights.add(r);
|
|
167
|
+
}
|
|
168
|
+
return rights;
|
|
169
|
+
}
|
|
170
|
+
static parse(input) {
|
|
171
|
+
const rights = new Rights();
|
|
172
|
+
if (!input) {
|
|
173
|
+
return rights;
|
|
174
|
+
}
|
|
175
|
+
const parts = input.split(/[\n\r,?]+/);
|
|
176
|
+
for (const part of parts) {
|
|
177
|
+
const trimmed = part.trim();
|
|
178
|
+
if (!trimmed) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const r = Right.parse(trimmed);
|
|
182
|
+
rights.add(r);
|
|
183
|
+
}
|
|
184
|
+
return rights;
|
|
185
|
+
}
|
|
186
|
+
format(separator = ', ') {
|
|
187
|
+
return this.list.map(r => r.toString()).join(separator);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Rights, type RightJSON } from './rights';
|
|
2
|
+
import { Role } from './role';
|
|
3
|
+
export type RoleJSON = {
|
|
4
|
+
inherits?: string[];
|
|
5
|
+
name: string;
|
|
6
|
+
rights: RightJSON[];
|
|
7
|
+
};
|
|
8
|
+
export declare class RoleRegistry {
|
|
9
|
+
private roles;
|
|
10
|
+
define(name: string, rights?: Rights): Role;
|
|
11
|
+
get(name: string): Role | undefined;
|
|
12
|
+
toJSON(): RoleJSON[];
|
|
13
|
+
static fromJSON(data: RoleJSON[]): RoleRegistry;
|
|
14
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Rights } from './rights';
|
|
2
|
+
import { Role } from './role';
|
|
3
|
+
export class RoleRegistry {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.roles = new Map();
|
|
6
|
+
}
|
|
7
|
+
define(name, rights) {
|
|
8
|
+
let role = this.roles.get(name);
|
|
9
|
+
if (!role) {
|
|
10
|
+
role = new Role(name, rights);
|
|
11
|
+
this.roles.set(name, role);
|
|
12
|
+
}
|
|
13
|
+
else if (rights) {
|
|
14
|
+
for (const r of rights.allRights()) {
|
|
15
|
+
role.rights.add(r);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return role;
|
|
19
|
+
}
|
|
20
|
+
get(name) {
|
|
21
|
+
return this.roles.get(name);
|
|
22
|
+
}
|
|
23
|
+
toJSON() {
|
|
24
|
+
return Array.from(this.roles.values()).map(r => r.toJSON());
|
|
25
|
+
}
|
|
26
|
+
static fromJSON(data) {
|
|
27
|
+
const registry = new RoleRegistry();
|
|
28
|
+
// First pass: create all roles
|
|
29
|
+
for (const item of data) {
|
|
30
|
+
registry.define(item.name, item.rights ? Rights.fromJSON(item.rights) : undefined);
|
|
31
|
+
}
|
|
32
|
+
// Second pass: resolve inheritance
|
|
33
|
+
for (const item of data) {
|
|
34
|
+
const role = registry.get(item.name);
|
|
35
|
+
if (item.inherits) {
|
|
36
|
+
for (const parentName of item.inherits) {
|
|
37
|
+
const parent = registry.get(parentName);
|
|
38
|
+
if (parent) {
|
|
39
|
+
role.inheritsFrom(parent);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return registry;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/role.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Right } from './right';
|
|
2
|
+
import { Rights } from './rights';
|
|
3
|
+
import type { RoleJSON } from './role-registry';
|
|
4
|
+
export declare class Role {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly rights: Rights;
|
|
7
|
+
private parents;
|
|
8
|
+
private _cachedAllRights;
|
|
9
|
+
constructor(name: string, rights?: Rights);
|
|
10
|
+
inheritsFrom(role: Role): this;
|
|
11
|
+
/**
|
|
12
|
+
* Returns all rights associated with this role, including inherited ones.
|
|
13
|
+
*/
|
|
14
|
+
allRights(): Array<{
|
|
15
|
+
right: Right;
|
|
16
|
+
source?: {
|
|
17
|
+
name: string;
|
|
18
|
+
type: 'role';
|
|
19
|
+
};
|
|
20
|
+
}>;
|
|
21
|
+
invalidateCache(): void;
|
|
22
|
+
toJSON(): RoleJSON;
|
|
23
|
+
}
|
package/dist/role.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Right } from './right';
|
|
2
|
+
import { Rights } from './rights';
|
|
3
|
+
export class Role {
|
|
4
|
+
constructor(name, rights) {
|
|
5
|
+
this.parents = [];
|
|
6
|
+
this._cachedAllRights = null;
|
|
7
|
+
this.name = name;
|
|
8
|
+
this.rights = rights ?? new Rights();
|
|
9
|
+
}
|
|
10
|
+
inheritsFrom(role) {
|
|
11
|
+
if (role === this) {
|
|
12
|
+
throw new Error(`Role ${this.name} cannot inherit from itself`);
|
|
13
|
+
}
|
|
14
|
+
if (!this.parents.includes(role)) {
|
|
15
|
+
this.parents.push(role);
|
|
16
|
+
this.invalidateCache();
|
|
17
|
+
}
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Returns all rights associated with this role, including inherited ones.
|
|
22
|
+
*/
|
|
23
|
+
allRights() {
|
|
24
|
+
if (this._cachedAllRights) {
|
|
25
|
+
return this._cachedAllRights;
|
|
26
|
+
}
|
|
27
|
+
const list = this.rights.allRights().map(r => ({
|
|
28
|
+
right: r,
|
|
29
|
+
source: { name: this.name, type: 'role' }
|
|
30
|
+
}));
|
|
31
|
+
for (const parent of this.parents) {
|
|
32
|
+
list.push(...parent.allRights());
|
|
33
|
+
}
|
|
34
|
+
this._cachedAllRights = list;
|
|
35
|
+
return list;
|
|
36
|
+
}
|
|
37
|
+
invalidateCache() {
|
|
38
|
+
this._cachedAllRights = null;
|
|
39
|
+
}
|
|
40
|
+
toJSON() {
|
|
41
|
+
const out = {
|
|
42
|
+
name: this.name,
|
|
43
|
+
rights: this.rights.toJSON()
|
|
44
|
+
};
|
|
45
|
+
if (this.parents.length > 0) {
|
|
46
|
+
out.inherits = this.parents.map(p => p.name);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Flags } from './constants';
|
|
2
|
+
import { Right, type ConditionContext } from './right';
|
|
3
|
+
import { Rights } from './rights';
|
|
4
|
+
import { Role } from './role';
|
|
5
|
+
export declare class Subject {
|
|
6
|
+
private roles;
|
|
7
|
+
readonly rights: Rights;
|
|
8
|
+
private _aggregate;
|
|
9
|
+
private _aggregateMeta;
|
|
10
|
+
memberOf(role: Role): this;
|
|
11
|
+
invalidateCache(): void;
|
|
12
|
+
has(path: string, flag: Flags, context?: ConditionContext): boolean;
|
|
13
|
+
explain(path: string, flag: Flags, context?: ConditionContext): {
|
|
14
|
+
allowed: boolean;
|
|
15
|
+
details: Array<{
|
|
16
|
+
allowed: boolean;
|
|
17
|
+
bit: Flags;
|
|
18
|
+
right?: Right;
|
|
19
|
+
source?: {
|
|
20
|
+
name?: string;
|
|
21
|
+
type: 'direct' | 'role';
|
|
22
|
+
};
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
all(path: string, context?: ConditionContext): boolean;
|
|
26
|
+
read(path: string, context?: ConditionContext): boolean;
|
|
27
|
+
write(path: string, context?: ConditionContext): boolean;
|
|
28
|
+
delete(path: string, context?: ConditionContext): boolean;
|
|
29
|
+
create(path: string, context?: ConditionContext): boolean;
|
|
30
|
+
execute(path: string, context?: ConditionContext): boolean;
|
|
31
|
+
}
|
package/dist/subject.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Flags } from './constants';
|
|
2
|
+
import { Right } from './right';
|
|
3
|
+
import { Rights } from './rights';
|
|
4
|
+
import { Role } from './role';
|
|
5
|
+
export class Subject {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.roles = [];
|
|
8
|
+
this.rights = new Rights();
|
|
9
|
+
this._aggregate = null;
|
|
10
|
+
this._aggregateMeta = null;
|
|
11
|
+
}
|
|
12
|
+
memberOf(role) {
|
|
13
|
+
if (!this.roles.includes(role)) {
|
|
14
|
+
this.roles.push(role);
|
|
15
|
+
this.invalidateCache();
|
|
16
|
+
}
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
invalidateCache() {
|
|
20
|
+
this._aggregate = null;
|
|
21
|
+
this._aggregateMeta = null;
|
|
22
|
+
}
|
|
23
|
+
has(path, flag, context) {
|
|
24
|
+
return this.explain(path, flag, context).allowed;
|
|
25
|
+
}
|
|
26
|
+
explain(path, flag, context) {
|
|
27
|
+
if (!this._aggregate) {
|
|
28
|
+
this._aggregate = new Rights();
|
|
29
|
+
this._aggregateMeta = new Map();
|
|
30
|
+
// Add rights from roles
|
|
31
|
+
for (const role of this.roles) {
|
|
32
|
+
for (const entry of role.allRights()) {
|
|
33
|
+
this._aggregate.add(entry.right);
|
|
34
|
+
if (entry.source) {
|
|
35
|
+
this._aggregateMeta.set(entry.right, entry.source);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Add direct rights
|
|
40
|
+
for (const r of this.rights.allRights()) {
|
|
41
|
+
this._aggregate.add(r);
|
|
42
|
+
this._aggregateMeta.set(r, { type: 'direct' });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
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
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Convenience helpers
|
|
55
|
+
all(path, context) {
|
|
56
|
+
return this.has(path, Flags.ALL, context);
|
|
57
|
+
}
|
|
58
|
+
read(path, context) {
|
|
59
|
+
return this.has(path, Flags.READ, context);
|
|
60
|
+
}
|
|
61
|
+
write(path, context) {
|
|
62
|
+
return this.has(path, Flags.WRITE, context);
|
|
63
|
+
}
|
|
64
|
+
delete(path, context) {
|
|
65
|
+
return this.has(path, Flags.DELETE, context);
|
|
66
|
+
}
|
|
67
|
+
create(path, context) {
|
|
68
|
+
return this.has(path, Flags.CREATE, context);
|
|
69
|
+
}
|
|
70
|
+
execute(path, context) {
|
|
71
|
+
return this.has(path, Flags.EXECUTE, context);
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/utils.d.ts
ADDED
package/dist/utils.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Flags, hasBit } from './constants';
|
|
2
|
+
export const normalizePath = (p) => {
|
|
3
|
+
if (!p) {
|
|
4
|
+
return '/';
|
|
5
|
+
}
|
|
6
|
+
let out = p.trim();
|
|
7
|
+
if (!out.startsWith('/')) {
|
|
8
|
+
out = '/' + out;
|
|
9
|
+
}
|
|
10
|
+
out = out.replace(/\/+/, '/');
|
|
11
|
+
out = out.replaceAll(/\/+/g, '/');
|
|
12
|
+
if (out.length > 1 && out.endsWith('/')) {
|
|
13
|
+
out = out.slice(0, -1);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
};
|
|
17
|
+
export const lettersFromMask = (mask) => {
|
|
18
|
+
if (mask === Flags.ALL) {
|
|
19
|
+
return '*';
|
|
20
|
+
}
|
|
21
|
+
const letters = [];
|
|
22
|
+
if (hasBit(mask, Flags.READ)) {
|
|
23
|
+
letters.push('r');
|
|
24
|
+
}
|
|
25
|
+
if (hasBit(mask, Flags.WRITE)) {
|
|
26
|
+
letters.push('w');
|
|
27
|
+
}
|
|
28
|
+
if (hasBit(mask, Flags.CREATE)) {
|
|
29
|
+
letters.push('c');
|
|
30
|
+
}
|
|
31
|
+
if (hasBit(mask, Flags.DELETE)) {
|
|
32
|
+
letters.push('d');
|
|
33
|
+
}
|
|
34
|
+
if (hasBit(mask, Flags.EXECUTE)) {
|
|
35
|
+
letters.push('x');
|
|
36
|
+
}
|
|
37
|
+
return letters.join('');
|
|
38
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "odgn-rights",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny TypeScript library for expressing and evaluating hierarchical rights with simple glob patterns.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"rights",
|
|
7
|
+
"permissions",
|
|
8
|
+
"glob",
|
|
9
|
+
"typescript"
|
|
10
|
+
],
|
|
11
|
+
"author": "Alex Veenendaal",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://tangled.sh:alex.veenenda.al/odgn-rights"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"main": "dist/index.js",
|
|
18
|
+
"types": "dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"format": "prettier --write .",
|
|
31
|
+
"format:check": "prettier --check .",
|
|
32
|
+
"lint": "eslint --cache .",
|
|
33
|
+
"lint:fix": "eslint --fix .",
|
|
34
|
+
"outdated": "bunx npm-check-updates --interactive --format group",
|
|
35
|
+
"test": "bun test",
|
|
36
|
+
"test:coverage": "bun test --coverage",
|
|
37
|
+
"unused": "knip",
|
|
38
|
+
"clean": "rm -rf dist",
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"prepublishOnly": "bun run clean && bun run build && bun run test && bun run lint:fix && bun run format:check"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
|
44
|
+
"@nkzw/eslint-config": "^3.3.0",
|
|
45
|
+
"@types/bun": "latest",
|
|
46
|
+
"eslint": "^9",
|
|
47
|
+
"knip": "^5.76.3",
|
|
48
|
+
"prettier": "^3"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"typescript": "^5"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"sideEffects": false
|
|
57
|
+
}
|