create-forgeon 0.2.6 → 0.2.7
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 +103 -0
- package/src/modules/i18n.mjs +11 -0
- package/src/modules/rbac.mjs +324 -0
- package/src/modules/registry.mjs +18 -0
- package/templates/base/docs/AI/MODULE_SPEC.md +8 -4
- 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
|
|
|
@@ -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');
|
|
@@ -1255,6 +1321,43 @@ describe('addModule', () => {
|
|
|
1255
1321
|
}
|
|
1256
1322
|
});
|
|
1257
1323
|
|
|
1324
|
+
it('keeps rbac wiring valid after mixed module installation order', () => {
|
|
1325
|
+
const targetRoot = mkTmp('forgeon-module-rbac-order-');
|
|
1326
|
+
const projectRoot = path.join(targetRoot, 'demo-rbac-order');
|
|
1327
|
+
const templateRoot = path.join(packageRoot, 'templates', 'base');
|
|
1328
|
+
|
|
1329
|
+
try {
|
|
1330
|
+
scaffoldProject({
|
|
1331
|
+
templateRoot,
|
|
1332
|
+
packageRoot,
|
|
1333
|
+
targetRoot: projectRoot,
|
|
1334
|
+
projectName: 'demo-rbac-order',
|
|
1335
|
+
frontend: 'react',
|
|
1336
|
+
db: 'prisma',
|
|
1337
|
+
dbPrismaEnabled: false,
|
|
1338
|
+
i18nEnabled: false,
|
|
1339
|
+
proxy: 'caddy',
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
for (const moduleId of ['jwt-auth', 'logger', 'rate-limit', 'rbac', 'swagger', 'i18n', 'db-prisma']) {
|
|
1343
|
+
addModule({ moduleId, targetRoot: projectRoot, packageRoot });
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
assertRbacWiring(projectRoot);
|
|
1347
|
+
|
|
1348
|
+
const healthController = fs.readFileSync(
|
|
1349
|
+
path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
|
|
1350
|
+
'utf8',
|
|
1351
|
+
);
|
|
1352
|
+
const classStart = healthController.indexOf('export class HealthController {');
|
|
1353
|
+
const classEnd = healthController.lastIndexOf('\n}');
|
|
1354
|
+
const rbacProbe = healthController.indexOf("@Get('rbac')");
|
|
1355
|
+
assert.equal(rbacProbe > classStart && rbacProbe < classEnd, true);
|
|
1356
|
+
} finally {
|
|
1357
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1258
1361
|
it('keeps db-prisma wiring across module installation orders', () => {
|
|
1259
1362
|
const sequences = [
|
|
1260
1363
|
['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',
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|