@lenne.tech/nest-server 11.16.1 → 11.18.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/dist/config.env.js +8 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/decorators/response-model.decorator.d.ts +3 -0
- package/dist/core/common/decorators/response-model.decorator.js +8 -0
- package/dist/core/common/decorators/response-model.decorator.js.map +1 -0
- package/dist/core/common/helpers/db.helper.js +2 -2
- package/dist/core/common/helpers/db.helper.js.map +1 -1
- package/dist/core/common/helpers/filter.helper.js +3 -3
- package/dist/core/common/helpers/filter.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.js +2 -2
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/helpers/interceptor.helper.d.ts +3 -0
- package/dist/core/common/helpers/interceptor.helper.js +84 -0
- package/dist/core/common/helpers/interceptor.helper.js.map +1 -0
- package/dist/core/common/helpers/service.helper.d.ts +1 -0
- package/dist/core/common/helpers/service.helper.js +1 -0
- package/dist/core/common/helpers/service.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.d.ts +2 -0
- package/dist/core/common/interceptors/check-security.interceptor.js +43 -1
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interceptors/response-model.interceptor.d.ts +13 -0
- package/dist/core/common/interceptors/response-model.interceptor.js +107 -0
- package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -0
- package/dist/core/common/interceptors/translate-response.interceptor.d.ts +8 -0
- package/dist/core/common/interceptors/translate-response.interceptor.js +85 -0
- package/dist/core/common/interceptors/translate-response.interceptor.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +16 -0
- package/dist/core/common/middleware/request-context.middleware.d.ts +5 -0
- package/dist/core/common/middleware/request-context.middleware.js +29 -0
- package/dist/core/common/middleware/request-context.middleware.js.map +1 -0
- package/dist/core/common/pipes/map-and-validate.pipe.js +2 -2
- package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
- package/dist/core/common/plugins/complexity.plugin.d.ts +2 -2
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.js +51 -0
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-password.plugin.d.ts +4 -0
- package/dist/core/common/plugins/mongoose-password.plugin.js +69 -0
- package/dist/core/common/plugins/mongoose-password.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js +80 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js.map +1 -0
- package/dist/core/common/services/config.service.js +2 -2
- package/dist/core/common/services/config.service.js.map +1 -1
- package/dist/core/common/services/model-registry.service.d.ts +8 -0
- package/dist/core/common/services/model-registry.service.js +20 -0
- package/dist/core/common/services/model-registry.service.js.map +1 -0
- package/dist/core/common/services/module.service.d.ts +2 -0
- package/dist/core/common/services/module.service.js +36 -1
- package/dist/core/common/services/module.service.js.map +1 -1
- package/dist/core/common/services/request-context.service.d.ts +18 -0
- package/dist/core/common/services/request-context.service.js +32 -0
- package/dist/core/common/services/request-context.service.js.map +1 -0
- package/dist/core/modules/auth/guards/auth.guard.js +2 -2
- package/dist/core/modules/auth/guards/auth.guard.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.js +2 -2
- package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
- package/dist/core/modules/permissions/core-permissions.controller.d.ts +13 -0
- package/dist/core/modules/permissions/core-permissions.controller.js +71 -0
- package/dist/core/modules/permissions/core-permissions.controller.js.map +1 -0
- package/dist/core/modules/permissions/core-permissions.module.d.ts +5 -0
- package/dist/core/modules/permissions/core-permissions.module.js +36 -0
- package/dist/core/modules/permissions/core-permissions.module.js.map +1 -0
- package/dist/core/modules/permissions/core-permissions.service.d.ts +34 -0
- package/dist/core/modules/permissions/core-permissions.service.js +610 -0
- package/dist/core/modules/permissions/core-permissions.service.js.map +1 -0
- package/dist/core/modules/permissions/interfaces/permissions.interface.d.ts +93 -0
- package/dist/core/modules/permissions/interfaces/permissions.interface.js +3 -0
- package/dist/core/modules/permissions/interfaces/permissions.interface.js.map +1 -0
- package/dist/core/modules/permissions/permissions-scanner.d.ts +25 -0
- package/dist/core/modules/permissions/permissions-scanner.js +817 -0
- package/dist/core/modules/permissions/permissions-scanner.js.map +1 -0
- package/dist/core.module.js +41 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/file/file-info.model.d.ts +12 -12
- package/dist/server/modules/user/user.model.d.ts +33 -33
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +35 -30
- package/src/config.env.ts +8 -2
- package/src/core/common/decorators/response-model.decorator.ts +31 -0
- package/src/core/common/helpers/db.helper.ts +2 -2
- package/src/core/common/helpers/filter.helper.ts +3 -3
- package/src/core/common/helpers/input.helper.ts +2 -2
- package/src/core/common/helpers/interceptor.helper.ts +132 -0
- package/src/core/common/helpers/service.helper.ts +1 -1
- package/src/core/common/interceptors/check-security.interceptor.ts +44 -1
- package/src/core/common/interceptors/response-model.interceptor.ts +135 -0
- package/src/core/common/interceptors/translate-response.interceptor.ts +104 -0
- package/src/core/common/interfaces/server-options.interface.ts +186 -0
- package/src/core/common/middleware/request-context.middleware.ts +25 -0
- package/src/core/common/pipes/map-and-validate.pipe.ts +2 -2
- package/src/core/common/plugins/complexity.plugin.ts +2 -2
- package/src/core/common/plugins/mongoose-audit-fields.plugin.ts +74 -0
- package/src/core/common/plugins/mongoose-password.plugin.ts +100 -0
- package/src/core/common/plugins/mongoose-role-guard.plugin.ts +150 -0
- package/src/core/common/services/config.service.ts +2 -2
- package/src/core/common/services/model-registry.service.ts +25 -0
- package/src/core/common/services/module.service.ts +91 -1
- package/src/core/common/services/request-context.service.ts +69 -0
- package/src/core/modules/auth/guards/auth.guard.ts +2 -2
- package/src/core/modules/better-auth/core-better-auth.resolver.ts +2 -2
- package/src/core/modules/permissions/INTEGRATION-CHECKLIST.md +56 -0
- package/src/core/modules/permissions/README.md +102 -0
- package/src/core/modules/permissions/core-permissions.controller.ts +34 -0
- package/src/core/modules/permissions/core-permissions.module.ts +36 -0
- package/src/core/modules/permissions/core-permissions.service.ts +627 -0
- package/src/core/modules/permissions/interfaces/permissions.interface.ts +125 -0
- package/src/core/modules/permissions/permissions-scanner.ts +1011 -0
- package/src/core.module.ts +62 -4
- package/src/index.ts +20 -0
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure permissions scanning functions with no NestJS dependencies.
|
|
3
|
+
*
|
|
4
|
+
* This module is the single source of truth for AST-based permissions scanning.
|
|
5
|
+
* It is used by:
|
|
6
|
+
* - CorePermissionsService (nest-server runtime, adds caching/watcher/HTTP)
|
|
7
|
+
* - lt CLI `lt server permissions` command (dynamic import from node_modules)
|
|
8
|
+
*
|
|
9
|
+
* All functions are standalone and framework-agnostic.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
12
|
+
import { join, relative } from 'path';
|
|
13
|
+
import { Project, SyntaxKind } from 'ts-morph';
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
EffectiveEndpoint,
|
|
17
|
+
EffectiveMatrixEntry,
|
|
18
|
+
EndpointPermissions,
|
|
19
|
+
FieldPermission,
|
|
20
|
+
FilePermissions,
|
|
21
|
+
MethodPermission,
|
|
22
|
+
ModulePermissions,
|
|
23
|
+
PermissionsReport,
|
|
24
|
+
ReportStats,
|
|
25
|
+
RoleEnumInfo,
|
|
26
|
+
SecurityCheckInfo,
|
|
27
|
+
SecurityWarning,
|
|
28
|
+
WarningsByType,
|
|
29
|
+
} from './interfaces/permissions.interface';
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Public API
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Calculate coverage and warning statistics from scan results.
|
|
37
|
+
*/
|
|
38
|
+
export function calculateStats(
|
|
39
|
+
modules: ModulePermissions[],
|
|
40
|
+
objects: FilePermissions[],
|
|
41
|
+
warnings: SecurityWarning[],
|
|
42
|
+
): ReportStats {
|
|
43
|
+
const warningsByType: WarningsByType = {
|
|
44
|
+
NO_RESTRICTION: 0,
|
|
45
|
+
NO_ROLES: 0,
|
|
46
|
+
NO_SECURITY_CHECK: 0,
|
|
47
|
+
UNRESTRICTED_FIELD: 0,
|
|
48
|
+
UNRESTRICTED_METHOD: 0,
|
|
49
|
+
};
|
|
50
|
+
for (const w of warnings) {
|
|
51
|
+
if (w.type in warningsByType) {
|
|
52
|
+
warningsByType[w.type as keyof WarningsByType]++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const totalModels = modules.reduce((s, m) => s + m.models.length, 0);
|
|
57
|
+
let totalMethods = 0;
|
|
58
|
+
let methodsWithRoles = 0;
|
|
59
|
+
for (const mod of modules) {
|
|
60
|
+
for (const ctrl of mod.controllers) {
|
|
61
|
+
for (const m of ctrl.methods) {
|
|
62
|
+
totalMethods++;
|
|
63
|
+
if (m.roles.length > 0 || ctrl.classRoles.length > 0) methodsWithRoles++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const res of mod.resolvers) {
|
|
67
|
+
for (const m of res.methods) {
|
|
68
|
+
totalMethods++;
|
|
69
|
+
if (m.roles.length > 0 || res.classRoles.length > 0) methodsWithRoles++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let modelsWithBothChecks = 0;
|
|
75
|
+
for (const mod of modules) {
|
|
76
|
+
for (const model of mod.models) {
|
|
77
|
+
if (model.classRestriction.length > 0 && model.securityCheck) {
|
|
78
|
+
modelsWithBothChecks++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const endpointCoverage = totalMethods > 0 ? Math.round((methodsWithRoles / totalMethods) * 100) : 100;
|
|
84
|
+
const securityCoverage = totalModels > 0 ? Math.round((modelsWithBothChecks / totalModels) * 100) : 100;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
endpointCoverage,
|
|
88
|
+
securityCoverage,
|
|
89
|
+
totalEndpoints: totalMethods,
|
|
90
|
+
totalModels,
|
|
91
|
+
totalModules: modules.length,
|
|
92
|
+
totalSubObjects: objects.length,
|
|
93
|
+
totalWarnings: warnings.length,
|
|
94
|
+
warningsByType,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build an effective permissions matrix for a module, grouping endpoints by role.
|
|
100
|
+
*/
|
|
101
|
+
export function buildEffectiveMatrix(mod: ModulePermissions): EffectiveMatrixEntry[] {
|
|
102
|
+
const allRoles = new Set<string>();
|
|
103
|
+
|
|
104
|
+
for (const ctrl of mod.controllers) {
|
|
105
|
+
for (const r of ctrl.classRoles) allRoles.add(r);
|
|
106
|
+
for (const m of ctrl.methods) {
|
|
107
|
+
for (const r of m.roles) allRoles.add(r);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const res of mod.resolvers) {
|
|
112
|
+
for (const r of res.classRoles) allRoles.add(r);
|
|
113
|
+
for (const m of res.methods) {
|
|
114
|
+
for (const r of m.roles) allRoles.add(r);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result: EffectiveMatrixEntry[] = [];
|
|
119
|
+
|
|
120
|
+
for (const role of [...allRoles].sort()) {
|
|
121
|
+
const endpoints: EffectiveEndpoint[] = [];
|
|
122
|
+
|
|
123
|
+
for (const ctrl of mod.controllers) {
|
|
124
|
+
for (const m of ctrl.methods) {
|
|
125
|
+
const effective = m.roles.length > 0 ? m.roles : ctrl.classRoles;
|
|
126
|
+
if (effective.includes(role)) {
|
|
127
|
+
endpoints.push({ effectiveRoles: effective, method: m.httpMethod, name: m.name, source: 'Controller' });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const res of mod.resolvers) {
|
|
133
|
+
for (const m of res.methods) {
|
|
134
|
+
const effective = m.roles.length > 0 ? m.roles : res.classRoles;
|
|
135
|
+
if (effective.includes(role)) {
|
|
136
|
+
endpoints.push({ effectiveRoles: effective, method: m.httpMethod, name: m.name, source: 'Resolver' });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
result.push({ endpoints, role });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Collect all role enums from the project's common/enums and modules directories.
|
|
149
|
+
*/
|
|
150
|
+
export function collectRoleEnums(project: Project, projectPath: string): RoleEnumInfo[] {
|
|
151
|
+
const enums: RoleEnumInfo[] = [];
|
|
152
|
+
const enumPatterns = [
|
|
153
|
+
join(projectPath, 'src', 'server', 'common', 'enums'),
|
|
154
|
+
join(projectPath, 'src', 'server', 'modules'),
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
for (const dir of enumPatterns) {
|
|
158
|
+
try {
|
|
159
|
+
const enumFiles = project.addSourceFilesAtPaths(join(dir, '**', '*.enum.ts'));
|
|
160
|
+
for (const sf of enumFiles) {
|
|
161
|
+
for (const enumDecl of sf.getEnums()) {
|
|
162
|
+
const enumName = enumDecl.getName();
|
|
163
|
+
if (enumName.toLowerCase().includes('role')) {
|
|
164
|
+
const values = enumDecl.getMembers().map((m) => ({
|
|
165
|
+
key: m.getName(),
|
|
166
|
+
value: m.getValue()?.toString() || m.getName(),
|
|
167
|
+
}));
|
|
168
|
+
enums.push({
|
|
169
|
+
file: relative(projectPath, sf.getFilePath()),
|
|
170
|
+
name: enumName,
|
|
171
|
+
values,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// Directory may not exist
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return enums;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Detect security gaps in modules and objects and return warnings.
|
|
186
|
+
*/
|
|
187
|
+
export function detectSecurityGaps(modules: ModulePermissions[], objects: FilePermissions[]): SecurityWarning[] {
|
|
188
|
+
const warnings: SecurityWarning[] = [];
|
|
189
|
+
|
|
190
|
+
for (const mod of modules) {
|
|
191
|
+
for (const model of mod.models) {
|
|
192
|
+
if (model.classRestriction.length === 0) {
|
|
193
|
+
warnings.push({
|
|
194
|
+
details: `Model ${model.className} has no @Restricted class-level restriction`,
|
|
195
|
+
file: model.filePath,
|
|
196
|
+
module: mod.name,
|
|
197
|
+
type: 'NO_RESTRICTION',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (!model.securityCheck) {
|
|
201
|
+
warnings.push({
|
|
202
|
+
details: `Model ${model.className} has no securityCheck override`,
|
|
203
|
+
file: model.filePath,
|
|
204
|
+
module: mod.name,
|
|
205
|
+
type: 'NO_SECURITY_CHECK',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
for (const field of model.fields) {
|
|
209
|
+
if (field.roles === '*(none)*') {
|
|
210
|
+
warnings.push({
|
|
211
|
+
details: `Field '${field.name}' has no role restriction`,
|
|
212
|
+
file: model.filePath,
|
|
213
|
+
module: mod.name,
|
|
214
|
+
type: 'UNRESTRICTED_FIELD',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const input of mod.inputs) {
|
|
220
|
+
for (const field of input.fields) {
|
|
221
|
+
if (field.roles === '*(none)*') {
|
|
222
|
+
warnings.push({
|
|
223
|
+
details: `Field '${field.name}' has no role restriction`,
|
|
224
|
+
file: input.filePath,
|
|
225
|
+
module: mod.name,
|
|
226
|
+
type: 'UNRESTRICTED_FIELD',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const ctrl of mod.controllers) {
|
|
232
|
+
if (ctrl.classRoles.length === 0) {
|
|
233
|
+
warnings.push({
|
|
234
|
+
details: `Controller ${ctrl.className} has no @Roles class-level restriction`,
|
|
235
|
+
file: ctrl.filePath,
|
|
236
|
+
module: mod.name,
|
|
237
|
+
type: 'NO_ROLES',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
for (const method of ctrl.methods) {
|
|
241
|
+
if (method.roles.length === 0 && ctrl.classRoles.length === 0) {
|
|
242
|
+
warnings.push({
|
|
243
|
+
details: `Method '${method.name}' has no @Roles and class has no @Roles`,
|
|
244
|
+
file: ctrl.filePath,
|
|
245
|
+
module: mod.name,
|
|
246
|
+
type: 'UNRESTRICTED_METHOD',
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const res of mod.resolvers) {
|
|
252
|
+
if (res.classRoles.length === 0) {
|
|
253
|
+
warnings.push({
|
|
254
|
+
details: `Resolver ${res.className} has no @Roles class-level restriction`,
|
|
255
|
+
file: res.filePath,
|
|
256
|
+
module: mod.name,
|
|
257
|
+
type: 'NO_ROLES',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
for (const method of res.methods) {
|
|
261
|
+
if (method.roles.length === 0 && res.classRoles.length === 0) {
|
|
262
|
+
warnings.push({
|
|
263
|
+
details: `Method '${method.name}' has no @Roles and class has no @Roles`,
|
|
264
|
+
file: res.filePath,
|
|
265
|
+
module: mod.name,
|
|
266
|
+
type: 'UNRESTRICTED_METHOD',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const obj of objects) {
|
|
274
|
+
for (const field of obj.fields) {
|
|
275
|
+
if (field.roles === '*(none)*') {
|
|
276
|
+
warnings.push({
|
|
277
|
+
details: `Field '${field.name}' has no role restriction`,
|
|
278
|
+
file: obj.filePath,
|
|
279
|
+
module: 'objects',
|
|
280
|
+
type: 'UNRESTRICTED_FIELD',
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return warnings;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Discover module directories under the given modules directory.
|
|
291
|
+
*/
|
|
292
|
+
export function discoverModules(modulesDir: string): string[] {
|
|
293
|
+
if (!existsSync(modulesDir)) return [];
|
|
294
|
+
return readdirSync(modulesDir)
|
|
295
|
+
.filter((item) => statSync(join(modulesDir, item)).isDirectory())
|
|
296
|
+
.sort();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Find the project root containing src/server/modules/.
|
|
301
|
+
* Walks up from startPath (default: process.cwd()) up to 10 levels.
|
|
302
|
+
*/
|
|
303
|
+
export function findProjectRoot(startPath?: string): string | null {
|
|
304
|
+
let current = startPath || process.cwd();
|
|
305
|
+
for (let i = 0; i < 10; i++) {
|
|
306
|
+
if (existsSync(join(current, 'src', 'server', 'modules'))) {
|
|
307
|
+
return current;
|
|
308
|
+
}
|
|
309
|
+
const parent = join(current, '..');
|
|
310
|
+
if (parent === current) break;
|
|
311
|
+
current = parent;
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Scan a NestJS project and return a full PermissionsReport.
|
|
318
|
+
*
|
|
319
|
+
* This is the main entry point for external consumers (e.g. lt CLI).
|
|
320
|
+
*
|
|
321
|
+
* @param projectPath - Absolute path to the project root (must contain src/server/modules/)
|
|
322
|
+
* @param logger - Optional logging callbacks for progress reporting
|
|
323
|
+
*/
|
|
324
|
+
export function scanPermissions(
|
|
325
|
+
projectPath: string,
|
|
326
|
+
logger?: { log?: (msg: string) => void; warn?: (msg: string) => void },
|
|
327
|
+
): PermissionsReport {
|
|
328
|
+
const log = logger?.log || (() => {});
|
|
329
|
+
const warn = logger?.warn || (() => {});
|
|
330
|
+
|
|
331
|
+
log('Scanning permissions...');
|
|
332
|
+
|
|
333
|
+
const project = new Project({ compilerOptions: { allowJs: true }, skipAddingFilesFromTsConfig: true });
|
|
334
|
+
const modulesDir = join(projectPath, 'src', 'server', 'modules');
|
|
335
|
+
const objectsDir = join(projectPath, 'src', 'server', 'common', 'objects');
|
|
336
|
+
|
|
337
|
+
// Preload nest-server base classes once (used by resolveInheritedFields for all models)
|
|
338
|
+
preloadBaseClasses(project, projectPath);
|
|
339
|
+
|
|
340
|
+
const roleEnums = collectRoleEnums(project, projectPath);
|
|
341
|
+
const moduleNames = discoverModules(modulesDir);
|
|
342
|
+
const modules: ModulePermissions[] = [];
|
|
343
|
+
|
|
344
|
+
for (const name of moduleNames) {
|
|
345
|
+
try {
|
|
346
|
+
modules.push(scanModule(project, modulesDir, name, projectPath));
|
|
347
|
+
} catch (error) {
|
|
348
|
+
warn(`Failed to scan module '${name}': ${error}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const objects = scanObjects(project, objectsDir, projectPath);
|
|
353
|
+
const warnings = detectSecurityGaps(modules, objects);
|
|
354
|
+
const stats = calculateStats(modules, objects, warnings);
|
|
355
|
+
|
|
356
|
+
log(`Scan complete: ${modules.length} modules, ${objects.length} objects, ${warnings.length} warnings`);
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
generated: new Date().toISOString(),
|
|
360
|
+
modules,
|
|
361
|
+
objects,
|
|
362
|
+
roleEnums,
|
|
363
|
+
stats,
|
|
364
|
+
warnings,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Scan a single module directory for models, inputs, outputs, controllers, and resolvers.
|
|
370
|
+
*/
|
|
371
|
+
export function scanModule(
|
|
372
|
+
project: Project,
|
|
373
|
+
modulesDir: string,
|
|
374
|
+
moduleName: string,
|
|
375
|
+
projectPath: string,
|
|
376
|
+
): ModulePermissions {
|
|
377
|
+
const moduleDir = join(modulesDir, moduleName);
|
|
378
|
+
const result: ModulePermissions = {
|
|
379
|
+
controllers: [],
|
|
380
|
+
inputs: [],
|
|
381
|
+
models: [],
|
|
382
|
+
name: moduleName,
|
|
383
|
+
outputs: [],
|
|
384
|
+
resolvers: [],
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// Models
|
|
388
|
+
for (const file of listDir(moduleDir).filter((f) => f.endsWith('.model.ts'))) {
|
|
389
|
+
try {
|
|
390
|
+
const sf = project.addSourceFileAtPath(join(moduleDir, file));
|
|
391
|
+
const perms = parseFilePermissions(sf, relative(projectPath, join(moduleDir, file)), true);
|
|
392
|
+
if (perms) {
|
|
393
|
+
const classDecl = sf.getClasses()[0];
|
|
394
|
+
if (classDecl) {
|
|
395
|
+
const inheritedFields = resolveInheritedFields(project, classDecl);
|
|
396
|
+
const localNames = new Set(perms.fields.map((f) => f.name));
|
|
397
|
+
for (const iField of inheritedFields) {
|
|
398
|
+
if (!localNames.has(iField.name)) perms.fields.push(iField);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
result.models.push(perms);
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
/* skip */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Inputs
|
|
409
|
+
const inputDir = join(moduleDir, 'inputs');
|
|
410
|
+
for (const file of listDir(inputDir).filter((f) => f.endsWith('.input.ts'))) {
|
|
411
|
+
try {
|
|
412
|
+
const sf = project.addSourceFileAtPath(join(inputDir, file));
|
|
413
|
+
const perms = parseFilePermissions(sf, relative(projectPath, join(inputDir, file)), false);
|
|
414
|
+
if (perms) result.inputs.push(perms);
|
|
415
|
+
} catch {
|
|
416
|
+
/* skip */
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Outputs
|
|
421
|
+
const outputDir = join(moduleDir, 'outputs');
|
|
422
|
+
for (const file of listDir(outputDir).filter((f) => f.endsWith('.output.ts'))) {
|
|
423
|
+
try {
|
|
424
|
+
const sf = project.addSourceFileAtPath(join(outputDir, file));
|
|
425
|
+
const perms = parseFilePermissions(sf, relative(projectPath, join(outputDir, file)), false);
|
|
426
|
+
if (perms) result.outputs.push(perms);
|
|
427
|
+
} catch {
|
|
428
|
+
/* skip */
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Controllers
|
|
433
|
+
for (const file of listDir(moduleDir).filter((f) => f.endsWith('.controller.ts'))) {
|
|
434
|
+
try {
|
|
435
|
+
const sf = project.addSourceFileAtPath(join(moduleDir, file));
|
|
436
|
+
const perms = parseEndpointPermissions(sf, relative(projectPath, join(moduleDir, file)));
|
|
437
|
+
if (perms) result.controllers.push(perms);
|
|
438
|
+
} catch {
|
|
439
|
+
/* skip */
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Resolvers
|
|
444
|
+
for (const file of listDir(moduleDir).filter((f) => f.endsWith('.resolver.ts'))) {
|
|
445
|
+
try {
|
|
446
|
+
const sf = project.addSourceFileAtPath(join(moduleDir, file));
|
|
447
|
+
const perms = parseEndpointPermissions(sf, relative(projectPath, join(moduleDir, file)));
|
|
448
|
+
if (perms) result.resolvers.push(perms);
|
|
449
|
+
} catch {
|
|
450
|
+
/* skip */
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Scan SubObjects directory for .object.ts files.
|
|
459
|
+
*/
|
|
460
|
+
export function scanObjects(project: Project, objectsDir: string, projectPath: string): FilePermissions[] {
|
|
461
|
+
const objects: FilePermissions[] = [];
|
|
462
|
+
if (!existsSync(objectsDir)) return objects;
|
|
463
|
+
|
|
464
|
+
for (const dir of listDir(objectsDir)) {
|
|
465
|
+
const dirPath = join(objectsDir, dir);
|
|
466
|
+
try {
|
|
467
|
+
if (!statSync(dirPath).isDirectory()) continue;
|
|
468
|
+
} catch {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
for (const file of listDir(dirPath).filter((f) => f.endsWith('.object.ts'))) {
|
|
472
|
+
try {
|
|
473
|
+
const sf = project.addSourceFileAtPath(join(dirPath, file));
|
|
474
|
+
const perms = parseFilePermissions(sf, relative(projectPath, join(dirPath, file)), false);
|
|
475
|
+
if (perms) objects.push(perms);
|
|
476
|
+
} catch {
|
|
477
|
+
/* skip */
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return objects;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
486
|
+
// Parsing helpers (exported for advanced usage, but normally not needed directly)
|
|
487
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Extract roles from decorator arguments, handling array syntax and enum prefixes.
|
|
491
|
+
*/
|
|
492
|
+
export function extractDecoratorRoles(decoratorArgs: string[]): string[] {
|
|
493
|
+
const roles: string[] = [];
|
|
494
|
+
for (const arg of decoratorArgs) {
|
|
495
|
+
const cleaned = arg.trim();
|
|
496
|
+
if (cleaned.startsWith('[')) {
|
|
497
|
+
const inner = cleaned.slice(1, -1);
|
|
498
|
+
for (const item of inner.split(',')) {
|
|
499
|
+
roles.push(formatRole(item.trim()));
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
roles.push(formatRole(cleaned));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return roles;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Analyze a securityCheck() method body using regex patterns to detect:
|
|
510
|
+
* - `delete obj.field` statements (direct field removal)
|
|
511
|
+
* - Named arrays like `fieldsToRemove = [...]` (batch field removal)
|
|
512
|
+
* - Helper calls like `removeKeys(obj, [...])` (utility-based removal)
|
|
513
|
+
* - `return undefined/null` (full object suppression)
|
|
514
|
+
*/
|
|
515
|
+
export function extractSecurityCheckInfo(classDecl: any): SecurityCheckInfo | undefined {
|
|
516
|
+
const method = classDecl.getMethod('securityCheck');
|
|
517
|
+
if (!method) return undefined;
|
|
518
|
+
|
|
519
|
+
const body = method.getBodyText() || '';
|
|
520
|
+
const fieldsStripped: string[] = [];
|
|
521
|
+
|
|
522
|
+
const deleteMatches = body.matchAll(/delete\s+\w+\.(\w+)/g);
|
|
523
|
+
for (const m of deleteMatches) fieldsStripped.push(m[1]);
|
|
524
|
+
|
|
525
|
+
const arrayMatches = body.matchAll(/(?:fieldsToRemove|removeFields|stripFields|fieldsToStrip)\s*=\s*\[([^\]]+)\]/g);
|
|
526
|
+
for (const m of arrayMatches) {
|
|
527
|
+
fieldsStripped.push(...m[1].split(',').map((f: string) => f.trim().replace(/['"]/g, '')));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const graphqlFieldMatches = body.matchAll(/(?:removeKeys|filterKeys)\s*\([^,]*,\s*\[([^\]]+)\]/g);
|
|
531
|
+
for (const m of graphqlFieldMatches) {
|
|
532
|
+
fieldsStripped.push(...m[1].split(',').map((f: string) => f.trim().replace(/['"]/g, '')));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const returnsUndefined = body.includes('return undefined') || body.includes('return null');
|
|
536
|
+
|
|
537
|
+
const summaryParts: string[] = ['Present'];
|
|
538
|
+
if (fieldsStripped.length > 0) summaryParts.push(`Strips fields: ${fieldsStripped.join(', ')}`);
|
|
539
|
+
if (returnsUndefined) summaryParts.push('May return undefined');
|
|
540
|
+
|
|
541
|
+
return { fieldsStripped: [...new Set(fieldsStripped)], returnsUndefined, summary: summaryParts.join('. ') };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Strip enum prefix (e.g. 'RoleEnum.ADMIN' -> 'ADMIN') */
|
|
545
|
+
export function formatRole(role: string): string {
|
|
546
|
+
if (!role) return '';
|
|
547
|
+
const dotIndex = role.lastIndexOf('.');
|
|
548
|
+
return dotIndex >= 0 ? role.substring(dotIndex + 1) : role;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Format roles array for display in Markdown output. */
|
|
552
|
+
export function formatRolesDisplay(roles: string[]): string {
|
|
553
|
+
if (roles.length === 0) return '*(none)*';
|
|
554
|
+
if (roles.length === 1) return `\`${roles[0]}\``;
|
|
555
|
+
return roles.map((r) => `\`${r}\``).join(', ');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Generate a Markdown report from a PermissionsReport.
|
|
560
|
+
*
|
|
561
|
+
* This is the single source of truth for Markdown output, used by both
|
|
562
|
+
* the nest-server runtime endpoint and the CLI command.
|
|
563
|
+
*/
|
|
564
|
+
export function generateMarkdownReport(report: PermissionsReport, projectPath?: string): string {
|
|
565
|
+
const lines: string[] = [];
|
|
566
|
+
|
|
567
|
+
// Header
|
|
568
|
+
lines.push('# Permissions Report');
|
|
569
|
+
lines.push('');
|
|
570
|
+
lines.push(`> Generated: ${report.generated}`);
|
|
571
|
+
if (projectPath) lines.push(`> Project: ${projectPath}`);
|
|
572
|
+
lines.push(
|
|
573
|
+
`> Modules: ${report.stats.totalModules} | Models: ${report.stats.totalModels} | Endpoints: ${report.stats.totalEndpoints} | SubObjects: ${report.stats.totalSubObjects}`,
|
|
574
|
+
);
|
|
575
|
+
lines.push(
|
|
576
|
+
`> Warnings: ${report.stats.totalWarnings} | Endpoint Coverage: ${report.stats.endpointCoverage}% | Security Coverage: ${report.stats.securityCoverage}%`,
|
|
577
|
+
);
|
|
578
|
+
lines.push('');
|
|
579
|
+
|
|
580
|
+
// Table of contents
|
|
581
|
+
lines.push('## Table of Contents');
|
|
582
|
+
lines.push('');
|
|
583
|
+
lines.push('- [Role Index](#role-index)');
|
|
584
|
+
lines.push('- [Summary](#summary)');
|
|
585
|
+
lines.push('- [Warnings](#warnings)');
|
|
586
|
+
for (const mod of report.modules) {
|
|
587
|
+
const anchor = mod.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
588
|
+
lines.push(`- [Module: ${mod.name}](#module-${anchor})`);
|
|
589
|
+
}
|
|
590
|
+
if (report.objects.length > 0) {
|
|
591
|
+
lines.push('- [SubObjects](#subobjects)');
|
|
592
|
+
}
|
|
593
|
+
lines.push('');
|
|
594
|
+
|
|
595
|
+
// Role index
|
|
596
|
+
lines.push('## Role Index');
|
|
597
|
+
lines.push('');
|
|
598
|
+
if (report.roleEnums.length > 0) {
|
|
599
|
+
lines.push('| Enum | Value | Type |');
|
|
600
|
+
lines.push('|------|-------|------|');
|
|
601
|
+
for (const enumInfo of report.roleEnums) {
|
|
602
|
+
for (const v of enumInfo.values) {
|
|
603
|
+
const isSystem = v.key.startsWith('S_');
|
|
604
|
+
lines.push(
|
|
605
|
+
`| ${enumInfo.name}.${v.key} | ${isSystem ? '*(system)*' : `\`${v.value}\``} | ${isSystem ? 'System' : 'Real'} |`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
lines.push('*No role enums found.*');
|
|
611
|
+
}
|
|
612
|
+
lines.push('');
|
|
613
|
+
|
|
614
|
+
// Summary table
|
|
615
|
+
lines.push('## Summary');
|
|
616
|
+
lines.push('');
|
|
617
|
+
lines.push('| Module | Models | Inputs | Outputs | Controllers | Resolvers | Warnings |');
|
|
618
|
+
lines.push('|--------|--------|--------|---------|-------------|-----------|----------|');
|
|
619
|
+
for (const mod of report.modules) {
|
|
620
|
+
const modWarnings = report.warnings.filter((w) => w.module === mod.name).length;
|
|
621
|
+
lines.push(
|
|
622
|
+
`| ${mod.name} | ${mod.models.length} | ${mod.inputs.length} | ${mod.outputs.length} | ${mod.controllers.length} | ${mod.resolvers.length} | ${modWarnings} |`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
lines.push('');
|
|
626
|
+
|
|
627
|
+
if (report.stats.totalWarnings > 0) {
|
|
628
|
+
lines.push('### Warnings by Type');
|
|
629
|
+
lines.push('');
|
|
630
|
+
lines.push('| Type | Count |');
|
|
631
|
+
lines.push('|------|-------|');
|
|
632
|
+
for (const [type, count] of Object.entries(report.stats.warningsByType)) {
|
|
633
|
+
if (count > 0) lines.push(`| ${type} | ${count} |`);
|
|
634
|
+
}
|
|
635
|
+
lines.push('');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Warnings
|
|
639
|
+
lines.push('## Warnings');
|
|
640
|
+
lines.push('');
|
|
641
|
+
if (report.warnings.length > 0) {
|
|
642
|
+
lines.push('| # | Module | File | Type | Details |');
|
|
643
|
+
lines.push('|---|--------|------|------|---------|');
|
|
644
|
+
report.warnings.forEach((w, i) => {
|
|
645
|
+
const fileName = w.file.split('/').pop() || w.file;
|
|
646
|
+
lines.push(`| ${i + 1} | ${w.module} | ${fileName} | ${w.type} | ${w.details} |`);
|
|
647
|
+
});
|
|
648
|
+
} else {
|
|
649
|
+
lines.push('*No warnings found.*');
|
|
650
|
+
}
|
|
651
|
+
lines.push('');
|
|
652
|
+
|
|
653
|
+
// Module details
|
|
654
|
+
for (const mod of report.modules) {
|
|
655
|
+
lines.push('---');
|
|
656
|
+
lines.push('');
|
|
657
|
+
lines.push(`## Module: ${mod.name}`);
|
|
658
|
+
lines.push('');
|
|
659
|
+
|
|
660
|
+
// Models
|
|
661
|
+
for (const model of mod.models) {
|
|
662
|
+
lines.push(`### Model: ${model.className}`);
|
|
663
|
+
lines.push(`- **File:** \`${model.filePath}\``);
|
|
664
|
+
if (model.extendsClass) lines.push(`- **Extends:** \`${model.extendsClass}\``);
|
|
665
|
+
lines.push(
|
|
666
|
+
`- **Class Restriction:** ${model.classRestriction.length > 0 ? model.classRestriction.map((r) => `\`${r}\``).join(', ') : '*(none)*'}`,
|
|
667
|
+
);
|
|
668
|
+
if (model.securityCheck) {
|
|
669
|
+
lines.push(`- **securityCheck:** ${model.securityCheck.summary}`);
|
|
670
|
+
} else {
|
|
671
|
+
lines.push('- **securityCheck:** Not present');
|
|
672
|
+
}
|
|
673
|
+
lines.push('');
|
|
674
|
+
|
|
675
|
+
if (model.fields.length > 0) {
|
|
676
|
+
lines.push('| Field | Roles | Source |');
|
|
677
|
+
lines.push('|-------|-------|--------|');
|
|
678
|
+
for (const field of model.fields) {
|
|
679
|
+
const source = field.inherited ? 'inherited' : 'local';
|
|
680
|
+
lines.push(`| ${field.name} | ${field.roles} | ${source} |`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
lines.push('');
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Inputs
|
|
687
|
+
for (const input of mod.inputs) {
|
|
688
|
+
lines.push(`### Input: ${input.className}`);
|
|
689
|
+
lines.push(`- **File:** \`${input.filePath}\``);
|
|
690
|
+
if (input.extendsClass) lines.push(`- **Extends:** \`${input.extendsClass}\``);
|
|
691
|
+
lines.push(
|
|
692
|
+
`- **Class Restriction:** ${input.classRestriction.length > 0 ? input.classRestriction.map((r) => `\`${r}\``).join(', ') : '*(none)*'}`,
|
|
693
|
+
);
|
|
694
|
+
lines.push('');
|
|
695
|
+
|
|
696
|
+
if (input.fields.length > 0) {
|
|
697
|
+
lines.push('| Field | Roles |');
|
|
698
|
+
lines.push('|-------|-------|');
|
|
699
|
+
for (const field of input.fields) {
|
|
700
|
+
lines.push(`| ${field.name} | ${field.roles} |`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
lines.push('');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Outputs
|
|
707
|
+
for (const output of mod.outputs) {
|
|
708
|
+
lines.push(`### Output: ${output.className}`);
|
|
709
|
+
lines.push(`- **File:** \`${output.filePath}\``);
|
|
710
|
+
if (output.extendsClass) lines.push(`- **Extends:** \`${output.extendsClass}\``);
|
|
711
|
+
lines.push('');
|
|
712
|
+
|
|
713
|
+
if (output.fields.length > 0) {
|
|
714
|
+
lines.push('| Field | Roles |');
|
|
715
|
+
lines.push('|-------|-------|');
|
|
716
|
+
for (const field of output.fields) {
|
|
717
|
+
lines.push(`| ${field.name} | ${field.roles} |`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
lines.push('');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Controllers
|
|
724
|
+
for (const ctrl of mod.controllers) {
|
|
725
|
+
lines.push(`### Controller: ${ctrl.className}`);
|
|
726
|
+
lines.push(`- **File:** \`${ctrl.filePath}\``);
|
|
727
|
+
lines.push(
|
|
728
|
+
`- **Class Roles:** ${ctrl.classRoles.length > 0 ? ctrl.classRoles.map((r) => `\`${r}\``).join(', ') : '*(none)*'}`,
|
|
729
|
+
);
|
|
730
|
+
lines.push('');
|
|
731
|
+
|
|
732
|
+
if (ctrl.methods.length > 0) {
|
|
733
|
+
lines.push('| Method | HTTP | Route | Roles | Effective |');
|
|
734
|
+
lines.push('|--------|------|-------|-------|-----------|');
|
|
735
|
+
for (const m of ctrl.methods) {
|
|
736
|
+
const effective =
|
|
737
|
+
m.roles.length > 0 ? formatRolesDisplay(m.roles) : `${formatRolesDisplay(ctrl.classRoles)} (class)`;
|
|
738
|
+
lines.push(
|
|
739
|
+
`| ${m.name} | ${m.httpMethod} | ${m.route || '/'} | ${formatRolesDisplay(m.roles)} | ${effective} |`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
lines.push('');
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Resolvers
|
|
747
|
+
for (const res of mod.resolvers) {
|
|
748
|
+
lines.push(`### Resolver: ${res.className}`);
|
|
749
|
+
lines.push(`- **File:** \`${res.filePath}\``);
|
|
750
|
+
lines.push(
|
|
751
|
+
`- **Class Roles:** ${res.classRoles.length > 0 ? res.classRoles.map((r) => `\`${r}\``).join(', ') : '*(none)*'}`,
|
|
752
|
+
);
|
|
753
|
+
lines.push('');
|
|
754
|
+
|
|
755
|
+
if (res.methods.length > 0) {
|
|
756
|
+
lines.push('| Method | Type | Roles | Effective |');
|
|
757
|
+
lines.push('|--------|------|-------|-----------|');
|
|
758
|
+
for (const m of res.methods) {
|
|
759
|
+
const effective =
|
|
760
|
+
m.roles.length > 0 ? formatRolesDisplay(m.roles) : `${formatRolesDisplay(res.classRoles)} (class)`;
|
|
761
|
+
lines.push(`| ${m.name} | ${m.httpMethod} | ${formatRolesDisplay(m.roles)} | ${effective} |`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
lines.push('');
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Effective matrix
|
|
768
|
+
const matrix = buildEffectiveMatrix(mod);
|
|
769
|
+
if (matrix.length > 0) {
|
|
770
|
+
lines.push(`### Effective Permissions: ${mod.name}`);
|
|
771
|
+
lines.push('');
|
|
772
|
+
lines.push('| Role | Endpoint Access |');
|
|
773
|
+
lines.push('|------|-----------------|');
|
|
774
|
+
for (const entry of matrix) {
|
|
775
|
+
const endpointList = entry.endpoints.map((e) => `${e.method} ${e.name}`).join(', ');
|
|
776
|
+
lines.push(`| \`${entry.role}\` | ${endpointList || '*(none)*'} |`);
|
|
777
|
+
}
|
|
778
|
+
lines.push('');
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// SubObjects
|
|
783
|
+
if (report.objects.length > 0) {
|
|
784
|
+
lines.push('---');
|
|
785
|
+
lines.push('');
|
|
786
|
+
lines.push('## SubObjects');
|
|
787
|
+
lines.push('');
|
|
788
|
+
|
|
789
|
+
for (const obj of report.objects) {
|
|
790
|
+
lines.push(`### ${obj.className}`);
|
|
791
|
+
lines.push(`- **File:** \`${obj.filePath}\``);
|
|
792
|
+
if (obj.extendsClass) lines.push(`- **Extends:** \`${obj.extendsClass}\``);
|
|
793
|
+
lines.push(
|
|
794
|
+
`- **Class Restriction:** ${obj.classRestriction.length > 0 ? obj.classRestriction.map((r) => `\`${r}\``).join(', ') : '*(none)*'}`,
|
|
795
|
+
);
|
|
796
|
+
lines.push('');
|
|
797
|
+
|
|
798
|
+
if (obj.fields.length > 0) {
|
|
799
|
+
lines.push('| Field | Roles | Source |');
|
|
800
|
+
lines.push('|-------|-------|--------|');
|
|
801
|
+
for (const field of obj.fields) {
|
|
802
|
+
const source = field.inherited ? 'inherited' : 'local';
|
|
803
|
+
lines.push(`| ${field.name} | ${field.roles} | ${source} |`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
lines.push('');
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return lines.join('\n');
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Parse endpoint permissions (controller/resolver) from a source file.
|
|
815
|
+
*/
|
|
816
|
+
export function parseEndpointPermissions(sourceFile: any, filePath: string): EndpointPermissions | undefined {
|
|
817
|
+
const classes = sourceFile.getClasses();
|
|
818
|
+
if (classes.length === 0) return undefined;
|
|
819
|
+
|
|
820
|
+
const classDecl = classes[0];
|
|
821
|
+
const className = classDecl.getName() || 'Unknown';
|
|
822
|
+
const classRoles = parseRolesDecorator(classDecl);
|
|
823
|
+
|
|
824
|
+
let controllerPrefix = '';
|
|
825
|
+
const controllerDeco = classDecl.getDecorator('Controller');
|
|
826
|
+
if (controllerDeco) {
|
|
827
|
+
const args = controllerDeco.getArguments();
|
|
828
|
+
if (args.length > 0) controllerPrefix = args[0].getText().replace(/['"]/g, '');
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const methods: MethodPermission[] = [];
|
|
832
|
+
for (const method of classDecl.getMethods()) {
|
|
833
|
+
const methodName = method.getName();
|
|
834
|
+
if (methodName.startsWith('_') || ['onModuleDestroy', 'onModuleInit'].includes(methodName)) continue;
|
|
835
|
+
|
|
836
|
+
const methodRoles = parseRolesDecorator(method);
|
|
837
|
+
|
|
838
|
+
for (const httpDeco of ['Delete', 'Get', 'Patch', 'Post', 'Put']) {
|
|
839
|
+
const deco = method.getDecorator(httpDeco);
|
|
840
|
+
if (deco) {
|
|
841
|
+
const args = deco.getArguments();
|
|
842
|
+
const route = args.length > 0 ? args[0].getText().replace(/['"]/g, '') : '/';
|
|
843
|
+
const fullRoute = controllerPrefix
|
|
844
|
+
? `/${controllerPrefix}/${route}`.replace(/\/+/g, '/')
|
|
845
|
+
: `/${route}`.replace(/\/+/g, '/');
|
|
846
|
+
methods.push({ httpMethod: httpDeco.toUpperCase(), name: methodName, roles: methodRoles, route: fullRoute });
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
for (const gqlDeco of ['Mutation', 'Query', 'Subscription']) {
|
|
851
|
+
const deco = method.getDecorator(gqlDeco);
|
|
852
|
+
if (deco) {
|
|
853
|
+
methods.push({ httpMethod: gqlDeco, name: methodName, roles: methodRoles });
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
return { className, classRoles, filePath, methods };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Parse field permissions from a class property, checking @UnifiedField and @Restricted decorators.
|
|
863
|
+
*/
|
|
864
|
+
export function parseFieldPermission(prop: any): FieldPermission {
|
|
865
|
+
const fieldName = prop.getName();
|
|
866
|
+
let roles = '*(none)*';
|
|
867
|
+
let description: string | undefined;
|
|
868
|
+
|
|
869
|
+
const unifiedField = prop.getDecorator('UnifiedField');
|
|
870
|
+
if (unifiedField) {
|
|
871
|
+
const args = unifiedField.getArguments();
|
|
872
|
+
if (args.length > 0) {
|
|
873
|
+
const optionsArg = args[0];
|
|
874
|
+
if (optionsArg.getKind() === SyntaxKind.ObjectLiteralExpression) {
|
|
875
|
+
const objLit = optionsArg.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
876
|
+
|
|
877
|
+
const rolesProp = objLit?.getProperty('roles');
|
|
878
|
+
if (rolesProp) {
|
|
879
|
+
const init = rolesProp.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
880
|
+
if (init) {
|
|
881
|
+
const rolesText = init.getText();
|
|
882
|
+
if (rolesText.startsWith('[')) {
|
|
883
|
+
const inner = rolesText.slice(1, -1);
|
|
884
|
+
const roleList = inner.split(',').map((r: string) => formatRole(r.trim()));
|
|
885
|
+
roles = roleList.map((r: string) => `\`${r}\``).join(', ');
|
|
886
|
+
} else {
|
|
887
|
+
roles = `\`${formatRole(rolesText)}\``;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const descProp = objLit?.getProperty('description');
|
|
893
|
+
if (descProp) {
|
|
894
|
+
const init = descProp.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
895
|
+
if (init) description = init.getText().replace(/^['"]|['"]$/g, '');
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (roles === '*(none)*') {
|
|
902
|
+
const restricted = prop.getDecorator('Restricted');
|
|
903
|
+
if (restricted) {
|
|
904
|
+
const args = restricted.getArguments().map((a: any) => a.getText());
|
|
905
|
+
if (args.length > 0) {
|
|
906
|
+
const roleList = extractDecoratorRoles(args);
|
|
907
|
+
roles = roleList.map((r: string) => `\`${r}\``).join(', ');
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return { description, name: fieldName, roles };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Parse file permissions (model/input/output) from a source file.
|
|
917
|
+
*/
|
|
918
|
+
export function parseFilePermissions(sourceFile: any, filePath: string, isModel: boolean): FilePermissions | undefined {
|
|
919
|
+
const classes = sourceFile.getClasses();
|
|
920
|
+
if (classes.length === 0) return undefined;
|
|
921
|
+
|
|
922
|
+
const classDecl = classes[0];
|
|
923
|
+
const className = classDecl.getName() || 'Unknown';
|
|
924
|
+
const extendsExpr = classDecl.getExtends();
|
|
925
|
+
const extendsClass = extendsExpr?.getText()?.replace(/<.*>/, '') || undefined;
|
|
926
|
+
const classRestriction = parseRestrictedDecorator(classDecl);
|
|
927
|
+
const securityCheck = isModel ? extractSecurityCheckInfo(classDecl) : undefined;
|
|
928
|
+
|
|
929
|
+
const fields: FieldPermission[] = [];
|
|
930
|
+
for (const prop of classDecl.getProperties()) {
|
|
931
|
+
fields.push(parseFieldPermission(prop));
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return { className, classRestriction, extendsClass, fields, filePath, securityCheck };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** Parse @Restricted decorator roles from a class or property. */
|
|
938
|
+
export function parseRestrictedDecorator(node: any): string[] {
|
|
939
|
+
const restricted = node.getDecorator('Restricted');
|
|
940
|
+
if (!restricted) return [];
|
|
941
|
+
const args = restricted.getArguments().map((a: any) => a.getText());
|
|
942
|
+
return extractDecoratorRoles(args);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Parse @Roles decorator roles from a class or method. */
|
|
946
|
+
export function parseRolesDecorator(node: any): string[] {
|
|
947
|
+
const roles = node.getDecorator('Roles');
|
|
948
|
+
if (!roles) return [];
|
|
949
|
+
const args = roles.getArguments().map((a: any) => a.getText());
|
|
950
|
+
return extractDecoratorRoles(args);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Resolve inherited fields from base classes in node_modules/@lenne.tech/nest-server.
|
|
955
|
+
* Recursively walks the inheritance chain.
|
|
956
|
+
*
|
|
957
|
+
* Requires base classes to be preloaded via preloadBaseClasses() (called once in scanPermissions).
|
|
958
|
+
*/
|
|
959
|
+
export function resolveInheritedFields(project: Project, classDecl: any): FieldPermission[] {
|
|
960
|
+
const inherited: FieldPermission[] = [];
|
|
961
|
+
const extendsExpr = classDecl.getExtends();
|
|
962
|
+
if (!extendsExpr) return inherited;
|
|
963
|
+
|
|
964
|
+
const baseClassName = extendsExpr.getText().replace(/<.*>/, '');
|
|
965
|
+
|
|
966
|
+
// Search already-loaded source files (preloaded in scanPermissions)
|
|
967
|
+
for (const sf of project.getSourceFiles()) {
|
|
968
|
+
for (const cls of sf.getClasses()) {
|
|
969
|
+
if (cls.getName() === baseClassName) {
|
|
970
|
+
for (const prop of cls.getProperties()) {
|
|
971
|
+
const field = parseFieldPermission(prop);
|
|
972
|
+
field.inherited = true;
|
|
973
|
+
inherited.push(field);
|
|
974
|
+
}
|
|
975
|
+
const parentFields = resolveInheritedFields(project, cls);
|
|
976
|
+
inherited.push(...parentFields);
|
|
977
|
+
return inherited;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return inherited;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
986
|
+
// Internal helpers
|
|
987
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
988
|
+
|
|
989
|
+
function preloadBaseClasses(project: Project, projectPath: string): void {
|
|
990
|
+
const nestServerPaths = [
|
|
991
|
+
join(projectPath, 'node_modules', '@lenne.tech', 'nest-server', 'src'),
|
|
992
|
+
join(projectPath, 'node_modules', '@lenne.tech', 'nest-server', 'dist'),
|
|
993
|
+
];
|
|
994
|
+
for (const basePath of nestServerPaths) {
|
|
995
|
+
try {
|
|
996
|
+
if (existsSync(basePath)) {
|
|
997
|
+
project.addSourceFilesAtPaths(join(basePath, '**', '*.ts'));
|
|
998
|
+
}
|
|
999
|
+
} catch {
|
|
1000
|
+
// Base path not available
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function listDir(dir: string): string[] {
|
|
1006
|
+
try {
|
|
1007
|
+
return readdirSync(dir);
|
|
1008
|
+
} catch {
|
|
1009
|
+
return [];
|
|
1010
|
+
}
|
|
1011
|
+
}
|