odgn-rights 0.2.0 → 0.5.1
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 +144 -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 +471 -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 +25 -0
- package/dist/rights.js +63 -2
- package/dist/role-registry.d.ts +9 -0
- package/dist/role-registry.js +12 -0
- package/dist/role.d.ts +5 -0
- package/dist/role.js +17 -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,11 @@
|
|
|
1
|
+
export type { BaseAdapterOptions, DatabaseAdapter, RightsRow, RoleInheritanceRow, RoleRightRow, RoleRow, SubjectRightRow, SubjectRoleRow, SubjectRow, TableNames } from './types';
|
|
2
|
+
export { createTableNames, DEFAULT_TABLE_PREFIX, generatePostgresDropSchema, generatePostgresSchema, generateSQLiteDropSchema, generateSQLiteSchema } from './schema';
|
|
3
|
+
export { BaseAdapter } from './base-adapter';
|
|
4
|
+
export { SQLiteAdapter } from './sqlite-adapter';
|
|
5
|
+
export type { SQLiteAdapterOptions } from './sqlite-adapter';
|
|
6
|
+
export { PostgresAdapter } from './postgres-adapter';
|
|
7
|
+
export type { PostgresAdapterOptions } from './postgres-adapter';
|
|
8
|
+
export { RedisAdapter } from './redis-adapter';
|
|
9
|
+
export type { RedisAdapterOptions } from './redis-adapter';
|
|
10
|
+
export { createPostgresRegistry, createPostgresRights, createRedisRegistry, createRedisRights, createSQLiteRegistry, createSQLiteRights } from './factories';
|
|
11
|
+
export type { CreatePostgresRegistryOptions, CreateRedisRightsOptions, CreateSQLiteRightsOptions } from './factories';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Schema utilities
|
|
2
|
+
export { createTableNames, DEFAULT_TABLE_PREFIX, generatePostgresDropSchema, generatePostgresSchema, generateSQLiteDropSchema, generateSQLiteSchema } from './schema';
|
|
3
|
+
// Base adapter
|
|
4
|
+
export { BaseAdapter } from './base-adapter';
|
|
5
|
+
// SQLite adapter
|
|
6
|
+
export { SQLiteAdapter } from './sqlite-adapter';
|
|
7
|
+
// PostgreSQL adapter
|
|
8
|
+
export { PostgresAdapter } from './postgres-adapter';
|
|
9
|
+
// Redis adapter
|
|
10
|
+
export { RedisAdapter } from './redis-adapter';
|
|
11
|
+
// Factory functions
|
|
12
|
+
export { createPostgresRegistry, createPostgresRights, createRedisRegistry, createRedisRights, createSQLiteRegistry, createSQLiteRights } from './factories';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Flags } from '../constants';
|
|
2
|
+
import { Right } from '../right';
|
|
3
|
+
import { Rights } from '../rights';
|
|
4
|
+
import { Role } from '../role';
|
|
5
|
+
import { RoleRegistry } from '../role-registry';
|
|
6
|
+
import { Subject } from '../subject';
|
|
7
|
+
import { BaseAdapter } from './base-adapter';
|
|
8
|
+
import type { BaseAdapterOptions, DatabaseAdapter } from './types';
|
|
9
|
+
export type PostgresAdapterOptions = BaseAdapterOptions & {
|
|
10
|
+
database?: string;
|
|
11
|
+
hostname?: string;
|
|
12
|
+
idleTimeout?: number;
|
|
13
|
+
max?: number;
|
|
14
|
+
password?: string;
|
|
15
|
+
port?: number;
|
|
16
|
+
ssl?: boolean | object;
|
|
17
|
+
url?: string;
|
|
18
|
+
username?: string;
|
|
19
|
+
};
|
|
20
|
+
export declare class PostgresAdapter extends BaseAdapter {
|
|
21
|
+
private sql;
|
|
22
|
+
private readonly options;
|
|
23
|
+
private transactionDepth;
|
|
24
|
+
constructor(options?: PostgresAdapterOptions);
|
|
25
|
+
connect(): Promise<void>;
|
|
26
|
+
disconnect(): Promise<void>;
|
|
27
|
+
migrate(): Promise<void>;
|
|
28
|
+
saveRight(right: Right): Promise<number>;
|
|
29
|
+
saveRights(rights: Rights): Promise<number[]>;
|
|
30
|
+
loadRight(id: number): Promise<Right | null>;
|
|
31
|
+
loadRights(): Promise<Rights>;
|
|
32
|
+
loadRightsByPath(pathPattern: string): Promise<Rights>;
|
|
33
|
+
deleteRight(id: number): Promise<boolean>;
|
|
34
|
+
saveRole(role: Role): Promise<number>;
|
|
35
|
+
loadRole(name: string): Promise<Role | null>;
|
|
36
|
+
loadRoles(): Promise<Role[]>;
|
|
37
|
+
deleteRole(name: string): Promise<boolean>;
|
|
38
|
+
saveRegistry(registry: RoleRegistry): Promise<void>;
|
|
39
|
+
loadRegistry(): Promise<RoleRegistry>;
|
|
40
|
+
saveSubject(identifier: string, subject: Subject): Promise<number>;
|
|
41
|
+
loadSubject(identifier: string): Promise<Subject | null>;
|
|
42
|
+
deleteSubject(identifier: string): Promise<boolean>;
|
|
43
|
+
protected getAllSubjectIdentifiers(): Promise<string[]>;
|
|
44
|
+
/**
|
|
45
|
+
* Optimized findSubjectsWithAccess using batch loading with JOINs.
|
|
46
|
+
* Reduces N+1 queries to a constant number of queries regardless of subject count.
|
|
47
|
+
*/
|
|
48
|
+
findSubjectsWithAccess(pathPattern: string, flags: Flags): Promise<string[]>;
|
|
49
|
+
clear(): Promise<void>;
|
|
50
|
+
transaction<T>(fn: (adapter: DatabaseAdapter) => Promise<T>): Promise<T>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { SQL } from 'bun';
|
|
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
|
+
import { generatePostgresSchema } from './schema';
|
|
10
|
+
export class PostgresAdapter extends BaseAdapter {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
super(options);
|
|
13
|
+
this.sql = null;
|
|
14
|
+
this.transactionDepth = 0;
|
|
15
|
+
this.options = options;
|
|
16
|
+
}
|
|
17
|
+
async connect() {
|
|
18
|
+
this.sql = this.options.url
|
|
19
|
+
? new SQL({
|
|
20
|
+
idleTimeout: this.options.idleTimeout ?? 30,
|
|
21
|
+
max: this.options.max ?? 1,
|
|
22
|
+
ssl: this.options.ssl,
|
|
23
|
+
url: this.options.url
|
|
24
|
+
})
|
|
25
|
+
: new SQL({
|
|
26
|
+
database: this.options.database,
|
|
27
|
+
hostname: this.options.hostname ?? 'localhost',
|
|
28
|
+
idleTimeout: this.options.idleTimeout ?? 30,
|
|
29
|
+
max: this.options.max ?? 1,
|
|
30
|
+
password: this.options.password,
|
|
31
|
+
port: this.options.port ?? 5432,
|
|
32
|
+
ssl: this.options.ssl,
|
|
33
|
+
username: this.options.username
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async disconnect() {
|
|
37
|
+
if (this.sql) {
|
|
38
|
+
await this.sql.end();
|
|
39
|
+
this.sql = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async migrate() {
|
|
43
|
+
if (!this.sql) {
|
|
44
|
+
throw new Error('Not connected');
|
|
45
|
+
}
|
|
46
|
+
await this.sql.unsafe(generatePostgresSchema(this.tables));
|
|
47
|
+
}
|
|
48
|
+
// ===========================================================================
|
|
49
|
+
// Rights Operations
|
|
50
|
+
// ===========================================================================
|
|
51
|
+
async saveRight(right) {
|
|
52
|
+
if (!this.sql) {
|
|
53
|
+
throw new Error('Not connected');
|
|
54
|
+
}
|
|
55
|
+
const row = this.rightToRow(right);
|
|
56
|
+
const { rights: rightsTable } = this.tables;
|
|
57
|
+
const [result] = await this.sql.unsafe(`
|
|
58
|
+
INSERT INTO ${rightsTable} (path, allow_mask, deny_mask, priority, description, tags, valid_from, valid_until)
|
|
59
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
60
|
+
ON CONFLICT (path, allow_mask, deny_mask, priority, valid_from, valid_until)
|
|
61
|
+
DO UPDATE SET updated_at = NOW()
|
|
62
|
+
RETURNING id
|
|
63
|
+
`, [
|
|
64
|
+
row.path,
|
|
65
|
+
row.allow_mask,
|
|
66
|
+
row.deny_mask,
|
|
67
|
+
row.priority,
|
|
68
|
+
row.description,
|
|
69
|
+
row.tags,
|
|
70
|
+
row.valid_from,
|
|
71
|
+
row.valid_until
|
|
72
|
+
]);
|
|
73
|
+
right._setDbId(result.id);
|
|
74
|
+
return result.id;
|
|
75
|
+
}
|
|
76
|
+
async saveRights(rights) {
|
|
77
|
+
const ids = [];
|
|
78
|
+
await this.transaction(async () => {
|
|
79
|
+
for (const right of rights.allRights()) {
|
|
80
|
+
const id = await this.saveRight(right);
|
|
81
|
+
ids.push(id);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
return ids;
|
|
85
|
+
}
|
|
86
|
+
async loadRight(id) {
|
|
87
|
+
if (!this.sql) {
|
|
88
|
+
throw new Error('Not connected');
|
|
89
|
+
}
|
|
90
|
+
const { rights: rightsTable } = this.tables;
|
|
91
|
+
const [row] = await this.sql.unsafe(`SELECT * FROM ${rightsTable} WHERE id = $1`, [id]);
|
|
92
|
+
if (!row) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return this.rowToRight(row);
|
|
96
|
+
}
|
|
97
|
+
async loadRights() {
|
|
98
|
+
if (!this.sql) {
|
|
99
|
+
throw new Error('Not connected');
|
|
100
|
+
}
|
|
101
|
+
const { rights: rightsTable } = this.tables;
|
|
102
|
+
const rows = await this.sql.unsafe(`SELECT * FROM ${rightsTable} ORDER BY id`);
|
|
103
|
+
const loadedRights = new Rights();
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
loadedRights.add(this.rowToRight(row));
|
|
106
|
+
}
|
|
107
|
+
return loadedRights;
|
|
108
|
+
}
|
|
109
|
+
async loadRightsByPath(pathPattern) {
|
|
110
|
+
if (!this.sql) {
|
|
111
|
+
throw new Error('Not connected');
|
|
112
|
+
}
|
|
113
|
+
const { rights: rightsTable } = this.tables;
|
|
114
|
+
const pattern = pathPattern.replaceAll('*', '%').replaceAll('?', '_');
|
|
115
|
+
const rows = await this.sql.unsafe(`SELECT * FROM ${rightsTable} WHERE path LIKE $1 ORDER BY id`, [pattern]);
|
|
116
|
+
const loadedRights = new Rights();
|
|
117
|
+
for (const row of rows) {
|
|
118
|
+
loadedRights.add(this.rowToRight(row));
|
|
119
|
+
}
|
|
120
|
+
return loadedRights;
|
|
121
|
+
}
|
|
122
|
+
async deleteRight(id) {
|
|
123
|
+
if (!this.sql) {
|
|
124
|
+
throw new Error('Not connected');
|
|
125
|
+
}
|
|
126
|
+
const { rights: rightsTable } = this.tables;
|
|
127
|
+
const result = await this.sql.unsafe(`DELETE FROM ${rightsTable} WHERE id = $1`, [id]);
|
|
128
|
+
return result.count > 0;
|
|
129
|
+
}
|
|
130
|
+
// ===========================================================================
|
|
131
|
+
// Role Operations
|
|
132
|
+
// ===========================================================================
|
|
133
|
+
async saveRole(role) {
|
|
134
|
+
if (!this.sql) {
|
|
135
|
+
throw new Error('Not connected');
|
|
136
|
+
}
|
|
137
|
+
const { roleInheritance, roleRights, roles } = this.tables;
|
|
138
|
+
return this.transaction(async () => {
|
|
139
|
+
const [roleResult] = await this.sql.unsafe(`
|
|
140
|
+
INSERT INTO ${roles} (name)
|
|
141
|
+
VALUES ($1)
|
|
142
|
+
ON CONFLICT (name) DO UPDATE SET updated_at = NOW()
|
|
143
|
+
RETURNING id
|
|
144
|
+
`, [role.name]);
|
|
145
|
+
const roleId = roleResult.id;
|
|
146
|
+
await this.sql.unsafe(`DELETE FROM ${roleRights} WHERE role_id = $1`, [
|
|
147
|
+
roleId
|
|
148
|
+
]);
|
|
149
|
+
await this.sql.unsafe(`DELETE FROM ${roleInheritance} WHERE child_role_id = $1`, [roleId]);
|
|
150
|
+
for (const right of role.rights.allRights()) {
|
|
151
|
+
const rightId = await this.saveRight(right);
|
|
152
|
+
await this.sql.unsafe(`INSERT INTO ${roleRights} (role_id, right_id) VALUES ($1, $2)`, [roleId, rightId]);
|
|
153
|
+
}
|
|
154
|
+
for (const parent of role.parents) {
|
|
155
|
+
const parentId = await this.saveRole(parent);
|
|
156
|
+
await this.sql.unsafe(`INSERT INTO ${roleInheritance} (child_role_id, parent_role_id) VALUES ($1, $2)`, [roleId, parentId]);
|
|
157
|
+
}
|
|
158
|
+
return roleId;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async loadRole(name) {
|
|
162
|
+
if (!this.sql) {
|
|
163
|
+
throw new Error('Not connected');
|
|
164
|
+
}
|
|
165
|
+
const { roleRights, roles } = this.tables;
|
|
166
|
+
const [roleRow] = await this.sql.unsafe(`SELECT * FROM ${roles} WHERE name = $1`, [name]);
|
|
167
|
+
if (!roleRow) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const rights = new Rights();
|
|
171
|
+
const roleRightRows = await this.sql.unsafe(`SELECT right_id FROM ${roleRights} WHERE role_id = $1`, [roleRow.id]);
|
|
172
|
+
for (const rr of roleRightRows) {
|
|
173
|
+
const right = await this.loadRight(rr.right_id);
|
|
174
|
+
if (right) {
|
|
175
|
+
rights.add(right);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return new Role(name, rights);
|
|
179
|
+
}
|
|
180
|
+
async loadRoles() {
|
|
181
|
+
if (!this.sql) {
|
|
182
|
+
throw new Error('Not connected');
|
|
183
|
+
}
|
|
184
|
+
const { roles: rolesTable } = this.tables;
|
|
185
|
+
const roleRows = await this.sql.unsafe(`SELECT * FROM ${rolesTable} ORDER BY id`);
|
|
186
|
+
const loadedRoles = [];
|
|
187
|
+
for (const row of roleRows) {
|
|
188
|
+
const role = await this.loadRole(row.name);
|
|
189
|
+
if (role) {
|
|
190
|
+
loadedRoles.push(role);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return loadedRoles;
|
|
194
|
+
}
|
|
195
|
+
async deleteRole(name) {
|
|
196
|
+
if (!this.sql) {
|
|
197
|
+
throw new Error('Not connected');
|
|
198
|
+
}
|
|
199
|
+
const { roles } = this.tables;
|
|
200
|
+
const result = await this.sql.unsafe(`DELETE FROM ${roles} WHERE name = $1`, [name]);
|
|
201
|
+
return result.count > 0;
|
|
202
|
+
}
|
|
203
|
+
// ===========================================================================
|
|
204
|
+
// RoleRegistry Operations
|
|
205
|
+
// ===========================================================================
|
|
206
|
+
async saveRegistry(registry) {
|
|
207
|
+
if (!this.sql) {
|
|
208
|
+
throw new Error('Not connected');
|
|
209
|
+
}
|
|
210
|
+
await this.transaction(async () => {
|
|
211
|
+
const rolesToSave = new Map();
|
|
212
|
+
const collectRoles = (role) => {
|
|
213
|
+
if (!rolesToSave.has(role.name)) {
|
|
214
|
+
rolesToSave.set(role.name, role);
|
|
215
|
+
for (const parent of role.parents) {
|
|
216
|
+
collectRoles(parent);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
registry.toJSON().forEach(roleJson => {
|
|
221
|
+
const role = registry.get(roleJson.name);
|
|
222
|
+
if (role) {
|
|
223
|
+
collectRoles(role);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
for (const role of rolesToSave.values()) {
|
|
227
|
+
await this.saveRole(role);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async loadRegistry() {
|
|
232
|
+
const registry = new RoleRegistry();
|
|
233
|
+
const roles = await this.loadRoles();
|
|
234
|
+
const roleMap = new Map();
|
|
235
|
+
for (const role of roles) {
|
|
236
|
+
// Define the role in the registry and get the registered role back
|
|
237
|
+
const registeredRole = registry.define(role.name, role.rights);
|
|
238
|
+
const { roles: rolesTable } = this.tables;
|
|
239
|
+
const [roleRow] = await this.sql.unsafe(`SELECT id FROM ${rolesTable} WHERE name = $1`, [role.name]);
|
|
240
|
+
if (roleRow) {
|
|
241
|
+
// Store the registered role, not the original loaded role
|
|
242
|
+
roleMap.set(role.name, { id: roleRow.id, role: registeredRole });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const { roleInheritance } = this.tables;
|
|
246
|
+
const inheritRows = await this.sql.unsafe(`SELECT child_role_id, parent_role_id FROM ${roleInheritance}`);
|
|
247
|
+
for (const ir of inheritRows) {
|
|
248
|
+
const inheritanceRow = ir;
|
|
249
|
+
let childRole;
|
|
250
|
+
let parentRole;
|
|
251
|
+
for (const [, data] of roleMap.entries()) {
|
|
252
|
+
if (data.id === inheritanceRow.child_role_id) {
|
|
253
|
+
childRole = data.role;
|
|
254
|
+
}
|
|
255
|
+
if (data.id === inheritanceRow.parent_role_id) {
|
|
256
|
+
parentRole = data.role;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (childRole && parentRole) {
|
|
260
|
+
childRole.inheritsFrom(parentRole);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return registry;
|
|
264
|
+
}
|
|
265
|
+
// ===========================================================================
|
|
266
|
+
// Subject Operations
|
|
267
|
+
// ===========================================================================
|
|
268
|
+
async saveSubject(identifier, subject) {
|
|
269
|
+
if (!this.sql) {
|
|
270
|
+
throw new Error('Not connected');
|
|
271
|
+
}
|
|
272
|
+
const { subjectRights, subjectRoles, subjects } = this.tables;
|
|
273
|
+
return this.transaction(async () => {
|
|
274
|
+
const [subjectResult] = await this.sql.unsafe(`
|
|
275
|
+
INSERT INTO ${subjects} (identifier)
|
|
276
|
+
VALUES ($1)
|
|
277
|
+
ON CONFLICT (identifier) DO UPDATE SET updated_at = NOW()
|
|
278
|
+
RETURNING id
|
|
279
|
+
`, [identifier]);
|
|
280
|
+
const subjectId = subjectResult.id;
|
|
281
|
+
await this.sql.unsafe(`DELETE FROM ${subjectRoles} WHERE subject_id = $1`, [subjectId]);
|
|
282
|
+
await this.sql.unsafe(`DELETE FROM ${subjectRights} WHERE subject_id = $1`, [subjectId]);
|
|
283
|
+
for (const role of subject.roles) {
|
|
284
|
+
const roleId = await this.saveRole(role);
|
|
285
|
+
await this.sql.unsafe(`INSERT INTO ${subjectRoles} (subject_id, role_id) VALUES ($1, $2)`, [subjectId, roleId]);
|
|
286
|
+
}
|
|
287
|
+
for (const right of subject.rights.allRights()) {
|
|
288
|
+
const rightId = await this.saveRight(right);
|
|
289
|
+
await this.sql.unsafe(`INSERT INTO ${subjectRights} (subject_id, right_id) VALUES ($1, $2)`, [subjectId, rightId]);
|
|
290
|
+
}
|
|
291
|
+
return subjectId;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
async loadSubject(identifier) {
|
|
295
|
+
if (!this.sql) {
|
|
296
|
+
throw new Error('Not connected');
|
|
297
|
+
}
|
|
298
|
+
const { roles, subjectRights, subjectRoles, subjects } = this.tables;
|
|
299
|
+
const [subjectRow] = await this.sql.unsafe(`SELECT * FROM ${subjects} WHERE identifier = $1`, [identifier]);
|
|
300
|
+
if (!subjectRow) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
const subject = new Subject();
|
|
304
|
+
const subjectRoleRows = await this.sql.unsafe(`SELECT role_id FROM ${subjectRoles} WHERE subject_id = $1`, [subjectRow.id]);
|
|
305
|
+
for (const sr of subjectRoleRows) {
|
|
306
|
+
const subjectRoleRow = sr;
|
|
307
|
+
const [roleName] = await this.sql.unsafe(`SELECT name FROM ${roles} WHERE id = $1`, [subjectRoleRow.role_id]);
|
|
308
|
+
if (roleName) {
|
|
309
|
+
const role = await this.loadRole(roleName.name);
|
|
310
|
+
if (role) {
|
|
311
|
+
subject.memberOf(role);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const subjectRightRows = await this.sql.unsafe(`SELECT right_id FROM ${subjectRights} WHERE subject_id = $1`, [subjectRow.id]);
|
|
316
|
+
for (const sr of subjectRightRows) {
|
|
317
|
+
const subjectRightRow = sr;
|
|
318
|
+
const right = await this.loadRight(subjectRightRow.right_id);
|
|
319
|
+
if (right) {
|
|
320
|
+
subject.rights.add(right);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return subject;
|
|
324
|
+
}
|
|
325
|
+
async deleteSubject(identifier) {
|
|
326
|
+
if (!this.sql) {
|
|
327
|
+
throw new Error('Not connected');
|
|
328
|
+
}
|
|
329
|
+
const { subjects } = this.tables;
|
|
330
|
+
const result = await this.sql.unsafe(`DELETE FROM ${subjects} WHERE identifier = $1`, [identifier]);
|
|
331
|
+
return result.count > 0;
|
|
332
|
+
}
|
|
333
|
+
async getAllSubjectIdentifiers() {
|
|
334
|
+
if (!this.sql) {
|
|
335
|
+
throw new Error('Not connected');
|
|
336
|
+
}
|
|
337
|
+
const { subjects } = this.tables;
|
|
338
|
+
const rows = (await this.sql.unsafe(`SELECT identifier FROM ${subjects}`));
|
|
339
|
+
return rows.map(row => row.identifier);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Optimized findSubjectsWithAccess using batch loading with JOINs.
|
|
343
|
+
* Reduces N+1 queries to a constant number of queries regardless of subject count.
|
|
344
|
+
*/
|
|
345
|
+
async findSubjectsWithAccess(pathPattern, flags) {
|
|
346
|
+
if (!this.sql) {
|
|
347
|
+
throw new Error('Not connected');
|
|
348
|
+
}
|
|
349
|
+
const { rights, roleRights, roles, subjectRights, subjectRoles, subjects } = this.tables;
|
|
350
|
+
// Load all subjects with their data in batch queries
|
|
351
|
+
const subjectRows = await this.sql.unsafe(`SELECT id, identifier FROM ${subjects}`);
|
|
352
|
+
if (subjectRows.length === 0) {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
// Create a map of subject id -> identifier for quick lookup
|
|
356
|
+
const subjectIdToIdentifier = new Map();
|
|
357
|
+
for (const row of subjectRows) {
|
|
358
|
+
const { id, identifier } = row;
|
|
359
|
+
subjectIdToIdentifier.set(id, identifier);
|
|
360
|
+
}
|
|
361
|
+
// Batch load all subject-role mappings with role names
|
|
362
|
+
const subjectRoleRows = await this.sql.unsafe(`SELECT sr.subject_id, r.name as role_name
|
|
363
|
+
FROM ${subjectRoles} sr
|
|
364
|
+
JOIN ${roles} r ON sr.role_id = r.id`);
|
|
365
|
+
// Batch load all subject direct rights
|
|
366
|
+
const subjectRightRows = await this.sql.unsafe(`SELECT sr.subject_id, rt.*
|
|
367
|
+
FROM ${subjectRights} sr
|
|
368
|
+
JOIN ${rights} rt ON sr.right_id = rt.id`);
|
|
369
|
+
// Batch load all role rights
|
|
370
|
+
const roleRightRows = await this.sql.unsafe(`SELECT r.name as role_name, rt.*
|
|
371
|
+
FROM ${roleRights} rr
|
|
372
|
+
JOIN ${roles} r ON rr.role_id = r.id
|
|
373
|
+
JOIN ${rights} rt ON rr.right_id = rt.id`);
|
|
374
|
+
// Build role -> rights mapping
|
|
375
|
+
const roleRightsMap = new Map();
|
|
376
|
+
for (const row of roleRightRows) {
|
|
377
|
+
const { role_name, ...rightData } = row;
|
|
378
|
+
if (!roleRightsMap.has(role_name)) {
|
|
379
|
+
roleRightsMap.set(role_name, new Rights());
|
|
380
|
+
}
|
|
381
|
+
roleRightsMap
|
|
382
|
+
.get(role_name)
|
|
383
|
+
.add(this.rowToRight(rightData));
|
|
384
|
+
}
|
|
385
|
+
// Build subject -> roles mapping
|
|
386
|
+
const subjectRolesMap = new Map();
|
|
387
|
+
for (const row of subjectRoleRows) {
|
|
388
|
+
const { role_name, subject_id } = row;
|
|
389
|
+
if (!subjectRolesMap.has(subject_id)) {
|
|
390
|
+
subjectRolesMap.set(subject_id, []);
|
|
391
|
+
}
|
|
392
|
+
subjectRolesMap.get(subject_id).push(role_name);
|
|
393
|
+
}
|
|
394
|
+
// Build subject -> direct rights mapping
|
|
395
|
+
const subjectDirectRightsMap = new Map();
|
|
396
|
+
for (const row of subjectRightRows) {
|
|
397
|
+
const { subject_id, ...rightData } = row;
|
|
398
|
+
if (!subjectDirectRightsMap.has(subject_id)) {
|
|
399
|
+
subjectDirectRightsMap.set(subject_id, new Rights());
|
|
400
|
+
}
|
|
401
|
+
subjectDirectRightsMap
|
|
402
|
+
.get(subject_id)
|
|
403
|
+
.add(this.rowToRight(rightData));
|
|
404
|
+
}
|
|
405
|
+
// Now construct Subject objects and check access
|
|
406
|
+
const matchingSubjects = [];
|
|
407
|
+
for (const [subjectId, identifier] of subjectIdToIdentifier) {
|
|
408
|
+
const subject = new Subject();
|
|
409
|
+
// Add roles with their rights
|
|
410
|
+
const roleNames = subjectRolesMap.get(subjectId) ?? [];
|
|
411
|
+
for (const roleName of roleNames) {
|
|
412
|
+
const roleRights = roleRightsMap.get(roleName) ?? new Rights();
|
|
413
|
+
const role = new Role(roleName, roleRights);
|
|
414
|
+
subject.memberOf(role);
|
|
415
|
+
}
|
|
416
|
+
// Add direct rights
|
|
417
|
+
const directRights = subjectDirectRightsMap.get(subjectId);
|
|
418
|
+
if (directRights) {
|
|
419
|
+
for (const right of directRights.allRights()) {
|
|
420
|
+
subject.rights.add(right);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Check if subject has the requested access
|
|
424
|
+
if (subject.has(pathPattern, flags)) {
|
|
425
|
+
matchingSubjects.push(identifier);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return matchingSubjects;
|
|
429
|
+
}
|
|
430
|
+
// ===========================================================================
|
|
431
|
+
// Utility
|
|
432
|
+
// ===========================================================================
|
|
433
|
+
async clear() {
|
|
434
|
+
if (!this.sql) {
|
|
435
|
+
throw new Error('Not connected');
|
|
436
|
+
}
|
|
437
|
+
const { rights, roleInheritance, roleRights, roles, subjectRights, subjectRoles, subjects } = this.tables;
|
|
438
|
+
await this.sql.unsafe(`DELETE FROM ${subjectRights}`);
|
|
439
|
+
await this.sql.unsafe(`DELETE FROM ${subjectRoles}`);
|
|
440
|
+
await this.sql.unsafe(`DELETE FROM ${subjects}`);
|
|
441
|
+
await this.sql.unsafe(`DELETE FROM ${roleInheritance}`);
|
|
442
|
+
await this.sql.unsafe(`DELETE FROM ${roleRights}`);
|
|
443
|
+
await this.sql.unsafe(`DELETE FROM ${roles}`);
|
|
444
|
+
await this.sql.unsafe(`DELETE FROM ${rights}`);
|
|
445
|
+
}
|
|
446
|
+
async transaction(fn) {
|
|
447
|
+
if (!this.sql) {
|
|
448
|
+
throw new Error('Not connected');
|
|
449
|
+
}
|
|
450
|
+
const isNested = this.transactionDepth > 0;
|
|
451
|
+
if (!isNested) {
|
|
452
|
+
await this.sql.unsafe('BEGIN');
|
|
453
|
+
}
|
|
454
|
+
this.transactionDepth++;
|
|
455
|
+
try {
|
|
456
|
+
const result = await fn(this);
|
|
457
|
+
this.transactionDepth--;
|
|
458
|
+
if (!isNested) {
|
|
459
|
+
await this.sql.unsafe('COMMIT');
|
|
460
|
+
}
|
|
461
|
+
return result;
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
this.transactionDepth--;
|
|
465
|
+
if (!isNested) {
|
|
466
|
+
await this.sql.unsafe('ROLLBACK');
|
|
467
|
+
}
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Flags } from '../constants';
|
|
2
|
+
import { Right } from '../right';
|
|
3
|
+
import { Rights } from '../rights';
|
|
4
|
+
import { Role } from '../role';
|
|
5
|
+
import { RoleRegistry } from '../role-registry';
|
|
6
|
+
import { Subject } from '../subject';
|
|
7
|
+
import { BaseAdapter } from './base-adapter';
|
|
8
|
+
import type { BaseAdapterOptions, DatabaseAdapter } from './types';
|
|
9
|
+
export type RedisAdapterOptions = BaseAdapterOptions & {
|
|
10
|
+
db?: number;
|
|
11
|
+
host?: string;
|
|
12
|
+
keyPrefix?: string;
|
|
13
|
+
lazyConnect?: boolean;
|
|
14
|
+
password?: string;
|
|
15
|
+
port?: number;
|
|
16
|
+
tls?: object;
|
|
17
|
+
url?: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Redis key structure:
|
|
21
|
+
*
|
|
22
|
+
* Rights:
|
|
23
|
+
* {prefix}rights:{id} → Hash { path, allow_mask, deny_mask, description, tags, valid_from, valid_until, created_at, updated_at }
|
|
24
|
+
* {prefix}rights:_seq → String (auto-increment counter)
|
|
25
|
+
* {prefix}rights:_all → Set of all right IDs
|
|
26
|
+
* {prefix}rights:_unique:{hash} → String (right ID for unique constraint lookup)
|
|
27
|
+
*
|
|
28
|
+
* Roles:
|
|
29
|
+
* {prefix}roles:{name} → Hash { name, created_at, updated_at }
|
|
30
|
+
* {prefix}roles:_all → Set of all role names
|
|
31
|
+
* {prefix}roles:{name}:rights → Set of right IDs
|
|
32
|
+
* {prefix}roles:{name}:parents → Set of parent role names
|
|
33
|
+
*
|
|
34
|
+
* Subjects:
|
|
35
|
+
* {prefix}subjects:{identifier} → Hash { identifier, id, created_at, updated_at }
|
|
36
|
+
* {prefix}subjects:_all → Set of all subject identifiers
|
|
37
|
+
* {prefix}subjects:_seq → String (auto-increment counter for subject IDs)
|
|
38
|
+
* {prefix}subjects:{identifier}:roles → Set of role names
|
|
39
|
+
* {prefix}subjects:{identifier}:rights → Set of right IDs
|
|
40
|
+
*/
|
|
41
|
+
export declare class RedisAdapter extends BaseAdapter {
|
|
42
|
+
private redis;
|
|
43
|
+
private readonly options;
|
|
44
|
+
private transactionDepth;
|
|
45
|
+
constructor(options?: RedisAdapterOptions);
|
|
46
|
+
/**
|
|
47
|
+
* Generate a Redis key with the configured prefix
|
|
48
|
+
*/
|
|
49
|
+
private key;
|
|
50
|
+
/**
|
|
51
|
+
* Generate a unique hash for a right's unique constraint fields
|
|
52
|
+
*/
|
|
53
|
+
private rightUniqueHash;
|
|
54
|
+
connect(): Promise<void>;
|
|
55
|
+
disconnect(): Promise<void>;
|
|
56
|
+
migrate(): Promise<void>;
|
|
57
|
+
saveRight(right: Right): Promise<number>;
|
|
58
|
+
saveRights(rights: Rights): Promise<number[]>;
|
|
59
|
+
loadRight(id: number): Promise<Right | null>;
|
|
60
|
+
loadRights(): Promise<Rights>;
|
|
61
|
+
loadRightsByPath(pathPattern: string): Promise<Rights>;
|
|
62
|
+
deleteRight(id: number): Promise<boolean>;
|
|
63
|
+
saveRole(role: Role): Promise<number>;
|
|
64
|
+
loadRole(name: string): Promise<Role | null>;
|
|
65
|
+
loadRoles(): Promise<Role[]>;
|
|
66
|
+
deleteRole(name: string): Promise<boolean>;
|
|
67
|
+
saveRegistry(registry: RoleRegistry): Promise<void>;
|
|
68
|
+
loadRegistry(): Promise<RoleRegistry>;
|
|
69
|
+
saveSubject(identifier: string, subject: Subject): Promise<number>;
|
|
70
|
+
loadSubject(identifier: string): Promise<Subject | null>;
|
|
71
|
+
deleteSubject(identifier: string): Promise<boolean>;
|
|
72
|
+
protected getAllSubjectIdentifiers(): Promise<string[]>;
|
|
73
|
+
/**
|
|
74
|
+
* Optimized findSubjectsWithAccess using batch loading with Redis pipeline.
|
|
75
|
+
* Reduces N+1 queries to a constant number of Redis operations regardless of subject count.
|
|
76
|
+
*/
|
|
77
|
+
findSubjectsWithAccess(pathPattern: string, flags: Flags): Promise<string[]>;
|
|
78
|
+
clear(): Promise<void>;
|
|
79
|
+
transaction<T>(fn: (adapter: DatabaseAdapter) => Promise<T>): Promise<T>;
|
|
80
|
+
/**
|
|
81
|
+
* Convert Redis hash data to a RightsRow
|
|
82
|
+
*/
|
|
83
|
+
private hashToRightsRow;
|
|
84
|
+
}
|