odgn-rights 0.2.0 → 0.5.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 +368 -0
- package/dist/adapters/base-adapter.d.ts +83 -0
- package/dist/adapters/base-adapter.js +142 -0
- package/dist/adapters/factories.d.ts +31 -0
- package/dist/adapters/factories.js +48 -0
- package/dist/adapters/index.d.ts +11 -0
- package/dist/adapters/index.js +12 -0
- package/dist/adapters/postgres-adapter.d.ts +51 -0
- package/dist/adapters/postgres-adapter.js +469 -0
- package/dist/adapters/redis-adapter.d.ts +84 -0
- package/dist/adapters/redis-adapter.js +673 -0
- package/dist/adapters/schema.d.ts +25 -0
- package/dist/adapters/schema.js +186 -0
- package/dist/adapters/sqlite-adapter.d.ts +78 -0
- package/dist/adapters/sqlite-adapter.js +655 -0
- package/dist/adapters/types.d.ts +174 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli/commands/explain.js +13 -5
- package/dist/helpers.d.ts +16 -0
- package/dist/{utils.js → helpers.js} +22 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/integrations/elysia.d.ts +235 -0
- package/dist/integrations/elysia.js +375 -0
- package/dist/right.d.ts +7 -0
- package/dist/right.js +63 -8
- package/dist/rights.d.ts +19 -0
- package/dist/rights.js +48 -2
- package/dist/role-registry.d.ts +9 -0
- package/dist/role-registry.js +12 -0
- package/dist/subject-registry.d.ts +77 -0
- package/dist/subject-registry.js +123 -0
- package/dist/subject.d.ts +4 -0
- package/dist/subject.js +3 -0
- package/package.json +41 -6
- package/dist/utils.d.ts +0 -2
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import Redis from 'ioredis';
|
|
2
|
+
import { Flags } from '../constants';
|
|
3
|
+
import { Right } from '../right';
|
|
4
|
+
import { Rights } from '../rights';
|
|
5
|
+
import { Role } from '../role';
|
|
6
|
+
import { RoleRegistry } from '../role-registry';
|
|
7
|
+
import { Subject } from '../subject';
|
|
8
|
+
import { BaseAdapter } from './base-adapter';
|
|
9
|
+
/**
|
|
10
|
+
* Redis key structure:
|
|
11
|
+
*
|
|
12
|
+
* Rights:
|
|
13
|
+
* {prefix}rights:{id} → Hash { path, allow_mask, deny_mask, description, tags, valid_from, valid_until, created_at, updated_at }
|
|
14
|
+
* {prefix}rights:_seq → String (auto-increment counter)
|
|
15
|
+
* {prefix}rights:_all → Set of all right IDs
|
|
16
|
+
* {prefix}rights:_unique:{hash} → String (right ID for unique constraint lookup)
|
|
17
|
+
*
|
|
18
|
+
* Roles:
|
|
19
|
+
* {prefix}roles:{name} → Hash { name, created_at, updated_at }
|
|
20
|
+
* {prefix}roles:_all → Set of all role names
|
|
21
|
+
* {prefix}roles:{name}:rights → Set of right IDs
|
|
22
|
+
* {prefix}roles:{name}:parents → Set of parent role names
|
|
23
|
+
*
|
|
24
|
+
* Subjects:
|
|
25
|
+
* {prefix}subjects:{identifier} → Hash { identifier, id, created_at, updated_at }
|
|
26
|
+
* {prefix}subjects:_all → Set of all subject identifiers
|
|
27
|
+
* {prefix}subjects:_seq → String (auto-increment counter for subject IDs)
|
|
28
|
+
* {prefix}subjects:{identifier}:roles → Set of role names
|
|
29
|
+
* {prefix}subjects:{identifier}:rights → Set of right IDs
|
|
30
|
+
*/
|
|
31
|
+
export class RedisAdapter extends BaseAdapter {
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
super(options);
|
|
34
|
+
this.redis = null;
|
|
35
|
+
this.transactionDepth = 0;
|
|
36
|
+
this.options = options;
|
|
37
|
+
}
|
|
38
|
+
// ===========================================================================
|
|
39
|
+
// Key Helpers
|
|
40
|
+
// ===========================================================================
|
|
41
|
+
/**
|
|
42
|
+
* Generate a Redis key with the configured prefix
|
|
43
|
+
*/
|
|
44
|
+
key(...parts) {
|
|
45
|
+
const prefix = this.options.keyPrefix ?? this.tablePrefix;
|
|
46
|
+
return `${prefix}${parts.join(':')}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Generate a unique hash for a right's unique constraint fields
|
|
50
|
+
*/
|
|
51
|
+
rightUniqueHash(right) {
|
|
52
|
+
const parts = [
|
|
53
|
+
right.path,
|
|
54
|
+
right.allowMaskValue.toString(),
|
|
55
|
+
right.denyMaskValue.toString(),
|
|
56
|
+
right.priority.toString(),
|
|
57
|
+
right.validFrom?.toISOString() ?? 'null',
|
|
58
|
+
right.validUntil?.toISOString() ?? 'null'
|
|
59
|
+
];
|
|
60
|
+
return parts.join('|');
|
|
61
|
+
}
|
|
62
|
+
// ===========================================================================
|
|
63
|
+
// Lifecycle
|
|
64
|
+
// ===========================================================================
|
|
65
|
+
async connect() {
|
|
66
|
+
this.redis = this.options.url
|
|
67
|
+
? new Redis(this.options.url, {
|
|
68
|
+
lazyConnect: this.options.lazyConnect ?? true
|
|
69
|
+
})
|
|
70
|
+
: new Redis({
|
|
71
|
+
db: this.options.db ?? 0,
|
|
72
|
+
host: this.options.host ?? 'localhost',
|
|
73
|
+
lazyConnect: this.options.lazyConnect ?? true,
|
|
74
|
+
password: this.options.password,
|
|
75
|
+
port: this.options.port ?? 6379,
|
|
76
|
+
tls: this.options.tls
|
|
77
|
+
});
|
|
78
|
+
await this.redis.connect();
|
|
79
|
+
}
|
|
80
|
+
async disconnect() {
|
|
81
|
+
if (this.redis) {
|
|
82
|
+
await this.redis.quit();
|
|
83
|
+
this.redis = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async migrate() {
|
|
87
|
+
// Redis is schemaless, so migration is a no-op
|
|
88
|
+
// Just verify we're connected
|
|
89
|
+
if (!this.redis) {
|
|
90
|
+
throw new Error('Not connected');
|
|
91
|
+
}
|
|
92
|
+
await this.redis.ping();
|
|
93
|
+
}
|
|
94
|
+
// ===========================================================================
|
|
95
|
+
// Rights Operations
|
|
96
|
+
// ===========================================================================
|
|
97
|
+
async saveRight(right) {
|
|
98
|
+
if (!this.redis) {
|
|
99
|
+
throw new Error('Not connected');
|
|
100
|
+
}
|
|
101
|
+
const uniqueHash = this.rightUniqueHash(right);
|
|
102
|
+
const uniqueKey = this.key('rights', '_unique', uniqueHash);
|
|
103
|
+
// Check for existing right with same unique constraint
|
|
104
|
+
const existingId = await this.redis.get(uniqueKey);
|
|
105
|
+
if (existingId) {
|
|
106
|
+
const id = Number.parseInt(existingId, 10);
|
|
107
|
+
// Update the updated_at timestamp
|
|
108
|
+
await this.redis.hset(this.key('rights', existingId), 'updated_at', new Date().toISOString());
|
|
109
|
+
right._setDbId(id);
|
|
110
|
+
return id;
|
|
111
|
+
}
|
|
112
|
+
// Generate new ID
|
|
113
|
+
const id = await this.redis.incr(this.key('rights', '_seq'));
|
|
114
|
+
const now = new Date().toISOString();
|
|
115
|
+
const row = this.rightToRow(right);
|
|
116
|
+
const hashData = {
|
|
117
|
+
allow_mask: row.allow_mask.toString(),
|
|
118
|
+
created_at: now,
|
|
119
|
+
deny_mask: row.deny_mask.toString(),
|
|
120
|
+
description: row.description ?? '',
|
|
121
|
+
id: id.toString(),
|
|
122
|
+
path: row.path,
|
|
123
|
+
priority: row.priority.toString(),
|
|
124
|
+
tags: row.tags ?? '',
|
|
125
|
+
updated_at: now,
|
|
126
|
+
valid_from: row.valid_from ?? '',
|
|
127
|
+
valid_until: row.valid_until ?? ''
|
|
128
|
+
};
|
|
129
|
+
const rightKey = this.key('rights', id.toString());
|
|
130
|
+
// Use pipeline for atomic operation
|
|
131
|
+
const pipeline = this.redis.multi();
|
|
132
|
+
pipeline.hset(rightKey, hashData);
|
|
133
|
+
pipeline.sadd(this.key('rights', '_all'), id.toString());
|
|
134
|
+
pipeline.set(uniqueKey, id.toString());
|
|
135
|
+
await pipeline.exec();
|
|
136
|
+
right._setDbId(id);
|
|
137
|
+
return id;
|
|
138
|
+
}
|
|
139
|
+
async saveRights(rights) {
|
|
140
|
+
const ids = [];
|
|
141
|
+
await this.transaction(async () => {
|
|
142
|
+
for (const right of rights.allRights()) {
|
|
143
|
+
const id = await this.saveRight(right);
|
|
144
|
+
ids.push(id);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
return ids;
|
|
148
|
+
}
|
|
149
|
+
async loadRight(id) {
|
|
150
|
+
if (!this.redis) {
|
|
151
|
+
throw new Error('Not connected');
|
|
152
|
+
}
|
|
153
|
+
const rightKey = this.key('rights', id.toString());
|
|
154
|
+
const data = await this.redis.hgetall(rightKey);
|
|
155
|
+
if (!data || Object.keys(data).length === 0) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const row = this.hashToRightsRow(data);
|
|
159
|
+
return this.rowToRight(row);
|
|
160
|
+
}
|
|
161
|
+
async loadRights() {
|
|
162
|
+
if (!this.redis) {
|
|
163
|
+
throw new Error('Not connected');
|
|
164
|
+
}
|
|
165
|
+
const allIds = await this.redis.smembers(this.key('rights', '_all'));
|
|
166
|
+
const loadedRights = new Rights();
|
|
167
|
+
for (const idStr of allIds) {
|
|
168
|
+
const right = await this.loadRight(Number.parseInt(idStr, 10));
|
|
169
|
+
if (right) {
|
|
170
|
+
loadedRights.add(right);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return loadedRights;
|
|
174
|
+
}
|
|
175
|
+
async loadRightsByPath(pathPattern) {
|
|
176
|
+
if (!this.redis) {
|
|
177
|
+
throw new Error('Not connected');
|
|
178
|
+
}
|
|
179
|
+
// Convert glob pattern to regex for filtering
|
|
180
|
+
// * → .* and ? → .
|
|
181
|
+
const regexPattern = pathPattern
|
|
182
|
+
.replaceAll(/[$()+.[\\\]^{|}]/g, String.raw `\$&`) // Escape regex special chars except * and ?
|
|
183
|
+
.replaceAll('*', '.*')
|
|
184
|
+
.replaceAll('?', '.');
|
|
185
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
186
|
+
const allRights = await this.loadRights();
|
|
187
|
+
const matchedRights = new Rights();
|
|
188
|
+
for (const right of allRights.allRights()) {
|
|
189
|
+
if (regex.test(right.path)) {
|
|
190
|
+
matchedRights.add(right);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return matchedRights;
|
|
194
|
+
}
|
|
195
|
+
async deleteRight(id) {
|
|
196
|
+
if (!this.redis) {
|
|
197
|
+
throw new Error('Not connected');
|
|
198
|
+
}
|
|
199
|
+
const rightKey = this.key('rights', id.toString());
|
|
200
|
+
const data = await this.redis.hgetall(rightKey);
|
|
201
|
+
if (!data || Object.keys(data).length === 0) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
// Reconstruct the unique hash to delete the unique constraint key
|
|
205
|
+
const row = this.hashToRightsRow(data);
|
|
206
|
+
const right = this.rowToRight(row);
|
|
207
|
+
const uniqueHash = this.rightUniqueHash(right);
|
|
208
|
+
const uniqueKey = this.key('rights', '_unique', uniqueHash);
|
|
209
|
+
const pipeline = this.redis.multi();
|
|
210
|
+
pipeline.del(rightKey);
|
|
211
|
+
pipeline.srem(this.key('rights', '_all'), id.toString());
|
|
212
|
+
pipeline.del(uniqueKey);
|
|
213
|
+
await pipeline.exec();
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
// ===========================================================================
|
|
217
|
+
// Role Operations
|
|
218
|
+
// ===========================================================================
|
|
219
|
+
async saveRole(role) {
|
|
220
|
+
if (!this.redis) {
|
|
221
|
+
throw new Error('Not connected');
|
|
222
|
+
}
|
|
223
|
+
return this.transaction(async () => {
|
|
224
|
+
const roleKey = this.key('roles', role.name);
|
|
225
|
+
const existingData = await this.redis.hgetall(roleKey);
|
|
226
|
+
const now = new Date().toISOString();
|
|
227
|
+
const createdAt = existingData && existingData.created_at ? existingData.created_at : now;
|
|
228
|
+
// Save/update role hash
|
|
229
|
+
await this.redis.hset(roleKey, {
|
|
230
|
+
created_at: createdAt,
|
|
231
|
+
name: role.name,
|
|
232
|
+
updated_at: now
|
|
233
|
+
});
|
|
234
|
+
// Add to roles set
|
|
235
|
+
await this.redis.sadd(this.key('roles', '_all'), role.name);
|
|
236
|
+
// Clear and rebuild role rights
|
|
237
|
+
const roleRightsKey = this.key('roles', role.name, 'rights');
|
|
238
|
+
await this.redis.del(roleRightsKey);
|
|
239
|
+
for (const right of role.rights.allRights()) {
|
|
240
|
+
const rightId = await this.saveRight(right);
|
|
241
|
+
await this.redis.sadd(roleRightsKey, rightId.toString());
|
|
242
|
+
}
|
|
243
|
+
// Clear and rebuild parent roles
|
|
244
|
+
const roleParentsKey = this.key('roles', role.name, 'parents');
|
|
245
|
+
await this.redis.del(roleParentsKey);
|
|
246
|
+
for (const parent of role.parents) {
|
|
247
|
+
await this.saveRole(parent);
|
|
248
|
+
await this.redis.sadd(roleParentsKey, parent.name);
|
|
249
|
+
}
|
|
250
|
+
// Return a synthetic ID (use hash of role name since Redis doesn't have auto-increment for this)
|
|
251
|
+
// For consistency, we'll return a positive integer based on the role's position or hash
|
|
252
|
+
const allRoles = await this.redis.smembers(this.key('roles', '_all'));
|
|
253
|
+
return allRoles.indexOf(role.name) + 1;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async loadRole(name) {
|
|
257
|
+
if (!this.redis) {
|
|
258
|
+
throw new Error('Not connected');
|
|
259
|
+
}
|
|
260
|
+
const roleKey = this.key('roles', name);
|
|
261
|
+
const data = await this.redis.hgetall(roleKey);
|
|
262
|
+
if (!data || Object.keys(data).length === 0) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
// Load role's rights
|
|
266
|
+
const rights = new Rights();
|
|
267
|
+
const roleRightsKey = this.key('roles', name, 'rights');
|
|
268
|
+
const rightIds = await this.redis.smembers(roleRightsKey);
|
|
269
|
+
for (const rightIdStr of rightIds) {
|
|
270
|
+
const right = await this.loadRight(Number.parseInt(rightIdStr, 10));
|
|
271
|
+
if (right) {
|
|
272
|
+
rights.add(right);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return new Role(name, rights);
|
|
276
|
+
}
|
|
277
|
+
async loadRoles() {
|
|
278
|
+
if (!this.redis) {
|
|
279
|
+
throw new Error('Not connected');
|
|
280
|
+
}
|
|
281
|
+
const allRoleNames = await this.redis.smembers(this.key('roles', '_all'));
|
|
282
|
+
const loadedRoles = [];
|
|
283
|
+
for (const name of allRoleNames) {
|
|
284
|
+
const role = await this.loadRole(name);
|
|
285
|
+
if (role) {
|
|
286
|
+
loadedRoles.push(role);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return loadedRoles;
|
|
290
|
+
}
|
|
291
|
+
async deleteRole(name) {
|
|
292
|
+
if (!this.redis) {
|
|
293
|
+
throw new Error('Not connected');
|
|
294
|
+
}
|
|
295
|
+
const roleKey = this.key('roles', name);
|
|
296
|
+
const exists = await this.redis.exists(roleKey);
|
|
297
|
+
if (!exists) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
const pipeline = this.redis.multi();
|
|
301
|
+
pipeline.del(roleKey);
|
|
302
|
+
pipeline.del(this.key('roles', name, 'rights'));
|
|
303
|
+
pipeline.del(this.key('roles', name, 'parents'));
|
|
304
|
+
pipeline.srem(this.key('roles', '_all'), name);
|
|
305
|
+
await pipeline.exec();
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
// ===========================================================================
|
|
309
|
+
// RoleRegistry Operations
|
|
310
|
+
// ===========================================================================
|
|
311
|
+
async saveRegistry(registry) {
|
|
312
|
+
if (!this.redis) {
|
|
313
|
+
throw new Error('Not connected');
|
|
314
|
+
}
|
|
315
|
+
await this.transaction(async () => {
|
|
316
|
+
const rolesToSave = new Map();
|
|
317
|
+
const collectRoles = (role) => {
|
|
318
|
+
if (!rolesToSave.has(role.name)) {
|
|
319
|
+
rolesToSave.set(role.name, role);
|
|
320
|
+
for (const parent of role.parents) {
|
|
321
|
+
collectRoles(parent);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
registry.toJSON().forEach(roleJson => {
|
|
326
|
+
const role = registry.get(roleJson.name);
|
|
327
|
+
if (role) {
|
|
328
|
+
collectRoles(role);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
for (const role of rolesToSave.values()) {
|
|
332
|
+
await this.saveRole(role);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async loadRegistry() {
|
|
337
|
+
const registry = new RoleRegistry();
|
|
338
|
+
const roles = await this.loadRoles();
|
|
339
|
+
// First pass: define all roles with their rights
|
|
340
|
+
for (const role of roles) {
|
|
341
|
+
registry.define(role.name, role.rights);
|
|
342
|
+
}
|
|
343
|
+
// Second pass: set up inheritance
|
|
344
|
+
for (const role of roles) {
|
|
345
|
+
const roleParentsKey = this.key('roles', role.name, 'parents');
|
|
346
|
+
const parentNames = await this.redis.smembers(roleParentsKey);
|
|
347
|
+
const registryRole = registry.get(role.name);
|
|
348
|
+
if (registryRole) {
|
|
349
|
+
for (const parentName of parentNames) {
|
|
350
|
+
const parentRole = registry.get(parentName);
|
|
351
|
+
if (parentRole) {
|
|
352
|
+
registryRole.inheritsFrom(parentRole);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return registry;
|
|
358
|
+
}
|
|
359
|
+
// ===========================================================================
|
|
360
|
+
// Subject Operations
|
|
361
|
+
// ===========================================================================
|
|
362
|
+
async saveSubject(identifier, subject) {
|
|
363
|
+
if (!this.redis) {
|
|
364
|
+
throw new Error('Not connected');
|
|
365
|
+
}
|
|
366
|
+
return this.transaction(async () => {
|
|
367
|
+
const subjectKey = this.key('subjects', identifier);
|
|
368
|
+
const existingData = await this.redis.hgetall(subjectKey);
|
|
369
|
+
const now = new Date().toISOString();
|
|
370
|
+
let id;
|
|
371
|
+
if (existingData && existingData.id) {
|
|
372
|
+
id = Number.parseInt(existingData.id, 10);
|
|
373
|
+
// Update existing subject
|
|
374
|
+
await this.redis.hset(subjectKey, {
|
|
375
|
+
identifier,
|
|
376
|
+
updated_at: now
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
// Create new subject with auto-increment ID
|
|
381
|
+
id = await this.redis.incr(this.key('subjects', '_seq'));
|
|
382
|
+
await this.redis.hset(subjectKey, {
|
|
383
|
+
created_at: now,
|
|
384
|
+
id: id.toString(),
|
|
385
|
+
identifier,
|
|
386
|
+
updated_at: now
|
|
387
|
+
});
|
|
388
|
+
await this.redis.sadd(this.key('subjects', '_all'), identifier);
|
|
389
|
+
}
|
|
390
|
+
// Clear and rebuild subject roles
|
|
391
|
+
const subjectRolesKey = this.key('subjects', identifier, 'roles');
|
|
392
|
+
await this.redis.del(subjectRolesKey);
|
|
393
|
+
for (const role of subject.roles) {
|
|
394
|
+
await this.saveRole(role);
|
|
395
|
+
await this.redis.sadd(subjectRolesKey, role.name);
|
|
396
|
+
}
|
|
397
|
+
// Clear and rebuild subject direct rights
|
|
398
|
+
const subjectRightsKey = this.key('subjects', identifier, 'rights');
|
|
399
|
+
await this.redis.del(subjectRightsKey);
|
|
400
|
+
for (const right of subject.rights.allRights()) {
|
|
401
|
+
const rightId = await this.saveRight(right);
|
|
402
|
+
await this.redis.sadd(subjectRightsKey, rightId.toString());
|
|
403
|
+
}
|
|
404
|
+
return id;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
async loadSubject(identifier) {
|
|
408
|
+
if (!this.redis) {
|
|
409
|
+
throw new Error('Not connected');
|
|
410
|
+
}
|
|
411
|
+
const subjectKey = this.key('subjects', identifier);
|
|
412
|
+
const data = await this.redis.hgetall(subjectKey);
|
|
413
|
+
if (!data || Object.keys(data).length === 0) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
const subject = new Subject();
|
|
417
|
+
// Load subject's roles
|
|
418
|
+
const subjectRolesKey = this.key('subjects', identifier, 'roles');
|
|
419
|
+
const roleNames = await this.redis.smembers(subjectRolesKey);
|
|
420
|
+
for (const roleName of roleNames) {
|
|
421
|
+
const role = await this.loadRole(roleName);
|
|
422
|
+
if (role) {
|
|
423
|
+
subject.memberOf(role);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Load subject's direct rights
|
|
427
|
+
const subjectRightsKey = this.key('subjects', identifier, 'rights');
|
|
428
|
+
const rightIds = await this.redis.smembers(subjectRightsKey);
|
|
429
|
+
for (const rightIdStr of rightIds) {
|
|
430
|
+
const right = await this.loadRight(Number.parseInt(rightIdStr, 10));
|
|
431
|
+
if (right) {
|
|
432
|
+
subject.rights.add(right);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return subject;
|
|
436
|
+
}
|
|
437
|
+
async deleteSubject(identifier) {
|
|
438
|
+
if (!this.redis) {
|
|
439
|
+
throw new Error('Not connected');
|
|
440
|
+
}
|
|
441
|
+
const subjectKey = this.key('subjects', identifier);
|
|
442
|
+
const exists = await this.redis.exists(subjectKey);
|
|
443
|
+
if (!exists) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
const pipeline = this.redis.multi();
|
|
447
|
+
pipeline.del(subjectKey);
|
|
448
|
+
pipeline.del(this.key('subjects', identifier, 'roles'));
|
|
449
|
+
pipeline.del(this.key('subjects', identifier, 'rights'));
|
|
450
|
+
pipeline.srem(this.key('subjects', '_all'), identifier);
|
|
451
|
+
await pipeline.exec();
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
async getAllSubjectIdentifiers() {
|
|
455
|
+
if (!this.redis) {
|
|
456
|
+
throw new Error('Not connected');
|
|
457
|
+
}
|
|
458
|
+
const allSubjectsKey = this.key('subjects', '_all');
|
|
459
|
+
return this.redis.smembers(allSubjectsKey);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Optimized findSubjectsWithAccess using batch loading with Redis pipeline.
|
|
463
|
+
* Reduces N+1 queries to a constant number of Redis operations regardless of subject count.
|
|
464
|
+
*/
|
|
465
|
+
async findSubjectsWithAccess(pathPattern, flags) {
|
|
466
|
+
if (!this.redis) {
|
|
467
|
+
throw new Error('Not connected');
|
|
468
|
+
}
|
|
469
|
+
// Get all subject identifiers
|
|
470
|
+
const allSubjectsKey = this.key('subjects', '_all');
|
|
471
|
+
const subjectIdentifiers = await this.redis.smembers(allSubjectsKey);
|
|
472
|
+
if (subjectIdentifiers.length === 0) {
|
|
473
|
+
return [];
|
|
474
|
+
}
|
|
475
|
+
// Batch load all subject roles using pipeline
|
|
476
|
+
const rolesPipeline = this.redis.pipeline();
|
|
477
|
+
for (const identifier of subjectIdentifiers) {
|
|
478
|
+
rolesPipeline.smembers(this.key('subjects', identifier, 'roles'));
|
|
479
|
+
}
|
|
480
|
+
const rolesResults = await rolesPipeline.exec();
|
|
481
|
+
// Build subject -> roles mapping
|
|
482
|
+
const subjectRolesMap = new Map();
|
|
483
|
+
for (let i = 0; i < subjectIdentifiers.length; i++) {
|
|
484
|
+
const identifier = subjectIdentifiers[i];
|
|
485
|
+
const result = rolesResults?.[i];
|
|
486
|
+
if (result && !result[0]) {
|
|
487
|
+
subjectRolesMap.set(identifier, result[1]);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
subjectRolesMap.set(identifier, []);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// Collect all unique role names
|
|
494
|
+
const allRoleNames = new Set();
|
|
495
|
+
for (const roles of subjectRolesMap.values()) {
|
|
496
|
+
for (const roleName of roles) {
|
|
497
|
+
allRoleNames.add(roleName);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// Batch load all role rights using pipeline
|
|
501
|
+
const roleRightIdsPipeline = this.redis.pipeline();
|
|
502
|
+
const roleNamesArray = Array.from(allRoleNames);
|
|
503
|
+
for (const roleName of roleNamesArray) {
|
|
504
|
+
roleRightIdsPipeline.smembers(this.key('roles', roleName, 'rights'));
|
|
505
|
+
}
|
|
506
|
+
const roleRightIdsResults = await roleRightIdsPipeline.exec();
|
|
507
|
+
// Build role -> right IDs mapping
|
|
508
|
+
const roleRightIdsMap = new Map();
|
|
509
|
+
for (let i = 0; i < roleNamesArray.length; i++) {
|
|
510
|
+
const roleName = roleNamesArray[i];
|
|
511
|
+
const result = roleRightIdsResults?.[i];
|
|
512
|
+
if (result && !result[0]) {
|
|
513
|
+
roleRightIdsMap.set(roleName, result[1]);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
roleRightIdsMap.set(roleName, []);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// Batch load all subject direct rights using pipeline
|
|
520
|
+
const directRightsPipeline = this.redis.pipeline();
|
|
521
|
+
for (const identifier of subjectIdentifiers) {
|
|
522
|
+
directRightsPipeline.smembers(this.key('subjects', identifier, 'rights'));
|
|
523
|
+
}
|
|
524
|
+
const directRightsResults = await directRightsPipeline.exec();
|
|
525
|
+
// Build subject -> direct right IDs mapping
|
|
526
|
+
const subjectDirectRightIdsMap = new Map();
|
|
527
|
+
for (let i = 0; i < subjectIdentifiers.length; i++) {
|
|
528
|
+
const identifier = subjectIdentifiers[i];
|
|
529
|
+
const result = directRightsResults?.[i];
|
|
530
|
+
if (result && !result[0]) {
|
|
531
|
+
subjectDirectRightIdsMap.set(identifier, result[1]);
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
subjectDirectRightIdsMap.set(identifier, []);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Collect all unique right IDs
|
|
538
|
+
const allRightIds = new Set();
|
|
539
|
+
for (const rightIds of roleRightIdsMap.values()) {
|
|
540
|
+
for (const id of rightIds) {
|
|
541
|
+
allRightIds.add(id);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
for (const rightIds of subjectDirectRightIdsMap.values()) {
|
|
545
|
+
for (const id of rightIds) {
|
|
546
|
+
allRightIds.add(id);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// Batch load all rights using pipeline
|
|
550
|
+
const rightsPipeline = this.redis.pipeline();
|
|
551
|
+
const rightIdsArray = Array.from(allRightIds);
|
|
552
|
+
for (const rightId of rightIdsArray) {
|
|
553
|
+
rightsPipeline.hgetall(this.key('rights', rightId));
|
|
554
|
+
}
|
|
555
|
+
const rightsResults = await rightsPipeline.exec();
|
|
556
|
+
// Build right ID -> Right mapping
|
|
557
|
+
const rightsMap = new Map();
|
|
558
|
+
for (let i = 0; i < rightIdsArray.length; i++) {
|
|
559
|
+
const rightId = rightIdsArray[i];
|
|
560
|
+
const result = rightsResults?.[i];
|
|
561
|
+
if (result && !result[0] && result[1]) {
|
|
562
|
+
const data = result[1];
|
|
563
|
+
if (Object.keys(data).length > 0) {
|
|
564
|
+
const row = this.hashToRightsRow(data);
|
|
565
|
+
rightsMap.set(rightId, this.rowToRight(row));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// Build role -> Rights mapping
|
|
570
|
+
const roleRightsMap = new Map();
|
|
571
|
+
for (const [roleName, rightIds] of roleRightIdsMap) {
|
|
572
|
+
const rights = new Rights();
|
|
573
|
+
for (const rightId of rightIds) {
|
|
574
|
+
const right = rightsMap.get(rightId);
|
|
575
|
+
if (right) {
|
|
576
|
+
rights.add(right);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
roleRightsMap.set(roleName, rights);
|
|
580
|
+
}
|
|
581
|
+
// Now construct Subject objects and check access
|
|
582
|
+
const matchingSubjects = [];
|
|
583
|
+
for (const identifier of subjectIdentifiers) {
|
|
584
|
+
const subject = new Subject();
|
|
585
|
+
// Add roles with their rights
|
|
586
|
+
const roleNames = subjectRolesMap.get(identifier) ?? [];
|
|
587
|
+
for (const roleName of roleNames) {
|
|
588
|
+
const roleRights = roleRightsMap.get(roleName) ?? new Rights();
|
|
589
|
+
const role = new Role(roleName, roleRights);
|
|
590
|
+
subject.memberOf(role);
|
|
591
|
+
}
|
|
592
|
+
// Add direct rights
|
|
593
|
+
const directRightIds = subjectDirectRightIdsMap.get(identifier) ?? [];
|
|
594
|
+
for (const rightId of directRightIds) {
|
|
595
|
+
const right = rightsMap.get(rightId);
|
|
596
|
+
if (right) {
|
|
597
|
+
subject.rights.add(right);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Check if subject has the requested access
|
|
601
|
+
if (subject.has(pathPattern, flags)) {
|
|
602
|
+
matchingSubjects.push(identifier);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return matchingSubjects;
|
|
606
|
+
}
|
|
607
|
+
// ===========================================================================
|
|
608
|
+
// Utility
|
|
609
|
+
// ===========================================================================
|
|
610
|
+
async clear() {
|
|
611
|
+
if (!this.redis) {
|
|
612
|
+
throw new Error('Not connected');
|
|
613
|
+
}
|
|
614
|
+
const prefix = this.options.keyPrefix ?? this.tablePrefix;
|
|
615
|
+
const pattern = `${prefix}*`;
|
|
616
|
+
// Use SCAN to find all keys matching our prefix (safer than KEYS for production)
|
|
617
|
+
let cursor = '0';
|
|
618
|
+
const keysToDelete = [];
|
|
619
|
+
do {
|
|
620
|
+
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
621
|
+
cursor = nextCursor;
|
|
622
|
+
keysToDelete.push(...keys);
|
|
623
|
+
} while (cursor !== '0');
|
|
624
|
+
// Delete keys in batches
|
|
625
|
+
if (keysToDelete.length > 0) {
|
|
626
|
+
const batchSize = 100;
|
|
627
|
+
for (let i = 0; i < keysToDelete.length; i += batchSize) {
|
|
628
|
+
const batch = keysToDelete.slice(i, i + batchSize);
|
|
629
|
+
await this.redis.del(...batch);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async transaction(fn) {
|
|
634
|
+
if (!this.redis) {
|
|
635
|
+
throw new Error('Not connected');
|
|
636
|
+
}
|
|
637
|
+
// Note: Redis MULTI/EXEC doesn't support true rollback like SQL databases.
|
|
638
|
+
// If an error occurs, we can't undo operations that were already executed.
|
|
639
|
+
// For nested transactions, we just track depth and only wrap at the outermost level.
|
|
640
|
+
const isNested = this.transactionDepth > 0;
|
|
641
|
+
this.transactionDepth++;
|
|
642
|
+
try {
|
|
643
|
+
const result = await fn(this);
|
|
644
|
+
this.transactionDepth--;
|
|
645
|
+
return result;
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
this.transactionDepth--;
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// ===========================================================================
|
|
653
|
+
// Private Helpers
|
|
654
|
+
// ===========================================================================
|
|
655
|
+
/**
|
|
656
|
+
* Convert Redis hash data to a RightsRow
|
|
657
|
+
*/
|
|
658
|
+
hashToRightsRow(data) {
|
|
659
|
+
return {
|
|
660
|
+
allow_mask: Number.parseInt(data.allow_mask || '0', 10),
|
|
661
|
+
created_at: data.created_at || new Date().toISOString(),
|
|
662
|
+
deny_mask: Number.parseInt(data.deny_mask || '0', 10),
|
|
663
|
+
description: data.description || null,
|
|
664
|
+
id: Number.parseInt(data.id || '0', 10),
|
|
665
|
+
path: data.path || '',
|
|
666
|
+
priority: Number.parseInt(data.priority || '0', 10),
|
|
667
|
+
tags: data.tags || null,
|
|
668
|
+
updated_at: data.updated_at || new Date().toISOString(),
|
|
669
|
+
valid_from: data.valid_from || null,
|
|
670
|
+
valid_until: data.valid_until || null
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TableNames } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Default table prefix for all adapter tables
|
|
4
|
+
*/
|
|
5
|
+
export declare const DEFAULT_TABLE_PREFIX = "tbl_";
|
|
6
|
+
/**
|
|
7
|
+
* Generate table names with the given prefix
|
|
8
|
+
*/
|
|
9
|
+
export declare const createTableNames: (prefix?: string) => TableNames;
|
|
10
|
+
/**
|
|
11
|
+
* Generate SQLite schema with the given table names
|
|
12
|
+
*/
|
|
13
|
+
export declare const generateSQLiteSchema: (tables: TableNames) => string;
|
|
14
|
+
/**
|
|
15
|
+
* Generate PostgreSQL schema with the given table names
|
|
16
|
+
*/
|
|
17
|
+
export declare const generatePostgresSchema: (tables: TableNames) => string;
|
|
18
|
+
/**
|
|
19
|
+
* Generate SQLite statements to drop all tables (useful for testing)
|
|
20
|
+
*/
|
|
21
|
+
export declare const generateSQLiteDropSchema: (tables: TableNames) => string;
|
|
22
|
+
/**
|
|
23
|
+
* Generate PostgreSQL statements to drop all tables (useful for testing)
|
|
24
|
+
*/
|
|
25
|
+
export declare const generatePostgresDropSchema: (tables: TableNames) => string;
|