@ftisindia/create-app 0.1.3 → 0.1.5
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/template/.env.example +6 -0
- package/template/README.md +11 -1
- package/template/_package.json +0 -2
- package/template/docs/API_REFERENCE.md +13 -0
- package/template/docs/OAUTH.md +7 -3
- package/template/scripts/gen-module.mjs +2 -0
- package/template/src/app.module.ts +16 -22
- package/template/src/common/dto/error-response.dto.ts +3 -3
- package/template/src/common/dto/membership-response.dto.ts +26 -14
- package/template/src/common/dto/mutation-response.dto.ts +1 -1
- package/template/src/common/dto/pagination-query.dto.ts +37 -0
- package/template/src/common/dto/role-summary.dto.ts +5 -5
- package/template/src/common/dto/user-summary.dto.ts +6 -6
- package/template/src/common/filters/http-exception.filter.ts +9 -19
- package/template/src/common/swagger/api-error-responses.ts +12 -12
- package/template/src/config/app.config.ts +8 -3
- package/template/src/config/auth.config.ts +3 -3
- package/template/src/config/database.config.ts +3 -3
- package/template/src/config/env.validation.ts +78 -40
- package/template/src/config/index.ts +5 -5
- package/template/src/config/rbac.config.ts +3 -3
- package/template/src/database/prisma/prisma-transaction.ts +1 -1
- package/template/src/database/prisma/prisma.module.ts +2 -2
- package/template/src/database/prisma/prisma.service.ts +3 -6
- package/template/src/main.ts +24 -11
- package/template/src/modules/access-control/access-control.module.ts +11 -10
- package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
- package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
- package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
- package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
- package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
- package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
- package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
- package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +31 -0
- package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
- package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
- package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
- package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
- package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
- package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
- package/template/src/modules/access-control/types/permission-key.ts +19 -19
- package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
- package/template/src/modules/audit/application/services/audit.service.ts +7 -7
- package/template/src/modules/audit/audit.module.ts +4 -4
- package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
- package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
- package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
- package/template/src/modules/auth/application/services/auth.service.ts +147 -110
- package/template/src/modules/auth/application/services/password.service.ts +2 -2
- package/template/src/modules/auth/application/services/token.service.ts +20 -21
- package/template/src/modules/auth/auth.module.ts +20 -47
- package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
- package/template/src/modules/auth/dto/login.dto.ts +4 -4
- package/template/src/modules/auth/dto/logout.dto.ts +1 -1
- package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
- package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
- package/template/src/modules/auth/dto/signup.dto.ts +5 -11
- package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
- package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
- package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
- package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
- package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
- package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
- package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
- package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
- package/template/src/modules/health/dto/health-response.dto.ts +5 -5
- package/template/src/modules/health/health.module.ts +2 -2
- package/template/src/modules/health/presentation/health.controller.ts +13 -13
- package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
- package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
- package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
- package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
- package/template/src/modules/invitations/invitations.module.ts +5 -5
- package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
- package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
- package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
- package/template/src/modules/memberships/memberships.module.ts +4 -4
- package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
- package/template/src/modules/organisations/application/services/organisations.service.ts +87 -23
- package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +65 -14
- package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
- package/template/src/modules/organisations/organisations.module.ts +5 -5
- package/template/src/modules/organisations/presentation/organisations.controller.ts +31 -18
- package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
- package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
- package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
- package/template/src/modules/request-context/request-context.module.ts +7 -7
- package/template/src/modules/request-context/types/request-context.ts +2 -2
- package/template/src/modules/sample/application/services/sample.service.ts +10 -8
- package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
- package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
- package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
- package/template/src/modules/sample/sample.module.ts +4 -4
- package/template/src/modules/settings/application/services/settings.service.ts +15 -27
- package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
- package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
- package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
- package/template/src/modules/settings/settings.module.ts +5 -5
- package/template/src/modules/settings/types/setting-definitions.ts +49 -33
- package/template/test/auth-refresh.spec.ts +90 -0
- package/template/test/frontend-bootstrap.spec.ts +181 -0
- package/template/test/role-permission-policy.spec.ts +94 -0
- package/template/test/route-registry.validator.spec.ts +12 -0
- package/template/test/security.e2e-spec.ts +134 -2
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { z } from
|
|
1
|
+
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
const booleanFromEnv = z.preprocess((value) => {
|
|
4
|
-
if (typeof value ===
|
|
4
|
+
if (typeof value === 'boolean') {
|
|
5
5
|
return value;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
if (typeof value ===
|
|
8
|
+
if (typeof value === 'string') {
|
|
9
9
|
const normalized = value.trim().toLowerCase();
|
|
10
|
-
if (normalized ===
|
|
10
|
+
if (normalized === 'true') {
|
|
11
11
|
return true;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
if (normalized ===
|
|
14
|
+
if (normalized === 'false') {
|
|
15
15
|
return false;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -22,84 +22,103 @@ const booleanFromEnv = z.preprocess((value) => {
|
|
|
22
22
|
const durationSchema = z
|
|
23
23
|
.string()
|
|
24
24
|
.trim()
|
|
25
|
-
.regex(/^\d+[smhdw]$/,
|
|
25
|
+
.regex(/^\d+[smhdw]$/, 'duration must use s, m, h, d, or w units');
|
|
26
26
|
|
|
27
27
|
export const envSchema = z
|
|
28
28
|
.object({
|
|
29
|
-
NODE_ENV: z
|
|
30
|
-
.enum(["development", "test", "production"])
|
|
31
|
-
.default("development"),
|
|
29
|
+
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
|
32
30
|
PORT: z.coerce.number().int().positive().default(3000),
|
|
33
31
|
DATABASE_URL: z
|
|
34
32
|
.string()
|
|
35
33
|
.trim()
|
|
36
|
-
.min(1,
|
|
37
|
-
.url(
|
|
38
|
-
JWT_SECRET: z
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
JWT_REFRESH_EXPIRES_IN: durationSchema.default("7d"),
|
|
44
|
-
JWT_ISSUER: z.string().trim().min(1).default("foundation-starter"),
|
|
45
|
-
JWT_AUDIENCE: z.string().trim().min(1).default("foundation-api"),
|
|
34
|
+
.min(1, 'DATABASE_URL is required')
|
|
35
|
+
.url('DATABASE_URL must be a valid URL'),
|
|
36
|
+
JWT_SECRET: z.string().trim().min(32, 'JWT_SECRET must be at least 32 characters'),
|
|
37
|
+
JWT_ACCESS_EXPIRES_IN: durationSchema.default('15m'),
|
|
38
|
+
JWT_REFRESH_EXPIRES_IN: durationSchema.default('7d'),
|
|
39
|
+
JWT_ISSUER: z.string().trim().min(1).default('foundation-starter'),
|
|
40
|
+
JWT_AUDIENCE: z.string().trim().min(1).default('foundation-api'),
|
|
46
41
|
AUTH_EMAIL_PASSWORD_ENABLED: booleanFromEnv.default(true),
|
|
47
42
|
AUTH_GOOGLE_ENABLED: booleanFromEnv.default(false),
|
|
48
|
-
AUTH_GOOGLE_CLIENT_ID: z.string().trim().optional().default(
|
|
49
|
-
AUTH_GOOGLE_CLIENT_SECRET: z.string().trim().optional().default(
|
|
43
|
+
AUTH_GOOGLE_CLIENT_ID: z.string().trim().optional().default(''),
|
|
44
|
+
AUTH_GOOGLE_CLIENT_SECRET: z.string().trim().optional().default(''),
|
|
50
45
|
AUTH_GOOGLE_CALLBACK_URL: z
|
|
51
46
|
.string()
|
|
52
47
|
.trim()
|
|
53
48
|
.url()
|
|
54
|
-
.default(
|
|
49
|
+
.default('http://localhost:3000/auth/google/callback'),
|
|
55
50
|
AUTH_GOOGLE_SUCCESS_REDIRECT_URL: z
|
|
56
51
|
.string()
|
|
57
52
|
.trim()
|
|
58
53
|
.url()
|
|
59
|
-
.default(
|
|
54
|
+
.default('http://localhost:3000/auth/google/success'),
|
|
60
55
|
AUTH_GOOGLE_ERROR_REDIRECT_URL: z
|
|
61
56
|
.string()
|
|
62
57
|
.trim()
|
|
63
58
|
.url()
|
|
64
|
-
.default(
|
|
65
|
-
SESSION_SECRET: z.string().trim().optional().default(
|
|
59
|
+
.default('http://localhost:3000/auth/google/error'),
|
|
60
|
+
SESSION_SECRET: z.string().trim().optional().default(''),
|
|
66
61
|
AUTH_FACEBOOK_ENABLED: booleanFromEnv.default(false),
|
|
67
62
|
AUTH_MOBILE_OTP_ENABLED: booleanFromEnv.default(false),
|
|
68
63
|
AUTH_MAGIC_LINK_ENABLED: booleanFromEnv.default(false),
|
|
64
|
+
CORS_ENABLED: booleanFromEnv.default(false),
|
|
65
|
+
CORS_ORIGINS: z.string().trim().optional().default(''),
|
|
66
|
+
CORS_CREDENTIALS: booleanFromEnv.default(false),
|
|
69
67
|
RBAC_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(60),
|
|
70
|
-
ORG_CONTEXT_MODE: z.literal(
|
|
68
|
+
ORG_CONTEXT_MODE: z.literal('path').default('path'),
|
|
71
69
|
})
|
|
72
70
|
.superRefine((env, ctx) => {
|
|
71
|
+
const corsOrigins = parseCorsOrigins(env.CORS_ORIGINS);
|
|
72
|
+
|
|
73
|
+
if (env.CORS_CREDENTIALS && corsOrigins.includes('*')) {
|
|
74
|
+
ctx.addIssue({
|
|
75
|
+
code: 'custom',
|
|
76
|
+
path: ['CORS_ORIGINS'],
|
|
77
|
+
message: 'CORS_ORIGINS cannot include * when CORS_CREDENTIALS=true',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
if (!env.AUTH_GOOGLE_ENABLED) {
|
|
74
82
|
return;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
if (!env.AUTH_GOOGLE_CLIENT_ID) {
|
|
78
86
|
ctx.addIssue({
|
|
79
|
-
code:
|
|
80
|
-
path: [
|
|
81
|
-
message:
|
|
82
|
-
"AUTH_GOOGLE_CLIENT_ID is required when AUTH_GOOGLE_ENABLED=true",
|
|
87
|
+
code: 'custom',
|
|
88
|
+
path: ['AUTH_GOOGLE_CLIENT_ID'],
|
|
89
|
+
message: 'AUTH_GOOGLE_CLIENT_ID is required when AUTH_GOOGLE_ENABLED=true',
|
|
83
90
|
});
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
if (!env.AUTH_GOOGLE_CLIENT_SECRET) {
|
|
87
94
|
ctx.addIssue({
|
|
88
|
-
code:
|
|
89
|
-
path: [
|
|
90
|
-
message:
|
|
91
|
-
"AUTH_GOOGLE_CLIENT_SECRET is required when AUTH_GOOGLE_ENABLED=true",
|
|
95
|
+
code: 'custom',
|
|
96
|
+
path: ['AUTH_GOOGLE_CLIENT_SECRET'],
|
|
97
|
+
message: 'AUTH_GOOGLE_CLIENT_SECRET is required when AUTH_GOOGLE_ENABLED=true',
|
|
92
98
|
});
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
if (env.SESSION_SECRET.length < 32) {
|
|
96
102
|
ctx.addIssue({
|
|
97
|
-
code:
|
|
98
|
-
path: [
|
|
99
|
-
message:
|
|
100
|
-
"SESSION_SECRET must be at least 32 characters when AUTH_GOOGLE_ENABLED=true",
|
|
103
|
+
code: 'custom',
|
|
104
|
+
path: ['SESSION_SECRET'],
|
|
105
|
+
message: 'SESSION_SECRET must be at least 32 characters when AUTH_GOOGLE_ENABLED=true',
|
|
101
106
|
});
|
|
102
107
|
}
|
|
108
|
+
|
|
109
|
+
if (env.NODE_ENV === 'production') {
|
|
110
|
+
requireHttpsInProduction(ctx, 'AUTH_GOOGLE_CALLBACK_URL', env.AUTH_GOOGLE_CALLBACK_URL);
|
|
111
|
+
requireHttpsInProduction(
|
|
112
|
+
ctx,
|
|
113
|
+
'AUTH_GOOGLE_SUCCESS_REDIRECT_URL',
|
|
114
|
+
env.AUTH_GOOGLE_SUCCESS_REDIRECT_URL,
|
|
115
|
+
);
|
|
116
|
+
requireHttpsInProduction(
|
|
117
|
+
ctx,
|
|
118
|
+
'AUTH_GOOGLE_ERROR_REDIRECT_URL',
|
|
119
|
+
env.AUTH_GOOGLE_ERROR_REDIRECT_URL,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
103
122
|
});
|
|
104
123
|
|
|
105
124
|
export type Env = z.infer<typeof envSchema>;
|
|
@@ -114,8 +133,8 @@ function parseEnv(config: Record<string, unknown>): Env {
|
|
|
114
133
|
}
|
|
115
134
|
|
|
116
135
|
const details = parsed.error.issues
|
|
117
|
-
.map((issue) => `- ${issue.path.join(
|
|
118
|
-
.join(
|
|
136
|
+
.map((issue) => `- ${issue.path.join('.') || 'env'}: ${issue.message}`)
|
|
137
|
+
.join('\n');
|
|
119
138
|
|
|
120
139
|
throw new Error(`Environment validation failed:\n${details}`);
|
|
121
140
|
}
|
|
@@ -129,3 +148,22 @@ export function getEnv(): Env {
|
|
|
129
148
|
validatedEnv ??= parseEnv(process.env);
|
|
130
149
|
return validatedEnv;
|
|
131
150
|
}
|
|
151
|
+
|
|
152
|
+
export function parseCorsOrigins(value: string) {
|
|
153
|
+
return value
|
|
154
|
+
.split(',')
|
|
155
|
+
.map((origin) => origin.trim())
|
|
156
|
+
.filter(Boolean);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function requireHttpsInProduction(ctx: z.RefinementCtx, key: string, value: string) {
|
|
160
|
+
if (new URL(value).protocol === 'https:') {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
ctx.addIssue({
|
|
165
|
+
code: 'custom',
|
|
166
|
+
path: [key],
|
|
167
|
+
message: `${key} must use https when Google OAuth is enabled in production.`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { default as appConfig } from
|
|
2
|
-
export { default as authConfig } from
|
|
3
|
-
export { default as databaseConfig } from
|
|
4
|
-
export { default as rbacConfig } from
|
|
5
|
-
export { validate } from
|
|
1
|
+
export { default as appConfig } from './app.config';
|
|
2
|
+
export { default as authConfig } from './auth.config';
|
|
3
|
+
export { default as databaseConfig } from './database.config';
|
|
4
|
+
export { default as rbacConfig } from './rbac.config';
|
|
5
|
+
export { validate } from './env.validation';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { registerAs } from
|
|
2
|
-
import { getEnv } from
|
|
1
|
+
import { registerAs } from '@nestjs/config';
|
|
2
|
+
import { getEnv } from './env.validation';
|
|
3
3
|
|
|
4
|
-
export default registerAs(
|
|
4
|
+
export default registerAs('rbac', () => ({
|
|
5
5
|
cacheTtlSeconds: getEnv().RBAC_CACHE_TTL_SECONDS,
|
|
6
6
|
}));
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import { Injectable, OnModuleDestroy, OnModuleInit } from
|
|
2
|
-
import { PrismaClient } from
|
|
1
|
+
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { PrismaClient } from '@prisma/client';
|
|
3
3
|
|
|
4
4
|
@Injectable()
|
|
5
|
-
export class PrismaService
|
|
6
|
-
extends PrismaClient
|
|
7
|
-
implements OnModuleInit, OnModuleDestroy
|
|
8
|
-
{
|
|
5
|
+
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
|
9
6
|
async onModuleInit() {
|
|
10
7
|
await this.$connect();
|
|
11
8
|
}
|
package/template/src/main.ts
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
import { ValidationPipe } from
|
|
2
|
-
import { ConfigService } from
|
|
3
|
-
import { NestFactory } from
|
|
4
|
-
import { DocumentBuilder, SwaggerModule } from
|
|
5
|
-
import { AppModule } from
|
|
6
|
-
import { HttpExceptionFilter } from
|
|
1
|
+
import { ValidationPipe } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { NestFactory } from '@nestjs/core';
|
|
4
|
+
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|
5
|
+
import { AppModule } from './app.module';
|
|
6
|
+
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|
7
7
|
|
|
8
8
|
async function bootstrap() {
|
|
9
9
|
const app = await NestFactory.create(AppModule);
|
|
10
10
|
const config = app.get(ConfigService);
|
|
11
|
+
const cors = config.get<{
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
origins: string[];
|
|
14
|
+
credentials: boolean;
|
|
15
|
+
}>('app.cors');
|
|
16
|
+
|
|
17
|
+
if (cors?.enabled) {
|
|
18
|
+
app.enableCors({
|
|
19
|
+
origin: cors.origins,
|
|
20
|
+
credentials: cors.credentials,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
app.enableShutdownHooks();
|
|
12
25
|
app.useGlobalFilters(new HttpExceptionFilter());
|
|
13
26
|
|
|
@@ -22,21 +35,21 @@ async function bootstrap() {
|
|
|
22
35
|
const document = SwaggerModule.createDocument(
|
|
23
36
|
app,
|
|
24
37
|
new DocumentBuilder()
|
|
25
|
-
.setTitle(
|
|
38
|
+
.setTitle('Foundation Starter API')
|
|
26
39
|
.setDescription(
|
|
27
|
-
|
|
40
|
+
'Modular-monolith starter API with auth, organisations, RBAC, audit, and settings.',
|
|
28
41
|
)
|
|
29
|
-
.setVersion(
|
|
42
|
+
.setVersion('0.1.0')
|
|
30
43
|
.addBearerAuth()
|
|
31
44
|
.build(),
|
|
32
45
|
);
|
|
33
|
-
SwaggerModule.setup(
|
|
46
|
+
SwaggerModule.setup('docs', app, document, {
|
|
34
47
|
swaggerOptions: {
|
|
35
48
|
persistAuthorization: true,
|
|
36
49
|
},
|
|
37
50
|
});
|
|
38
51
|
|
|
39
|
-
await app.listen(config.get<number>(
|
|
52
|
+
await app.listen(config.get<number>('app.port') ?? 3000);
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
void bootstrap();
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import { Global, Module } from
|
|
2
|
-
import { DiscoveryModule } from
|
|
3
|
-
import { AuthModule } from
|
|
4
|
-
import { RouteRegistryValidator } from
|
|
5
|
-
import { AbilityFactory } from
|
|
6
|
-
import { AccessControlService } from
|
|
7
|
-
import { PermissionGuard } from
|
|
8
|
-
import { RbacCacheService } from
|
|
9
|
-
import { AccessControlController } from
|
|
1
|
+
import { Global, Module } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryModule } from '@nestjs/core';
|
|
3
|
+
import { AuthModule } from '../auth/auth.module';
|
|
4
|
+
import { RouteRegistryValidator } from './application/route-registry.validator';
|
|
5
|
+
import { AbilityFactory } from './application/services/ability.factory';
|
|
6
|
+
import { AccessControlService } from './application/services/access-control.service';
|
|
7
|
+
import { PermissionGuard } from './application/services/permission.guard';
|
|
8
|
+
import { RbacCacheService } from './application/services/rbac-cache.service';
|
|
9
|
+
import { AccessControlController } from './presentation/access-control.controller';
|
|
10
|
+
import { CurrentAccessControlController } from './presentation/current-access-control.controller';
|
|
10
11
|
|
|
11
12
|
@Global()
|
|
12
13
|
@Module({
|
|
13
14
|
imports: [AuthModule, DiscoveryModule],
|
|
14
|
-
controllers: [AccessControlController],
|
|
15
|
+
controllers: [AccessControlController, CurrentAccessControlController],
|
|
15
16
|
providers: [
|
|
16
17
|
AbilityFactory,
|
|
17
18
|
AccessControlService,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ForbiddenException } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prevents privilege self-elevation: an actor may only assign or invite to a
|
|
5
|
+
* role whose permission set is a subset of the actor's own effective
|
|
6
|
+
* permissions. Organisation owners bypass the check.
|
|
7
|
+
*
|
|
8
|
+
* Both MembershipsService.assignRole and InvitationsService (create/resend)
|
|
9
|
+
* call this with the candidate role's permission keys and the actor's RBAC
|
|
10
|
+
* context, so a member holding only e.g. `roles.manage` / `memberships.invite`
|
|
11
|
+
* cannot grant a role carrying permissions they do not themselves hold.
|
|
12
|
+
*/
|
|
13
|
+
export function assertRoleWithinActorPermissions(args: {
|
|
14
|
+
actorIsOwner: boolean;
|
|
15
|
+
actorPermissionKeys: readonly string[];
|
|
16
|
+
rolePermissionKeys: readonly string[];
|
|
17
|
+
}) {
|
|
18
|
+
if (args.actorIsOwner) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const heldKeys = new Set(args.actorPermissionKeys);
|
|
23
|
+
const exceeded = args.rolePermissionKeys.filter((key) => !heldKeys.has(key)).sort();
|
|
24
|
+
|
|
25
|
+
if (exceeded.length > 0) {
|
|
26
|
+
throw new ForbiddenException(
|
|
27
|
+
`You cannot grant a role with permissions you do not hold: ${exceeded.join(', ')}.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Prevents privilege escalation when a role's permission set is rewritten: a
|
|
34
|
+
* non-owner actor may only add or remove permission keys they themselves hold.
|
|
35
|
+
* Permission keys outside the actor's own set must stay unchanged — they can be
|
|
36
|
+
* neither granted nor revoked by someone who does not hold them. Owners bypass.
|
|
37
|
+
*/
|
|
38
|
+
export function assertPermissionChangeWithinActor(args: {
|
|
39
|
+
actorIsOwner: boolean;
|
|
40
|
+
actorPermissionKeys: readonly string[];
|
|
41
|
+
previousPermissionKeys: readonly string[];
|
|
42
|
+
nextPermissionKeys: readonly string[];
|
|
43
|
+
}) {
|
|
44
|
+
if (args.actorIsOwner) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const previous = new Set(args.previousPermissionKeys);
|
|
49
|
+
const next = new Set(args.nextPermissionKeys);
|
|
50
|
+
const changed = new Set<string>();
|
|
51
|
+
|
|
52
|
+
for (const key of args.nextPermissionKeys) {
|
|
53
|
+
if (!previous.has(key)) {
|
|
54
|
+
changed.add(key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const key of args.previousPermissionKeys) {
|
|
58
|
+
if (!next.has(key)) {
|
|
59
|
+
changed.add(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const heldKeys = new Set(args.actorPermissionKeys);
|
|
64
|
+
const exceeded = [...changed].filter((key) => !heldKeys.has(key)).sort();
|
|
65
|
+
|
|
66
|
+
if (exceeded.length > 0) {
|
|
67
|
+
throw new ForbiddenException(
|
|
68
|
+
`You cannot grant or revoke permissions you do not hold: ${exceeded.join(', ')}.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from
|
|
6
|
-
import {
|
|
7
|
-
import { DiscoveryService, MetadataScanner, Reflector } from "@nestjs/core";
|
|
8
|
-
import { REQUIRED_PERMISSIONS_KEY } from "../presentation/permissions.decorator";
|
|
9
|
-
import {
|
|
10
|
-
PermissionKey,
|
|
11
|
-
permissionKeys,
|
|
12
|
-
RoutePermissionEntry,
|
|
13
|
-
} from "../types/permission-key";
|
|
14
|
-
import { routePermissionRegistry } from "../types/route-permission-registry";
|
|
1
|
+
import { Injectable, OnApplicationBootstrap, RequestMethod } from '@nestjs/common';
|
|
2
|
+
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
|
3
|
+
import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';
|
|
4
|
+
import { REQUIRED_PERMISSIONS_KEY } from '../presentation/permissions.decorator';
|
|
5
|
+
import { PermissionKey, permissionKeys, RoutePermissionEntry } from '../types/permission-key';
|
|
6
|
+
import { routePermissionRegistry } from '../types/route-permission-registry';
|
|
15
7
|
|
|
16
8
|
type ControllerInstance = Record<string, unknown>;
|
|
17
9
|
type RouteHandler = (...args: unknown[]) => unknown;
|
|
@@ -33,28 +25,16 @@ export class RouteRegistryValidator implements OnApplicationBootstrap {
|
|
|
33
25
|
const enforcedRoutes = this.discoverPermissionRoutes();
|
|
34
26
|
const registryRoutes = routePermissionRegistry.map(normalizeEntry);
|
|
35
27
|
|
|
36
|
-
const enforcedByRoute = mapByRoute(
|
|
37
|
-
|
|
38
|
-
"enforced route metadata",
|
|
39
|
-
);
|
|
40
|
-
const registryByRoute = mapByRoute(
|
|
41
|
-
registryRoutes,
|
|
42
|
-
"routePermissionRegistry",
|
|
43
|
-
);
|
|
28
|
+
const enforcedByRoute = mapByRoute(enforcedRoutes, 'enforced route metadata');
|
|
29
|
+
const registryByRoute = mapByRoute(registryRoutes, 'routePermissionRegistry');
|
|
44
30
|
const missingFromRegistry = enforcedRoutes.filter(
|
|
45
31
|
(entry) => !registryByRoute.has(routeKey(entry)),
|
|
46
32
|
);
|
|
47
33
|
const staleRegistryEntries = registryRoutes.filter(
|
|
48
34
|
(entry) => !enforcedByRoute.has(routeKey(entry)),
|
|
49
35
|
);
|
|
50
|
-
const permissionMismatches = collectPermissionMismatches(
|
|
51
|
-
|
|
52
|
-
enforcedByRoute,
|
|
53
|
-
);
|
|
54
|
-
const unknownPermissions = collectUnknownPermissions([
|
|
55
|
-
...registryRoutes,
|
|
56
|
-
...enforcedRoutes,
|
|
57
|
-
]);
|
|
36
|
+
const permissionMismatches = collectPermissionMismatches(registryByRoute, enforcedByRoute);
|
|
37
|
+
const unknownPermissions = collectUnknownPermissions([...registryRoutes, ...enforcedRoutes]);
|
|
58
38
|
|
|
59
39
|
if (
|
|
60
40
|
missingFromRegistry.length > 0 ||
|
|
@@ -83,16 +63,12 @@ export class RouteRegistryValidator implements OnApplicationBootstrap {
|
|
|
83
63
|
continue;
|
|
84
64
|
}
|
|
85
65
|
|
|
86
|
-
const controllerPaths = toPathParts(
|
|
87
|
-
this.reflector.get(PATH_METADATA, metatype),
|
|
88
|
-
);
|
|
66
|
+
const controllerPaths = toPathParts(this.reflector.get(PATH_METADATA, metatype));
|
|
89
67
|
const prototype = Object.getPrototypeOf(instance) as object | null;
|
|
90
68
|
|
|
91
|
-
for (const methodName of this.metadataScanner.getAllMethodNames(
|
|
92
|
-
prototype,
|
|
93
|
-
)) {
|
|
69
|
+
for (const methodName of this.metadataScanner.getAllMethodNames(prototype)) {
|
|
94
70
|
const handler = instance[methodName];
|
|
95
|
-
if (typeof handler !==
|
|
71
|
+
if (typeof handler !== 'function') {
|
|
96
72
|
continue;
|
|
97
73
|
}
|
|
98
74
|
|
|
@@ -112,9 +88,7 @@ export class RouteRegistryValidator implements OnApplicationBootstrap {
|
|
|
112
88
|
continue;
|
|
113
89
|
}
|
|
114
90
|
|
|
115
|
-
const methodPaths = toPathParts(
|
|
116
|
-
this.reflector.get(PATH_METADATA, handler),
|
|
117
|
-
);
|
|
91
|
+
const methodPaths = toPathParts(this.reflector.get(PATH_METADATA, handler));
|
|
118
92
|
for (const path of combinePaths(controllerPaths, methodPaths)) {
|
|
119
93
|
entries.push(
|
|
120
94
|
normalizeEntry({
|
|
@@ -143,10 +117,7 @@ function collectPermissionMismatches(
|
|
|
143
117
|
continue;
|
|
144
118
|
}
|
|
145
119
|
|
|
146
|
-
if (
|
|
147
|
-
permissionListKey(registered.permissions) !==
|
|
148
|
-
permissionListKey(enforced.permissions)
|
|
149
|
-
) {
|
|
120
|
+
if (permissionListKey(registered.permissions) !== permissionListKey(enforced.permissions)) {
|
|
150
121
|
mismatches.push({
|
|
151
122
|
key,
|
|
152
123
|
registryPermissions: registered.permissions,
|
|
@@ -176,48 +147,48 @@ function formatRegistryDriftError({
|
|
|
176
147
|
permissionMismatches: RoutePermissionDiff[];
|
|
177
148
|
unknownPermissions: string[];
|
|
178
149
|
}) {
|
|
179
|
-
const sections = [
|
|
150
|
+
const sections = ['Route permission registry drift detected.'];
|
|
180
151
|
|
|
181
152
|
if (missingFromRegistry.length > 0) {
|
|
182
153
|
sections.push(
|
|
183
154
|
[
|
|
184
|
-
|
|
155
|
+
'Permission-gated routes missing from routePermissionRegistry:',
|
|
185
156
|
...missingFromRegistry.map(formatEntry),
|
|
186
|
-
].join(
|
|
157
|
+
].join('\n'),
|
|
187
158
|
);
|
|
188
159
|
}
|
|
189
160
|
|
|
190
161
|
if (staleRegistryEntries.length > 0) {
|
|
191
162
|
sections.push(
|
|
192
163
|
[
|
|
193
|
-
|
|
164
|
+
'routePermissionRegistry entries without matching permission-gated routes:',
|
|
194
165
|
...staleRegistryEntries.map(formatEntry),
|
|
195
|
-
].join(
|
|
166
|
+
].join('\n'),
|
|
196
167
|
);
|
|
197
168
|
}
|
|
198
169
|
|
|
199
170
|
if (permissionMismatches.length > 0) {
|
|
200
171
|
sections.push(
|
|
201
172
|
[
|
|
202
|
-
|
|
173
|
+
'routePermissionRegistry permission mismatches:',
|
|
203
174
|
...permissionMismatches.map(
|
|
204
175
|
(diff) =>
|
|
205
|
-
`- ${diff.key}: registry [${diff.registryPermissions.join(
|
|
176
|
+
`- ${diff.key}: registry [${diff.registryPermissions.join(', ')}], enforced [${diff.enforcedPermissions.join(', ')}]`,
|
|
206
177
|
),
|
|
207
|
-
].join(
|
|
178
|
+
].join('\n'),
|
|
208
179
|
);
|
|
209
180
|
}
|
|
210
181
|
|
|
211
182
|
if (unknownPermissions.length > 0) {
|
|
212
183
|
sections.push(
|
|
213
184
|
[
|
|
214
|
-
|
|
185
|
+
'Unknown permission keys referenced by guarded routes or routePermissionRegistry:',
|
|
215
186
|
...unknownPermissions.map((permission) => `- ${permission}`),
|
|
216
|
-
].join(
|
|
187
|
+
].join('\n'),
|
|
217
188
|
);
|
|
218
189
|
}
|
|
219
190
|
|
|
220
|
-
return sections.join(
|
|
191
|
+
return sections.join('\n\n');
|
|
221
192
|
}
|
|
222
193
|
|
|
223
194
|
function mapByRoute(entries: RoutePermissionEntry[], source: string) {
|
|
@@ -248,7 +219,7 @@ function combinePaths(controllerPaths: string[], methodPaths: string[]) {
|
|
|
248
219
|
|
|
249
220
|
for (const controllerPath of controllerPaths) {
|
|
250
221
|
for (const methodPath of methodPaths) {
|
|
251
|
-
paths.push(normalizePath([controllerPath, methodPath].join(
|
|
222
|
+
paths.push(normalizePath([controllerPath, methodPath].join('/')));
|
|
252
223
|
}
|
|
253
224
|
}
|
|
254
225
|
|
|
@@ -257,19 +228,19 @@ function combinePaths(controllerPaths: string[], methodPaths: string[]) {
|
|
|
257
228
|
|
|
258
229
|
function toPathParts(metadata: string | string[] | undefined) {
|
|
259
230
|
if (Array.isArray(metadata)) {
|
|
260
|
-
return metadata.length > 0 ? metadata : [
|
|
231
|
+
return metadata.length > 0 ? metadata : [''];
|
|
261
232
|
}
|
|
262
233
|
|
|
263
|
-
return [metadata ??
|
|
234
|
+
return [metadata ?? ''];
|
|
264
235
|
}
|
|
265
236
|
|
|
266
237
|
function normalizePath(path: string) {
|
|
267
238
|
const trimmed = path
|
|
268
|
-
.split(
|
|
239
|
+
.split('/')
|
|
269
240
|
.filter((part) => part.length > 0)
|
|
270
|
-
.join(
|
|
241
|
+
.join('/');
|
|
271
242
|
|
|
272
|
-
return trimmed ? `/${trimmed}` :
|
|
243
|
+
return trimmed ? `/${trimmed}` : '/';
|
|
273
244
|
}
|
|
274
245
|
|
|
275
246
|
function requestMethodToString(method: RequestMethod) {
|
|
@@ -281,9 +252,9 @@ function routeKey(entry: RoutePermissionEntry) {
|
|
|
281
252
|
}
|
|
282
253
|
|
|
283
254
|
function permissionListKey(permissions: PermissionKey[]) {
|
|
284
|
-
return permissions.join(
|
|
255
|
+
return permissions.join('\0');
|
|
285
256
|
}
|
|
286
257
|
|
|
287
258
|
function formatEntry(entry: RoutePermissionEntry) {
|
|
288
|
-
return `- ${routeKey(entry)} -> [${entry.permissions.join(
|
|
259
|
+
return `- ${routeKey(entry)} -> [${entry.permissions.join(', ')}]`;
|
|
289
260
|
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from "@casl/ability";
|
|
6
|
-
import { Injectable } from "@nestjs/common";
|
|
7
|
-
import { permissionKeyToRule } from "../../types/permission-key";
|
|
8
|
-
import { RbacContext } from "../../types/rbac-context";
|
|
1
|
+
import { AbilityBuilder, createMongoAbility, MongoAbility } from '@casl/ability';
|
|
2
|
+
import { Injectable } from '@nestjs/common';
|
|
3
|
+
import { permissionKeyToRule } from '../../types/permission-key';
|
|
4
|
+
import { RbacContext } from '../../types/rbac-context';
|
|
9
5
|
|
|
10
6
|
export type AppAbility = MongoAbility<[string, string]>;
|
|
11
7
|
|
|
@@ -15,7 +11,7 @@ export class AbilityFactory {
|
|
|
15
11
|
const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
|
|
16
12
|
|
|
17
13
|
if (context.isOwner) {
|
|
18
|
-
can(
|
|
14
|
+
can('manage', 'all');
|
|
19
15
|
}
|
|
20
16
|
|
|
21
17
|
for (const key of context.permissionKeys) {
|