create-forgeon 0.2.6 → 0.2.8
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/package.json +1 -1
- package/src/modules/executor.mjs +2 -0
- package/src/modules/executor.test.mjs +178 -1
- package/src/modules/i18n.mjs +11 -0
- package/src/modules/rbac.mjs +324 -0
- package/src/modules/registry.mjs +18 -0
- package/src/modules/sync-integrations.mjs +143 -0
- package/src/run-add-module.mjs +1 -1
- package/templates/base/README.md +1 -0
- package/templates/base/docs/AI/MODULE_SPEC.md +8 -4
- package/templates/base/scripts/forgeon-sync-integrations.mjs +118 -0
- package/templates/module-fragments/rbac/00_title.md +1 -0
- package/templates/module-fragments/rbac/10_overview.md +6 -0
- package/templates/module-fragments/rbac/20_idea.md +9 -0
- package/templates/module-fragments/rbac/30_what_it_adds.md +11 -0
- package/templates/module-fragments/rbac/40_how_it_works.md +20 -0
- package/templates/module-fragments/rbac/50_how_to_use.md +19 -0
- package/templates/module-fragments/rbac/60_configuration.md +9 -0
- package/templates/module-fragments/rbac/70_operational_notes.md +10 -0
- package/templates/module-fragments/rbac/90_status_implemented.md +3 -0
- package/templates/module-presets/rbac/packages/rbac/package.json +19 -0
- package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.guard.ts +91 -0
- package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.module.ts +8 -0
- package/templates/module-presets/rbac/packages/rbac/src/index.ts +6 -0
- package/templates/module-presets/rbac/packages/rbac/src/rbac.constants.ts +4 -0
- package/templates/module-presets/rbac/packages/rbac/src/rbac.decorators.ts +11 -0
- package/templates/module-presets/rbac/packages/rbac/src/rbac.helpers.ts +31 -0
- package/templates/module-presets/rbac/packages/rbac/src/rbac.types.ts +7 -0
- package/templates/module-presets/rbac/packages/rbac/tsconfig.json +9 -0
package/package.json
CHANGED
package/src/modules/executor.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { applyI18nModule } from './i18n.mjs';
|
|
|
7
7
|
import { applyJwtAuthModule } from './jwt-auth.mjs';
|
|
8
8
|
import { applyLoggerModule } from './logger.mjs';
|
|
9
9
|
import { applyRateLimitModule } from './rate-limit.mjs';
|
|
10
|
+
import { applyRbacModule } from './rbac.mjs';
|
|
10
11
|
import { applySwaggerModule } from './swagger.mjs';
|
|
11
12
|
|
|
12
13
|
function ensureForgeonLikeProject(targetRoot) {
|
|
@@ -31,6 +32,7 @@ const MODULE_APPLIERS = {
|
|
|
31
32
|
'jwt-auth': applyJwtAuthModule,
|
|
32
33
|
logger: applyLoggerModule,
|
|
33
34
|
'rate-limit': applyRateLimitModule,
|
|
35
|
+
rbac: applyRbacModule,
|
|
34
36
|
swagger: applySwaggerModule,
|
|
35
37
|
};
|
|
36
38
|
|
|
@@ -5,7 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { addModule } from './executor.mjs';
|
|
8
|
-
import { syncIntegrations } from './sync-integrations.mjs';
|
|
8
|
+
import { scanIntegrations, syncIntegrations } from './sync-integrations.mjs';
|
|
9
9
|
import { scaffoldProject } from '../core/scaffold.mjs';
|
|
10
10
|
|
|
11
11
|
function mkTmp(prefix) {
|
|
@@ -81,6 +81,37 @@ function assertRateLimitWiring(projectRoot) {
|
|
|
81
81
|
assert.match(readme, /## Rate Limit Module/);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
function assertRbacWiring(projectRoot) {
|
|
85
|
+
const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
|
|
86
|
+
assert.match(appModule, /ForgeonRbacModule/);
|
|
87
|
+
|
|
88
|
+
const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
|
|
89
|
+
assert.match(apiPackage, /@forgeon\/rbac/);
|
|
90
|
+
assert.match(apiPackage, /pnpm --filter @forgeon\/rbac build/);
|
|
91
|
+
|
|
92
|
+
const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
|
|
93
|
+
assert.match(apiDockerfile, /COPY packages\/rbac\/package\.json packages\/rbac\/package\.json/);
|
|
94
|
+
assert.match(apiDockerfile, /COPY packages\/rbac packages\/rbac/);
|
|
95
|
+
assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/rbac build/);
|
|
96
|
+
|
|
97
|
+
const healthController = fs.readFileSync(
|
|
98
|
+
path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
|
|
99
|
+
'utf8',
|
|
100
|
+
);
|
|
101
|
+
assert.match(healthController, /UseGuards/);
|
|
102
|
+
assert.match(healthController, /ForgeonRbacGuard/);
|
|
103
|
+
assert.match(healthController, /@Get\('rbac'\)/);
|
|
104
|
+
assert.match(healthController, /@Permissions\('health\.rbac'\)/);
|
|
105
|
+
|
|
106
|
+
const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
|
|
107
|
+
assert.match(appTsx, /Check RBAC access/);
|
|
108
|
+
assert.match(appTsx, /RBAC probe response/);
|
|
109
|
+
assert.match(appTsx, /x-forgeon-permissions/);
|
|
110
|
+
|
|
111
|
+
const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
|
|
112
|
+
assert.match(readme, /## RBAC \/ Permissions Module/);
|
|
113
|
+
}
|
|
114
|
+
|
|
84
115
|
function assertJwtAuthWiring(projectRoot, withPrismaStore) {
|
|
85
116
|
const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
|
|
86
117
|
assert.match(apiPackage, /@forgeon\/auth-api/);
|
|
@@ -603,6 +634,41 @@ describe('addModule', () => {
|
|
|
603
634
|
}
|
|
604
635
|
});
|
|
605
636
|
|
|
637
|
+
it('applies rbac module on top of scaffold without i18n', () => {
|
|
638
|
+
const targetRoot = mkTmp('forgeon-module-rbac-');
|
|
639
|
+
const projectRoot = path.join(targetRoot, 'demo-rbac');
|
|
640
|
+
const templateRoot = path.join(packageRoot, 'templates', 'base');
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
scaffoldProject({
|
|
644
|
+
templateRoot,
|
|
645
|
+
packageRoot,
|
|
646
|
+
targetRoot: projectRoot,
|
|
647
|
+
projectName: 'demo-rbac',
|
|
648
|
+
frontend: 'react',
|
|
649
|
+
db: 'prisma',
|
|
650
|
+
dbPrismaEnabled: false,
|
|
651
|
+
i18nEnabled: false,
|
|
652
|
+
proxy: 'caddy',
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const result = addModule({
|
|
656
|
+
moduleId: 'rbac',
|
|
657
|
+
targetRoot: projectRoot,
|
|
658
|
+
packageRoot,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
assert.equal(result.applied, true);
|
|
662
|
+
assertRbacWiring(projectRoot);
|
|
663
|
+
|
|
664
|
+
const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
|
|
665
|
+
assert.match(moduleDoc, /## Idea \/ Why/);
|
|
666
|
+
assert.match(moduleDoc, /## How It Works/);
|
|
667
|
+
} finally {
|
|
668
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
606
672
|
it('applies swagger module on top of scaffold without i18n', () => {
|
|
607
673
|
const targetRoot = mkTmp('forgeon-module-swagger-');
|
|
608
674
|
const projectRoot = path.join(targetRoot, 'demo-swagger');
|
|
@@ -1079,6 +1145,80 @@ describe('addModule', () => {
|
|
|
1079
1145
|
}
|
|
1080
1146
|
});
|
|
1081
1147
|
|
|
1148
|
+
it('detects and applies jwt-auth + rbac claims integration explicitly', () => {
|
|
1149
|
+
const targetRoot = mkTmp('forgeon-module-jwt-rbac-');
|
|
1150
|
+
const projectRoot = path.join(targetRoot, 'demo-jwt-rbac');
|
|
1151
|
+
const templateRoot = path.join(packageRoot, 'templates', 'base');
|
|
1152
|
+
|
|
1153
|
+
try {
|
|
1154
|
+
scaffoldProject({
|
|
1155
|
+
templateRoot,
|
|
1156
|
+
packageRoot,
|
|
1157
|
+
targetRoot: projectRoot,
|
|
1158
|
+
projectName: 'demo-jwt-rbac',
|
|
1159
|
+
frontend: 'react',
|
|
1160
|
+
db: 'prisma',
|
|
1161
|
+
dbPrismaEnabled: false,
|
|
1162
|
+
i18nEnabled: false,
|
|
1163
|
+
proxy: 'caddy',
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
addModule({
|
|
1167
|
+
moduleId: 'rbac',
|
|
1168
|
+
targetRoot: projectRoot,
|
|
1169
|
+
packageRoot,
|
|
1170
|
+
});
|
|
1171
|
+
addModule({
|
|
1172
|
+
moduleId: 'jwt-auth',
|
|
1173
|
+
targetRoot: projectRoot,
|
|
1174
|
+
packageRoot,
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
const scan = scanIntegrations({
|
|
1178
|
+
targetRoot: projectRoot,
|
|
1179
|
+
relatedModuleId: 'jwt-auth',
|
|
1180
|
+
});
|
|
1181
|
+
assert.equal(scan.groups.some((group) => group.id === 'auth-rbac-claims'), true);
|
|
1182
|
+
|
|
1183
|
+
const syncResult = syncIntegrations({
|
|
1184
|
+
targetRoot: projectRoot,
|
|
1185
|
+
packageRoot,
|
|
1186
|
+
groupIds: ['auth-rbac-claims'],
|
|
1187
|
+
});
|
|
1188
|
+
const claimsPair = syncResult.summary.find((item) => item.id === 'auth-rbac-claims');
|
|
1189
|
+
assert.ok(claimsPair);
|
|
1190
|
+
assert.equal(claimsPair.result.applied, true);
|
|
1191
|
+
|
|
1192
|
+
const authContracts = fs.readFileSync(
|
|
1193
|
+
path.join(projectRoot, 'packages', 'auth-contracts', 'src', 'index.ts'),
|
|
1194
|
+
'utf8',
|
|
1195
|
+
);
|
|
1196
|
+
assert.match(authContracts, /permissions\?: string\[\];/);
|
|
1197
|
+
|
|
1198
|
+
const authService = fs.readFileSync(
|
|
1199
|
+
path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.service.ts'),
|
|
1200
|
+
'utf8',
|
|
1201
|
+
);
|
|
1202
|
+
assert.match(authService, /permissions: \['health\.rbac'\]/);
|
|
1203
|
+
assert.match(authService, /permissions: user\.permissions,/);
|
|
1204
|
+
assert.match(
|
|
1205
|
+
authService,
|
|
1206
|
+
/permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
|
|
1207
|
+
);
|
|
1208
|
+
|
|
1209
|
+
const authController = fs.readFileSync(
|
|
1210
|
+
path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.controller.ts'),
|
|
1211
|
+
'utf8',
|
|
1212
|
+
);
|
|
1213
|
+
assert.match(
|
|
1214
|
+
authController,
|
|
1215
|
+
/permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
|
|
1216
|
+
);
|
|
1217
|
+
} finally {
|
|
1218
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1082
1222
|
it('applies logger then jwt-auth on db/i18n-disabled scaffold without breaking health controller syntax', () => {
|
|
1083
1223
|
const targetRoot = mkTmp('forgeon-module-jwt-nodb-noi18n-');
|
|
1084
1224
|
const projectRoot = path.join(targetRoot, 'demo-jwt-nodb-noi18n');
|
|
@@ -1255,6 +1395,43 @@ describe('addModule', () => {
|
|
|
1255
1395
|
}
|
|
1256
1396
|
});
|
|
1257
1397
|
|
|
1398
|
+
it('keeps rbac wiring valid after mixed module installation order', () => {
|
|
1399
|
+
const targetRoot = mkTmp('forgeon-module-rbac-order-');
|
|
1400
|
+
const projectRoot = path.join(targetRoot, 'demo-rbac-order');
|
|
1401
|
+
const templateRoot = path.join(packageRoot, 'templates', 'base');
|
|
1402
|
+
|
|
1403
|
+
try {
|
|
1404
|
+
scaffoldProject({
|
|
1405
|
+
templateRoot,
|
|
1406
|
+
packageRoot,
|
|
1407
|
+
targetRoot: projectRoot,
|
|
1408
|
+
projectName: 'demo-rbac-order',
|
|
1409
|
+
frontend: 'react',
|
|
1410
|
+
db: 'prisma',
|
|
1411
|
+
dbPrismaEnabled: false,
|
|
1412
|
+
i18nEnabled: false,
|
|
1413
|
+
proxy: 'caddy',
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
for (const moduleId of ['jwt-auth', 'logger', 'rate-limit', 'rbac', 'swagger', 'i18n', 'db-prisma']) {
|
|
1417
|
+
addModule({ moduleId, targetRoot: projectRoot, packageRoot });
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
assertRbacWiring(projectRoot);
|
|
1421
|
+
|
|
1422
|
+
const healthController = fs.readFileSync(
|
|
1423
|
+
path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
|
|
1424
|
+
'utf8',
|
|
1425
|
+
);
|
|
1426
|
+
const classStart = healthController.indexOf('export class HealthController {');
|
|
1427
|
+
const classEnd = healthController.lastIndexOf('\n}');
|
|
1428
|
+
const rbacProbe = healthController.indexOf("@Get('rbac')");
|
|
1429
|
+
assert.equal(rbacProbe > classStart && rbacProbe < classEnd, true);
|
|
1430
|
+
} finally {
|
|
1431
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
1432
|
+
}
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1258
1435
|
it('keeps db-prisma wiring across module installation orders', () => {
|
|
1259
1436
|
const sequences = [
|
|
1260
1437
|
['logger', 'swagger', 'i18n'],
|
package/src/modules/i18n.mjs
CHANGED
|
@@ -422,6 +422,7 @@ function restoreKnownWebProbes(targetRoot, previousAppContent) {
|
|
|
422
422
|
return;
|
|
423
423
|
}
|
|
424
424
|
const anchors = [
|
|
425
|
+
' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
|
|
425
426
|
' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
|
|
426
427
|
' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
|
|
427
428
|
' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
|
|
@@ -458,6 +459,7 @@ function restoreKnownWebProbes(targetRoot, previousAppContent) {
|
|
|
458
459
|
return;
|
|
459
460
|
}
|
|
460
461
|
const anchors = [
|
|
462
|
+
" {renderResult('RBAC probe response', rbacProbeResult)}",
|
|
461
463
|
" {renderResult('Rate limit probe response', rateLimitProbeResult)}",
|
|
462
464
|
" {renderResult('Auth probe response', authProbeResult)}",
|
|
463
465
|
" {renderResult('DB probe response', dbProbeResult)}",
|
|
@@ -498,6 +500,15 @@ function restoreKnownWebProbes(targetRoot, previousAppContent) {
|
|
|
498
500
|
ensureProbeResult(" {renderResult('Rate limit probe response', rateLimitProbeResult)}");
|
|
499
501
|
}
|
|
500
502
|
|
|
503
|
+
if (previousAppContent.includes('Check RBAC access')) {
|
|
504
|
+
ensureProbeState(' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);');
|
|
505
|
+
ensureProbeButton(
|
|
506
|
+
'Check RBAC access',
|
|
507
|
+
" <button\n onClick={() =>\n runProbe(setRbacProbeResult, '/health/rbac', {\n headers: { 'x-forgeon-permissions': 'health.rbac' },\n })\n }\n >\n Check RBAC access\n </button>",
|
|
508
|
+
);
|
|
509
|
+
ensureProbeResult(" {renderResult('RBAC probe response', rbacProbeResult)}");
|
|
510
|
+
}
|
|
511
|
+
|
|
501
512
|
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
502
513
|
}
|
|
503
514
|
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { copyRecursive, writeJson } from '../utils/fs.mjs';
|
|
4
|
+
import {
|
|
5
|
+
ensureBuildSteps,
|
|
6
|
+
ensureClassMember,
|
|
7
|
+
ensureDependency,
|
|
8
|
+
ensureImportLine,
|
|
9
|
+
ensureLineAfter,
|
|
10
|
+
ensureLineBefore,
|
|
11
|
+
ensureNestCommonImport,
|
|
12
|
+
} from './shared/patch-utils.mjs';
|
|
13
|
+
|
|
14
|
+
function copyFromPreset(packageRoot, targetRoot, relativePath) {
|
|
15
|
+
const source = path.join(packageRoot, 'templates', 'module-presets', 'rbac', relativePath);
|
|
16
|
+
if (!fs.existsSync(source)) {
|
|
17
|
+
throw new Error(`Missing rbac preset template: ${source}`);
|
|
18
|
+
}
|
|
19
|
+
const destination = path.join(targetRoot, relativePath);
|
|
20
|
+
copyRecursive(source, destination);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function patchApiPackage(targetRoot) {
|
|
24
|
+
const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
|
|
25
|
+
if (!fs.existsSync(packagePath)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
30
|
+
ensureDependency(packageJson, '@forgeon/rbac', 'workspace:*');
|
|
31
|
+
ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/rbac build']);
|
|
32
|
+
writeJson(packagePath, packageJson);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function patchAppModule(targetRoot) {
|
|
36
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
|
|
37
|
+
if (!fs.existsSync(filePath)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
42
|
+
if (!content.includes("from '@forgeon/rbac';")) {
|
|
43
|
+
if (content.includes("import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';")) {
|
|
44
|
+
content = ensureLineAfter(
|
|
45
|
+
content,
|
|
46
|
+
"import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';",
|
|
47
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
48
|
+
);
|
|
49
|
+
} else if (
|
|
50
|
+
content.includes("import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';")
|
|
51
|
+
) {
|
|
52
|
+
content = ensureLineAfter(
|
|
53
|
+
content,
|
|
54
|
+
"import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
|
|
55
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
56
|
+
);
|
|
57
|
+
} else if (
|
|
58
|
+
content.includes("import { ForgeonRateLimitModule, rateLimitConfig, rateLimitEnvSchema } from '@forgeon/rate-limit';")
|
|
59
|
+
) {
|
|
60
|
+
content = ensureLineAfter(
|
|
61
|
+
content,
|
|
62
|
+
"import { ForgeonRateLimitModule, rateLimitConfig, rateLimitEnvSchema } from '@forgeon/rate-limit';",
|
|
63
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
64
|
+
);
|
|
65
|
+
} else if (
|
|
66
|
+
content.includes("import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';")
|
|
67
|
+
) {
|
|
68
|
+
content = ensureLineAfter(
|
|
69
|
+
content,
|
|
70
|
+
"import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';",
|
|
71
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
72
|
+
);
|
|
73
|
+
} else if (
|
|
74
|
+
content.includes("import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';")
|
|
75
|
+
) {
|
|
76
|
+
content = ensureLineAfter(
|
|
77
|
+
content,
|
|
78
|
+
"import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';",
|
|
79
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
80
|
+
);
|
|
81
|
+
} else if (
|
|
82
|
+
content.includes("import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';")
|
|
83
|
+
) {
|
|
84
|
+
content = ensureLineAfter(
|
|
85
|
+
content,
|
|
86
|
+
"import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';",
|
|
87
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
88
|
+
);
|
|
89
|
+
} else {
|
|
90
|
+
content = ensureLineAfter(
|
|
91
|
+
content,
|
|
92
|
+
"import { ConfigModule } from '@nestjs/config';",
|
|
93
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!content.includes(' ForgeonRbacModule,')) {
|
|
99
|
+
if (content.includes(' ForgeonI18nModule.register({')) {
|
|
100
|
+
content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonRbacModule,');
|
|
101
|
+
} else if (content.includes(' ForgeonAuthModule.register({')) {
|
|
102
|
+
content = ensureLineBefore(content, ' ForgeonAuthModule.register({', ' ForgeonRbacModule,');
|
|
103
|
+
} else if (content.includes(' ForgeonAuthModule.register(),')) {
|
|
104
|
+
content = ensureLineBefore(content, ' ForgeonAuthModule.register(),', ' ForgeonRbacModule,');
|
|
105
|
+
} else if (content.includes(' ForgeonRateLimitModule,')) {
|
|
106
|
+
content = ensureLineAfter(content, ' ForgeonRateLimitModule,', ' ForgeonRbacModule,');
|
|
107
|
+
} else if (content.includes(' DbPrismaModule,')) {
|
|
108
|
+
content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonRbacModule,');
|
|
109
|
+
} else if (content.includes(' ForgeonLoggerModule,')) {
|
|
110
|
+
content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonRbacModule,');
|
|
111
|
+
} else if (content.includes(' ForgeonSwaggerModule,')) {
|
|
112
|
+
content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonRbacModule,');
|
|
113
|
+
} else {
|
|
114
|
+
content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonRbacModule,');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function patchHealthController(targetRoot) {
|
|
122
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
|
|
123
|
+
if (!fs.existsSync(filePath)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
128
|
+
content = ensureNestCommonImport(content, 'UseGuards');
|
|
129
|
+
|
|
130
|
+
if (!content.includes("from '@forgeon/rbac';")) {
|
|
131
|
+
content = ensureImportLine(
|
|
132
|
+
content,
|
|
133
|
+
"import { ForgeonRbacGuard, Permissions } from '@forgeon/rbac';",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!content.includes("@Get('rbac')")) {
|
|
138
|
+
const method = `
|
|
139
|
+
@Get('rbac')
|
|
140
|
+
@UseGuards(ForgeonRbacGuard)
|
|
141
|
+
@Permissions('health.rbac')
|
|
142
|
+
getRbacProbe() {
|
|
143
|
+
return {
|
|
144
|
+
status: 'ok',
|
|
145
|
+
feature: 'rbac',
|
|
146
|
+
granted: true,
|
|
147
|
+
requiredPermission: 'health.rbac',
|
|
148
|
+
hint: 'Send x-forgeon-permissions: health.rbac to pass this check manually.',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
`;
|
|
152
|
+
const beforeNeedle = content.includes("@Get('rate-limit')")
|
|
153
|
+
? "@Get('rate-limit')"
|
|
154
|
+
: content.includes('private translate(')
|
|
155
|
+
? 'private translate('
|
|
156
|
+
: '';
|
|
157
|
+
content = ensureClassMember(content, 'HealthController', method, { beforeNeedle });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function patchWebApp(targetRoot) {
|
|
164
|
+
const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
|
|
165
|
+
if (!fs.existsSync(filePath)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
170
|
+
content = content
|
|
171
|
+
.replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
|
|
172
|
+
.replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
|
|
173
|
+
.replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
|
|
174
|
+
.replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
|
|
175
|
+
|
|
176
|
+
if (!content.includes('rbacProbeResult')) {
|
|
177
|
+
const stateAnchors = [
|
|
178
|
+
' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
|
|
179
|
+
' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
|
|
180
|
+
' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
|
|
181
|
+
' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
|
|
182
|
+
];
|
|
183
|
+
const stateAnchor = stateAnchors.find((line) => content.includes(line));
|
|
184
|
+
if (stateAnchor) {
|
|
185
|
+
content = ensureLineAfter(
|
|
186
|
+
content,
|
|
187
|
+
stateAnchor,
|
|
188
|
+
' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!content.includes('Check RBAC access')) {
|
|
194
|
+
const useProxyPath = content.includes("runProbe(setHealthResult, '/health')");
|
|
195
|
+
const probePath = useProxyPath ? '/health/rbac' : '/api/health/rbac';
|
|
196
|
+
const button = ` <button\n onClick={() =>\n runProbe(setRbacProbeResult, '${probePath}', {\n headers: { 'x-forgeon-permissions': 'health.rbac' },\n })\n }\n >\n Check RBAC access\n </button>`;
|
|
197
|
+
|
|
198
|
+
const actionsStart = content.indexOf('<div className="actions">');
|
|
199
|
+
if (actionsStart >= 0) {
|
|
200
|
+
const actionsEnd = content.indexOf('\n </div>', actionsStart);
|
|
201
|
+
if (actionsEnd >= 0) {
|
|
202
|
+
content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!content.includes("{renderResult('RBAC probe response', rbacProbeResult)}")) {
|
|
208
|
+
const resultLine = " {renderResult('RBAC probe response', rbacProbeResult)}";
|
|
209
|
+
const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
|
|
210
|
+
if (content.includes(networkLine)) {
|
|
211
|
+
content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
|
|
212
|
+
} else {
|
|
213
|
+
const anchors = [
|
|
214
|
+
" {renderResult('Rate limit probe response', rateLimitProbeResult)}",
|
|
215
|
+
" {renderResult('Auth probe response', authProbeResult)}",
|
|
216
|
+
" {renderResult('DB probe response', dbProbeResult)}",
|
|
217
|
+
" {renderResult('Validation probe response', validationProbeResult)}",
|
|
218
|
+
];
|
|
219
|
+
const anchor = anchors.find((line) => content.includes(line));
|
|
220
|
+
if (anchor) {
|
|
221
|
+
content = ensureLineAfter(content, anchor, resultLine);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function patchApiDockerfile(targetRoot) {
|
|
230
|
+
const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
|
|
231
|
+
if (!fs.existsSync(dockerfilePath)) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
|
|
236
|
+
const packageAnchors = [
|
|
237
|
+
'COPY packages/auth-api/package.json packages/auth-api/package.json',
|
|
238
|
+
'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
|
|
239
|
+
'COPY packages/logger/package.json packages/logger/package.json',
|
|
240
|
+
'COPY packages/swagger/package.json packages/swagger/package.json',
|
|
241
|
+
'COPY packages/i18n/package.json packages/i18n/package.json',
|
|
242
|
+
'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
|
|
243
|
+
'COPY packages/core/package.json packages/core/package.json',
|
|
244
|
+
];
|
|
245
|
+
const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
|
|
246
|
+
content = ensureLineAfter(content, packageAnchor, 'COPY packages/rbac/package.json packages/rbac/package.json');
|
|
247
|
+
|
|
248
|
+
const sourceAnchors = [
|
|
249
|
+
'COPY packages/auth-api packages/auth-api',
|
|
250
|
+
'COPY packages/rate-limit packages/rate-limit',
|
|
251
|
+
'COPY packages/logger packages/logger',
|
|
252
|
+
'COPY packages/swagger packages/swagger',
|
|
253
|
+
'COPY packages/i18n packages/i18n',
|
|
254
|
+
'COPY packages/db-prisma packages/db-prisma',
|
|
255
|
+
'COPY packages/core packages/core',
|
|
256
|
+
];
|
|
257
|
+
const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
|
|
258
|
+
content = ensureLineAfter(content, sourceAnchor, 'COPY packages/rbac packages/rbac');
|
|
259
|
+
|
|
260
|
+
content = content.replace(/^RUN pnpm --filter @forgeon\/rbac build\r?\n?/gm, '');
|
|
261
|
+
const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
|
|
262
|
+
? 'RUN pnpm --filter @forgeon/api prisma:generate'
|
|
263
|
+
: 'RUN pnpm --filter @forgeon/api build';
|
|
264
|
+
content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/rbac build');
|
|
265
|
+
|
|
266
|
+
fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function patchReadme(targetRoot) {
|
|
270
|
+
const readmePath = path.join(targetRoot, 'README.md');
|
|
271
|
+
if (!fs.existsSync(readmePath)) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const marker = '## RBAC / Permissions Module';
|
|
276
|
+
let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
277
|
+
if (content.includes(marker)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const section = `## RBAC / Permissions Module
|
|
282
|
+
|
|
283
|
+
The rbac add-module provides a minimal authorization layer for role and permission checks.
|
|
284
|
+
|
|
285
|
+
What it adds:
|
|
286
|
+
- \`@Roles(...)\` and \`@Permissions(...)\` decorators
|
|
287
|
+
- \`ForgeonRbacGuard\`
|
|
288
|
+
- simple helper functions for role / permission checks
|
|
289
|
+
- a protected probe route: \`GET /api/health/rbac\`
|
|
290
|
+
|
|
291
|
+
How it works:
|
|
292
|
+
- the guard reads metadata from decorators
|
|
293
|
+
- it checks \`request.user\` first
|
|
294
|
+
- if no user payload is present, it can also read test headers:
|
|
295
|
+
- \`x-forgeon-roles\`
|
|
296
|
+
- \`x-forgeon-permissions\`
|
|
297
|
+
|
|
298
|
+
How to verify:
|
|
299
|
+
- the generated frontend button sends \`x-forgeon-permissions: health.rbac\` and should return \`200\`
|
|
300
|
+
- the same route without that header should return \`403\`
|
|
301
|
+
|
|
302
|
+
Current scope:
|
|
303
|
+
- no policy engine
|
|
304
|
+
- no database-backed role store
|
|
305
|
+
- no frontend route-guard layer in this module`;
|
|
306
|
+
|
|
307
|
+
if (content.includes('## Prisma In Docker Start')) {
|
|
308
|
+
content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
|
|
309
|
+
} else {
|
|
310
|
+
content = `${content.trimEnd()}\n\n${section}\n`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function applyRbacModule({ packageRoot, targetRoot }) {
|
|
317
|
+
copyFromPreset(packageRoot, targetRoot, path.join('packages', 'rbac'));
|
|
318
|
+
patchApiPackage(targetRoot);
|
|
319
|
+
patchAppModule(targetRoot);
|
|
320
|
+
patchHealthController(targetRoot);
|
|
321
|
+
patchWebApp(targetRoot);
|
|
322
|
+
patchApiDockerfile(targetRoot);
|
|
323
|
+
patchReadme(targetRoot);
|
|
324
|
+
}
|
package/src/modules/registry.mjs
CHANGED
|
@@ -57,6 +57,24 @@ const MODULE_PRESETS = {
|
|
|
57
57
|
'90_status_implemented',
|
|
58
58
|
],
|
|
59
59
|
},
|
|
60
|
+
rbac: {
|
|
61
|
+
id: 'rbac',
|
|
62
|
+
label: 'RBAC / Permissions',
|
|
63
|
+
category: 'auth-security',
|
|
64
|
+
implemented: true,
|
|
65
|
+
description: 'Role and permission decorators with a Nest guard and a protected probe endpoint.',
|
|
66
|
+
docFragments: [
|
|
67
|
+
'00_title',
|
|
68
|
+
'10_overview',
|
|
69
|
+
'20_idea',
|
|
70
|
+
'30_what_it_adds',
|
|
71
|
+
'40_how_it_works',
|
|
72
|
+
'50_how_to_use',
|
|
73
|
+
'60_configuration',
|
|
74
|
+
'70_operational_notes',
|
|
75
|
+
'90_status_implemented',
|
|
76
|
+
],
|
|
77
|
+
},
|
|
60
78
|
queue: {
|
|
61
79
|
id: 'queue',
|
|
62
80
|
label: 'Queue Worker',
|
|
@@ -68,6 +68,29 @@ function isAuthPersistencePending(rootDir) {
|
|
|
68
68
|
return !(hasModuleWiring && hasSchema && hasStoreFile && hasMigration);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function isAuthRbacPending(rootDir) {
|
|
72
|
+
const authContractsPath = path.join(rootDir, 'packages', 'auth-contracts', 'src', 'index.ts');
|
|
73
|
+
const authServicePath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.service.ts');
|
|
74
|
+
const authControllerPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.controller.ts');
|
|
75
|
+
|
|
76
|
+
if (!fs.existsSync(authContractsPath) || !fs.existsSync(authServicePath) || !fs.existsSync(authControllerPath)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const authContracts = fs.readFileSync(authContractsPath, 'utf8');
|
|
81
|
+
const authService = fs.readFileSync(authServicePath, 'utf8');
|
|
82
|
+
const authController = fs.readFileSync(authControllerPath, 'utf8');
|
|
83
|
+
|
|
84
|
+
const hasContracts = authContracts.includes('permissions?: string[];');
|
|
85
|
+
const hasDemoClaims = authService.includes("permissions: ['health.rbac']");
|
|
86
|
+
const hasPayloadClaims = authService.includes('permissions: user.permissions,');
|
|
87
|
+
const hasRefreshClaims = authService.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],');
|
|
88
|
+
const hasControllerClaims =
|
|
89
|
+
authController.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],');
|
|
90
|
+
|
|
91
|
+
return !(hasContracts && hasDemoClaims && hasPayloadClaims && hasRefreshClaims && hasControllerClaims);
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
const INTEGRATION_GROUPS = [
|
|
72
95
|
{
|
|
73
96
|
id: 'auth-persistence',
|
|
@@ -83,6 +106,20 @@ const INTEGRATION_GROUPS = [
|
|
|
83
106
|
isPending: (rootDir) => isAuthPersistencePending(rootDir),
|
|
84
107
|
apply: syncJwtDbPrisma,
|
|
85
108
|
},
|
|
109
|
+
{
|
|
110
|
+
id: 'auth-rbac-claims',
|
|
111
|
+
title: 'Auth Claims Integration',
|
|
112
|
+
modules: ['jwt-auth', 'rbac'],
|
|
113
|
+
description: [
|
|
114
|
+
'Extend AuthUser with optional permissions in @forgeon/auth-contracts',
|
|
115
|
+
'Add demo RBAC claims to jwt-auth login and token payloads',
|
|
116
|
+
'Expose permissions in auth refresh and /me responses',
|
|
117
|
+
'Update JWT auth README note about RBAC demo claims',
|
|
118
|
+
],
|
|
119
|
+
isAvailable: (detected) => detected.jwtAuth && detected.rbac,
|
|
120
|
+
isPending: (rootDir) => isAuthRbacPending(rootDir),
|
|
121
|
+
apply: syncJwtRbacClaims,
|
|
122
|
+
},
|
|
86
123
|
];
|
|
87
124
|
|
|
88
125
|
function detectModules(rootDir) {
|
|
@@ -93,6 +130,9 @@ function detectModules(rootDir) {
|
|
|
93
130
|
jwtAuth:
|
|
94
131
|
fs.existsSync(path.join(rootDir, 'packages', 'auth-api', 'package.json')) ||
|
|
95
132
|
appModuleText.includes("from '@forgeon/auth-api'"),
|
|
133
|
+
rbac:
|
|
134
|
+
fs.existsSync(path.join(rootDir, 'packages', 'rbac', 'package.json')) ||
|
|
135
|
+
appModuleText.includes("from '@forgeon/rbac'"),
|
|
96
136
|
dbPrisma:
|
|
97
137
|
fs.existsSync(path.join(rootDir, 'packages', 'db-prisma', 'package.json')) ||
|
|
98
138
|
appModuleText.includes("from '@forgeon/db-prisma'"),
|
|
@@ -216,6 +256,109 @@ function syncJwtDbPrisma({ rootDir, packageRoot, changedFiles }) {
|
|
|
216
256
|
return { applied: true };
|
|
217
257
|
}
|
|
218
258
|
|
|
259
|
+
function syncJwtRbacClaims({ rootDir, changedFiles }) {
|
|
260
|
+
const authContractsPath = path.join(rootDir, 'packages', 'auth-contracts', 'src', 'index.ts');
|
|
261
|
+
const authServicePath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.service.ts');
|
|
262
|
+
const authControllerPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.controller.ts');
|
|
263
|
+
const readmePath = path.join(rootDir, 'README.md');
|
|
264
|
+
|
|
265
|
+
if (!fs.existsSync(authContractsPath) || !fs.existsSync(authServicePath) || !fs.existsSync(authControllerPath)) {
|
|
266
|
+
return { applied: false, reason: 'auth package files are missing' };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let touched = false;
|
|
270
|
+
|
|
271
|
+
let authContracts = fs.readFileSync(authContractsPath, 'utf8').replace(/\r\n/g, '\n');
|
|
272
|
+
const originalAuthContracts = authContracts;
|
|
273
|
+
if (!authContracts.includes('permissions?: string[];')) {
|
|
274
|
+
authContracts = authContracts.replace(
|
|
275
|
+
' roles: string[];',
|
|
276
|
+
` roles: string[];
|
|
277
|
+
permissions?: string[];`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (authContracts !== originalAuthContracts) {
|
|
281
|
+
fs.writeFileSync(authContractsPath, `${authContracts.trimEnd()}\n`, 'utf8');
|
|
282
|
+
changedFiles.add(authContractsPath);
|
|
283
|
+
touched = true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let authService = fs.readFileSync(authServicePath, 'utf8').replace(/\r\n/g, '\n');
|
|
287
|
+
const originalAuthService = authService;
|
|
288
|
+
authService = authService.replace(
|
|
289
|
+
/roles: \['user'\],/g,
|
|
290
|
+
`roles: ['admin'],
|
|
291
|
+
permissions: ['health.rbac'],`,
|
|
292
|
+
);
|
|
293
|
+
if (!authService.includes('permissions: user.permissions,')) {
|
|
294
|
+
authService = authService.replace(
|
|
295
|
+
' roles: user.roles,',
|
|
296
|
+
` roles: user.roles,
|
|
297
|
+
permissions: user.permissions,`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (!authService.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
|
|
301
|
+
authService = authService.replace(
|
|
302
|
+
" roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
|
|
303
|
+
` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
|
|
304
|
+
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (!authService.includes('demoPermissions: [')) {
|
|
308
|
+
authService = authService.replace(
|
|
309
|
+
" demoEmail: this.configService.demoEmail,",
|
|
310
|
+
` demoEmail: this.configService.demoEmail,
|
|
311
|
+
demoPermissions: ['health.rbac'],`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (authService !== originalAuthService) {
|
|
315
|
+
fs.writeFileSync(authServicePath, `${authService.trimEnd()}\n`, 'utf8');
|
|
316
|
+
changedFiles.add(authServicePath);
|
|
317
|
+
touched = true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let authController = fs.readFileSync(authControllerPath, 'utf8').replace(/\r\n/g, '\n');
|
|
321
|
+
const originalAuthController = authController;
|
|
322
|
+
if (!authController.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
|
|
323
|
+
authController = authController.replace(
|
|
324
|
+
" roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
|
|
325
|
+
` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
|
|
326
|
+
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (authController !== originalAuthController) {
|
|
330
|
+
fs.writeFileSync(authControllerPath, `${authController.trimEnd()}\n`, 'utf8');
|
|
331
|
+
changedFiles.add(authControllerPath);
|
|
332
|
+
touched = true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (fs.existsSync(readmePath)) {
|
|
336
|
+
let readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
337
|
+
const originalReadme = readme;
|
|
338
|
+
if (!readme.includes('- RBAC integration: demo auth tokens include `health.rbac` permission')) {
|
|
339
|
+
const marker = 'Default demo credentials:';
|
|
340
|
+
if (readme.includes(marker)) {
|
|
341
|
+
readme = readme.replace(
|
|
342
|
+
marker,
|
|
343
|
+
`- RBAC integration: demo auth tokens include \`health.rbac\` permission
|
|
344
|
+
|
|
345
|
+
Default demo credentials:`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (readme !== originalReadme) {
|
|
350
|
+
fs.writeFileSync(readmePath, `${readme.trimEnd()}\n`, 'utf8');
|
|
351
|
+
changedFiles.add(readmePath);
|
|
352
|
+
touched = true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!touched) {
|
|
357
|
+
return { applied: false, reason: 'already synced' };
|
|
358
|
+
}
|
|
359
|
+
return { applied: true };
|
|
360
|
+
}
|
|
361
|
+
|
|
219
362
|
export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
|
|
220
363
|
const rootDir = path.resolve(targetRoot);
|
|
221
364
|
const changedFiles = new Set();
|
package/src/run-add-module.mjs
CHANGED
|
@@ -94,7 +94,7 @@ function ensureSyncTooling({ packageRoot, targetRoot }) {
|
|
|
94
94
|
);
|
|
95
95
|
const targetScript = path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs');
|
|
96
96
|
|
|
97
|
-
if (fs.existsSync(sourceScript)
|
|
97
|
+
if (fs.existsSync(sourceScript)) {
|
|
98
98
|
fs.mkdirSync(path.dirname(targetScript), { recursive: true });
|
|
99
99
|
fs.copyFileSync(sourceScript, targetScript);
|
|
100
100
|
}
|
package/templates/base/README.md
CHANGED
|
@@ -37,6 +37,7 @@ pnpm forgeon:sync-integrations
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Current sync coverage:
|
|
40
|
+
- `jwt-auth + rbac`: extends demo auth tokens with the `health.rbac` permission.
|
|
40
41
|
- `jwt-auth + db-prisma`: wires persistent refresh-token storage for auth.
|
|
41
42
|
|
|
42
43
|
`create-forgeon add <module>` scans for relevant integration groups and can apply them immediately.
|
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
Define one repeatable fullstack pattern for Forgeon add-modules.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Most feature modules should be split into:
|
|
8
8
|
|
|
9
|
-
1. `@forgeon/<feature>-contracts`
|
|
10
|
-
2. `@forgeon/<feature>-api`
|
|
11
|
-
3. `@forgeon/<feature>-web`
|
|
9
|
+
1. `@forgeon/<feature>-contracts`
|
|
10
|
+
2. `@forgeon/<feature>-api`
|
|
11
|
+
3. `@forgeon/<feature>-web`
|
|
12
|
+
|
|
13
|
+
Exception:
|
|
14
|
+
|
|
15
|
+
- backend-only infrastructure or security modules may use a single runtime package (`@forgeon/<feature>`) when shared contracts and a dedicated web package add no real value.
|
|
12
16
|
|
|
13
17
|
## 1) Contracts Package
|
|
14
18
|
|
|
@@ -53,6 +53,9 @@ function detectModules(rootDir) {
|
|
|
53
53
|
jwtAuth:
|
|
54
54
|
fs.existsSync(path.join(rootDir, 'packages', 'auth-api', 'package.json')) ||
|
|
55
55
|
appModuleText.includes("from '@forgeon/auth-api'"),
|
|
56
|
+
rbac:
|
|
57
|
+
fs.existsSync(path.join(rootDir, 'packages', 'rbac', 'package.json')) ||
|
|
58
|
+
appModuleText.includes("from '@forgeon/rbac'"),
|
|
56
59
|
dbPrisma:
|
|
57
60
|
fs.existsSync(path.join(rootDir, 'packages', 'db-prisma', 'package.json')) ||
|
|
58
61
|
appModuleText.includes("from '@forgeon/db-prisma'"),
|
|
@@ -179,6 +182,109 @@ function syncJwtDbPrisma({ rootDir, changedFiles }) {
|
|
|
179
182
|
return { applied: true };
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
function syncJwtRbacClaims({ rootDir, changedFiles }) {
|
|
186
|
+
const authContractsPath = path.join(rootDir, 'packages', 'auth-contracts', 'src', 'index.ts');
|
|
187
|
+
const authServicePath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.service.ts');
|
|
188
|
+
const authControllerPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.controller.ts');
|
|
189
|
+
const readmePath = path.join(rootDir, 'README.md');
|
|
190
|
+
|
|
191
|
+
if (!fs.existsSync(authContractsPath) || !fs.existsSync(authServicePath) || !fs.existsSync(authControllerPath)) {
|
|
192
|
+
return { applied: false, reason: 'auth package files are missing' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let touched = false;
|
|
196
|
+
|
|
197
|
+
let authContracts = fs.readFileSync(authContractsPath, 'utf8').replace(/\r\n/g, '\n');
|
|
198
|
+
const originalAuthContracts = authContracts;
|
|
199
|
+
if (!authContracts.includes('permissions?: string[];')) {
|
|
200
|
+
authContracts = authContracts.replace(
|
|
201
|
+
' roles: string[];',
|
|
202
|
+
` roles: string[];
|
|
203
|
+
permissions?: string[];`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (authContracts !== originalAuthContracts) {
|
|
207
|
+
fs.writeFileSync(authContractsPath, `${authContracts.trimEnd()}\n`, 'utf8');
|
|
208
|
+
changedFiles.add(authContractsPath);
|
|
209
|
+
touched = true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let authService = fs.readFileSync(authServicePath, 'utf8').replace(/\r\n/g, '\n');
|
|
213
|
+
const originalAuthService = authService;
|
|
214
|
+
authService = authService.replace(
|
|
215
|
+
/roles: \['user'\],/g,
|
|
216
|
+
`roles: ['admin'],
|
|
217
|
+
permissions: ['health.rbac'],`,
|
|
218
|
+
);
|
|
219
|
+
if (!authService.includes('permissions: user.permissions,')) {
|
|
220
|
+
authService = authService.replace(
|
|
221
|
+
' roles: user.roles,',
|
|
222
|
+
` roles: user.roles,
|
|
223
|
+
permissions: user.permissions,`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (!authService.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
|
|
227
|
+
authService = authService.replace(
|
|
228
|
+
" roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
|
|
229
|
+
` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
|
|
230
|
+
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (!authService.includes('demoPermissions: [')) {
|
|
234
|
+
authService = authService.replace(
|
|
235
|
+
" demoEmail: this.configService.demoEmail,",
|
|
236
|
+
` demoEmail: this.configService.demoEmail,
|
|
237
|
+
demoPermissions: ['health.rbac'],`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (authService !== originalAuthService) {
|
|
241
|
+
fs.writeFileSync(authServicePath, `${authService.trimEnd()}\n`, 'utf8');
|
|
242
|
+
changedFiles.add(authServicePath);
|
|
243
|
+
touched = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let authController = fs.readFileSync(authControllerPath, 'utf8').replace(/\r\n/g, '\n');
|
|
247
|
+
const originalAuthController = authController;
|
|
248
|
+
if (!authController.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
|
|
249
|
+
authController = authController.replace(
|
|
250
|
+
" roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
|
|
251
|
+
` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
|
|
252
|
+
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (authController !== originalAuthController) {
|
|
256
|
+
fs.writeFileSync(authControllerPath, `${authController.trimEnd()}\n`, 'utf8');
|
|
257
|
+
changedFiles.add(authControllerPath);
|
|
258
|
+
touched = true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (fs.existsSync(readmePath)) {
|
|
262
|
+
let readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
263
|
+
const originalReadme = readme;
|
|
264
|
+
if (!readme.includes('- RBAC integration: demo auth tokens include `health.rbac` permission')) {
|
|
265
|
+
const marker = 'Default demo credentials:';
|
|
266
|
+
if (readme.includes(marker)) {
|
|
267
|
+
readme = readme.replace(
|
|
268
|
+
marker,
|
|
269
|
+
`- RBAC integration: demo auth tokens include \`health.rbac\` permission
|
|
270
|
+
|
|
271
|
+
Default demo credentials:`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (readme !== originalReadme) {
|
|
276
|
+
fs.writeFileSync(readmePath, `${readme.trimEnd()}\n`, 'utf8');
|
|
277
|
+
changedFiles.add(readmePath);
|
|
278
|
+
touched = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!touched) {
|
|
283
|
+
return { applied: false, reason: 'already synced' };
|
|
284
|
+
}
|
|
285
|
+
return { applied: true };
|
|
286
|
+
}
|
|
287
|
+
|
|
182
288
|
function run() {
|
|
183
289
|
const rootDir = process.cwd();
|
|
184
290
|
const changedFiles = new Set();
|
|
@@ -197,6 +303,18 @@ function run() {
|
|
|
197
303
|
});
|
|
198
304
|
}
|
|
199
305
|
|
|
306
|
+
if (detected.jwtAuth && detected.rbac) {
|
|
307
|
+
summary.push({
|
|
308
|
+
feature: 'jwt-auth + rbac',
|
|
309
|
+
result: syncJwtRbacClaims({ rootDir, changedFiles }),
|
|
310
|
+
});
|
|
311
|
+
} else {
|
|
312
|
+
summary.push({
|
|
313
|
+
feature: 'jwt-auth + rbac',
|
|
314
|
+
result: { applied: false, reason: 'required modules are not both installed' },
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
200
318
|
console.log('[forgeon:sync-integrations] done');
|
|
201
319
|
for (const item of summary) {
|
|
202
320
|
if (item.result.applied) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# {{MODULE_LABEL}}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## Idea / Why
|
|
2
|
+
|
|
3
|
+
This module adds a minimal authorization layer for backend routes.
|
|
4
|
+
|
|
5
|
+
It exists to answer one simple question consistently:
|
|
6
|
+
|
|
7
|
+
- does the current caller have the required role or permission for this endpoint?
|
|
8
|
+
|
|
9
|
+
The module intentionally stays small. It does not try to become a general policy engine. It provides a stable baseline for backend access checks and leaves more advanced patterns to separate modules if they are ever needed.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## What It Adds
|
|
2
|
+
|
|
3
|
+
- `@forgeon/rbac` runtime package
|
|
4
|
+
- `@Roles(...)` decorator
|
|
5
|
+
- `@Permissions(...)` decorator
|
|
6
|
+
- `ForgeonRbacGuard`
|
|
7
|
+
- helper utilities for role and permission checks
|
|
8
|
+
- a protected probe route: `GET /api/health/rbac`
|
|
9
|
+
- a frontend probe button that sends a valid permission header
|
|
10
|
+
|
|
11
|
+
This module is backend-first. It does not include frontend route guards. If frontend access-control helpers are needed later, they should live in a separate module.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
## How It Works
|
|
2
|
+
|
|
3
|
+
Implementation details:
|
|
4
|
+
|
|
5
|
+
- decorators store required roles and permissions as Nest metadata
|
|
6
|
+
- `ForgeonRbacGuard` reads that metadata with `Reflector`
|
|
7
|
+
- the guard checks `request.user` first
|
|
8
|
+
- if no user payload is available, it can also read test headers:
|
|
9
|
+
- `x-forgeon-roles`
|
|
10
|
+
- `x-forgeon-permissions`
|
|
11
|
+
|
|
12
|
+
Decision rules in v1:
|
|
13
|
+
|
|
14
|
+
- roles: any required role is enough
|
|
15
|
+
- permissions: all required permissions must be present
|
|
16
|
+
|
|
17
|
+
Failure path:
|
|
18
|
+
|
|
19
|
+
- denied access throws `403`
|
|
20
|
+
- the existing Forgeon error envelope wraps it as `FORBIDDEN`
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
## How To Use
|
|
2
|
+
|
|
3
|
+
Install:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx create-forgeon@latest add rbac
|
|
7
|
+
pnpm install
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Verify:
|
|
11
|
+
|
|
12
|
+
1. start the project
|
|
13
|
+
2. click `Check RBAC access` on the generated frontend
|
|
14
|
+
3. the request should return `200`
|
|
15
|
+
|
|
16
|
+
Manual forbidden-path check:
|
|
17
|
+
|
|
18
|
+
1. call `GET /api/health/rbac` without the `x-forgeon-permissions` header
|
|
19
|
+
2. the request should return `403`
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## Configuration
|
|
2
|
+
|
|
3
|
+
This module has no dedicated environment variables in v1.
|
|
4
|
+
|
|
5
|
+
Behavior is controlled by:
|
|
6
|
+
|
|
7
|
+
- route decorators (`@Roles`, `@Permissions`)
|
|
8
|
+
- the active request payload (`request.user`)
|
|
9
|
+
- optional testing headers (`x-forgeon-roles`, `x-forgeon-permissions`)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
## Operational Notes
|
|
2
|
+
|
|
3
|
+
Current scope:
|
|
4
|
+
|
|
5
|
+
- no policy engine
|
|
6
|
+
- no database-backed role or permission store
|
|
7
|
+
- no admin UI
|
|
8
|
+
- no frontend route-guard package in this module
|
|
9
|
+
|
|
10
|
+
This is a stable baseline module, not a placeholder for a future “v2”. If access-control needs grow later, this module can be extended carefully or paired with a separate specialized module.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeon/rbac",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -p tsconfig.json"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@nestjs/common": "^11.0.1",
|
|
12
|
+
"@nestjs/core": "^11.0.1",
|
|
13
|
+
"reflect-metadata": "^0.2.2"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^22.10.7",
|
|
17
|
+
"typescript": "^5.7.3"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CanActivate,
|
|
3
|
+
ExecutionContext,
|
|
4
|
+
ForbiddenException,
|
|
5
|
+
Injectable,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { Reflector } from '@nestjs/core';
|
|
8
|
+
import {
|
|
9
|
+
RBAC_PERMISSIONS_HEADER,
|
|
10
|
+
RBAC_PERMISSIONS_KEY,
|
|
11
|
+
RBAC_ROLES_HEADER,
|
|
12
|
+
RBAC_ROLES_KEY,
|
|
13
|
+
} from './rbac.constants';
|
|
14
|
+
import { hasAllPermissions, hasAnyRole } from './rbac.helpers';
|
|
15
|
+
import { Permission, RbacPrincipal, Role } from './rbac.types';
|
|
16
|
+
|
|
17
|
+
type HeaderValue = string | string[] | undefined;
|
|
18
|
+
|
|
19
|
+
type HttpRequestLike = {
|
|
20
|
+
user?: unknown;
|
|
21
|
+
headers?: Record<string, HeaderValue>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
@Injectable()
|
|
25
|
+
export class ForgeonRbacGuard implements CanActivate {
|
|
26
|
+
constructor(private readonly reflector: Reflector) {}
|
|
27
|
+
|
|
28
|
+
canActivate(context: ExecutionContext): boolean {
|
|
29
|
+
const requiredRoles =
|
|
30
|
+
this.reflector.getAllAndOverride<Role[]>(RBAC_ROLES_KEY, [
|
|
31
|
+
context.getHandler(),
|
|
32
|
+
context.getClass(),
|
|
33
|
+
]) ?? [];
|
|
34
|
+
const requiredPermissions =
|
|
35
|
+
this.reflector.getAllAndOverride<Permission[]>(RBAC_PERMISSIONS_KEY, [
|
|
36
|
+
context.getHandler(),
|
|
37
|
+
context.getClass(),
|
|
38
|
+
]) ?? [];
|
|
39
|
+
|
|
40
|
+
if (requiredRoles.length === 0 && requiredPermissions.length === 0) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const request = context.switchToHttp().getRequest<HttpRequestLike>();
|
|
45
|
+
const principal = this.resolvePrincipal(request);
|
|
46
|
+
|
|
47
|
+
if (!hasAnyRole(principal, requiredRoles) || !hasAllPermissions(principal, requiredPermissions)) {
|
|
48
|
+
throw new ForbiddenException({
|
|
49
|
+
message: 'Access denied',
|
|
50
|
+
details: {
|
|
51
|
+
feature: 'rbac',
|
|
52
|
+
requiredRoles,
|
|
53
|
+
requiredPermissions,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private resolvePrincipal(request: HttpRequestLike | undefined): RbacPrincipal {
|
|
62
|
+
const user = request?.user;
|
|
63
|
+
if (user && typeof user === 'object') {
|
|
64
|
+
const principal = user as RbacPrincipal;
|
|
65
|
+
if (Array.isArray(principal.roles) || Array.isArray(principal.permissions)) {
|
|
66
|
+
return {
|
|
67
|
+
roles: Array.isArray(principal.roles) ? principal.roles : [],
|
|
68
|
+
permissions: Array.isArray(principal.permissions) ? principal.permissions : [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const headers = request?.headers;
|
|
74
|
+
return {
|
|
75
|
+
roles: this.parseHeaderList(headers?.[RBAC_ROLES_HEADER]),
|
|
76
|
+
permissions: this.parseHeaderList(headers?.[RBAC_PERMISSIONS_HEADER]),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private parseHeaderList(value: HeaderValue): string[] {
|
|
81
|
+
const source = Array.isArray(value) ? value.join(',') : value;
|
|
82
|
+
if (typeof source !== 'string' || source.trim().length === 0) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return source
|
|
87
|
+
.split(',')
|
|
88
|
+
.map((item) => item.trim())
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
import { RBAC_PERMISSIONS_KEY, RBAC_ROLES_KEY } from './rbac.constants';
|
|
3
|
+
import { Permission, Role } from './rbac.types';
|
|
4
|
+
|
|
5
|
+
export function Roles(...roles: Role[]) {
|
|
6
|
+
return SetMetadata(RBAC_ROLES_KEY, roles);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Permissions(...permissions: Permission[]) {
|
|
10
|
+
return SetMetadata(RBAC_PERMISSIONS_KEY, permissions);
|
|
11
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Permission, RbacPrincipal, Role } from './rbac.types';
|
|
2
|
+
|
|
3
|
+
export function hasRole(principal: RbacPrincipal | null | undefined, role: Role): boolean {
|
|
4
|
+
const roles = principal?.roles ?? [];
|
|
5
|
+
return roles.includes(role);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function hasPermission(
|
|
9
|
+
principal: RbacPrincipal | null | undefined,
|
|
10
|
+
permission: Permission,
|
|
11
|
+
): boolean {
|
|
12
|
+
const permissions = principal?.permissions ?? [];
|
|
13
|
+
return permissions.includes(permission);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function hasAnyRole(principal: RbacPrincipal | null | undefined, roles: Role[]): boolean {
|
|
17
|
+
if (roles.length === 0) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return roles.some((role) => hasRole(principal, role));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hasAllPermissions(
|
|
24
|
+
principal: RbacPrincipal | null | undefined,
|
|
25
|
+
permissions: Permission[],
|
|
26
|
+
): boolean {
|
|
27
|
+
if (permissions.length === 0) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
return permissions.every((permission) => hasPermission(principal, permission));
|
|
31
|
+
}
|