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 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;
@@ -0,0 +1,7 @@
1
+ export * from './constants';
2
+ export * from './right';
3
+ export * from './rights';
4
+ export * from './role';
5
+ export * from './role-registry';
6
+ export * from './subject';
7
+ export * from './utils';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from './constants';
2
+ export * from './right';
3
+ export * from './rights';
4
+ export * from './role';
5
+ export * from './role-registry';
6
+ export * from './subject';
7
+ export * from './utils';
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export declare const normalizePath: (p: string) => string;
2
+ export declare const lettersFromMask: (mask: number) => string;
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
+ }