@ftisindia/create-app 0.1.4 → 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 +10 -0
- package/template/src/config/app.config.ts +6 -1
- package/template/src/config/env.validation.ts +20 -0
- package/template/src/main.ts +13 -0
- package/template/src/modules/access-control/access-control.module.ts +2 -1
- package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +31 -0
- package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
- package/template/src/modules/organisations/application/services/organisations.service.ts +67 -1
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +51 -0
- package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
- package/template/test/frontend-bootstrap.spec.ts +181 -0
- package/template/test/route-registry.validator.spec.ts +12 -0
- package/template/test/security.e2e-spec.ts +134 -2
package/package.json
CHANGED
package/template/.env.example
CHANGED
|
@@ -21,6 +21,12 @@ AUTH_FACEBOOK_ENABLED=false
|
|
|
21
21
|
AUTH_MOBILE_OTP_ENABLED=false
|
|
22
22
|
AUTH_MAGIC_LINK_ENABLED=false
|
|
23
23
|
|
|
24
|
+
CORS_ENABLED=false
|
|
25
|
+
# Comma-separated exact browser origins, for example:
|
|
26
|
+
# CORS_ORIGINS=http://localhost:5173,https://app.example.com
|
|
27
|
+
CORS_ORIGINS=
|
|
28
|
+
CORS_CREDENTIALS=false
|
|
29
|
+
|
|
24
30
|
RBAC_CACHE_TTL_SECONDS=60
|
|
25
31
|
# Path is the only supported mode in this starter phase.
|
|
26
32
|
ORG_CONTEXT_MODE=path
|
package/template/README.md
CHANGED
|
@@ -78,6 +78,7 @@ GET /auth/google
|
|
|
78
78
|
GET /auth/google/callback
|
|
79
79
|
GET /auth/me
|
|
80
80
|
POST /organisations
|
|
81
|
+
GET /organisations/mine
|
|
81
82
|
GET /organisations/:orgId/memberships/me
|
|
82
83
|
GET /organisations/:orgId/memberships
|
|
83
84
|
PATCH /organisations/:orgId/memberships/:membershipId/status
|
|
@@ -91,6 +92,7 @@ POST /organisations/:orgId/invitations/:invitationId/revoke
|
|
|
91
92
|
POST /organisations/:orgId/invitations/:invitationId/resend
|
|
92
93
|
POST /invitations/accept
|
|
93
94
|
POST /invitations/decline
|
|
95
|
+
GET /organisations/:orgId/access-control/me
|
|
94
96
|
GET /organisations/:orgId/access-control/permissions
|
|
95
97
|
GET /organisations/:orgId/access-control/route-permissions
|
|
96
98
|
GET /organisations/:orgId/access-control/roles
|
|
@@ -107,6 +109,14 @@ GET /organisations/:orgId/sample/status
|
|
|
107
109
|
POST /organisations/:orgId/sample/echo
|
|
108
110
|
```
|
|
109
111
|
|
|
112
|
+
## Frontend Bootstrap
|
|
113
|
+
|
|
114
|
+
After login, call `GET /organisations/mine` to populate the organisation picker,
|
|
115
|
+
store the selected `orgId`, then call
|
|
116
|
+
`GET /organisations/:orgId/access-control/me`. Use the returned
|
|
117
|
+
`permissionKeys` for route, menu, and button visibility in the frontend. Backend
|
|
118
|
+
guards remain the source of truth for access control.
|
|
119
|
+
|
|
110
120
|
## Architecture Rules
|
|
111
121
|
|
|
112
122
|
- JWTs contain identity only. Organisation membership, roles, and permissions are read per request.
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { registerAs } from '@nestjs/config';
|
|
2
|
-
import { getEnv } from './env.validation';
|
|
2
|
+
import { getEnv, parseCorsOrigins } from './env.validation';
|
|
3
3
|
|
|
4
4
|
export default registerAs('app', () => ({
|
|
5
5
|
nodeEnv: getEnv().NODE_ENV,
|
|
6
6
|
port: getEnv().PORT,
|
|
7
|
+
cors: {
|
|
8
|
+
enabled: getEnv().CORS_ENABLED,
|
|
9
|
+
origins: parseCorsOrigins(getEnv().CORS_ORIGINS),
|
|
10
|
+
credentials: getEnv().CORS_CREDENTIALS,
|
|
11
|
+
},
|
|
7
12
|
}));
|
|
@@ -61,10 +61,23 @@ export const envSchema = z
|
|
|
61
61
|
AUTH_FACEBOOK_ENABLED: booleanFromEnv.default(false),
|
|
62
62
|
AUTH_MOBILE_OTP_ENABLED: booleanFromEnv.default(false),
|
|
63
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),
|
|
64
67
|
RBAC_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(60),
|
|
65
68
|
ORG_CONTEXT_MODE: z.literal('path').default('path'),
|
|
66
69
|
})
|
|
67
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
|
+
|
|
68
81
|
if (!env.AUTH_GOOGLE_ENABLED) {
|
|
69
82
|
return;
|
|
70
83
|
}
|
|
@@ -136,6 +149,13 @@ export function getEnv(): Env {
|
|
|
136
149
|
return validatedEnv;
|
|
137
150
|
}
|
|
138
151
|
|
|
152
|
+
export function parseCorsOrigins(value: string) {
|
|
153
|
+
return value
|
|
154
|
+
.split(',')
|
|
155
|
+
.map((origin) => origin.trim())
|
|
156
|
+
.filter(Boolean);
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
function requireHttpsInProduction(ctx: z.RefinementCtx, key: string, value: string) {
|
|
140
160
|
if (new URL(value).protocol === 'https:') {
|
|
141
161
|
return;
|
package/template/src/main.ts
CHANGED
|
@@ -8,6 +8,19 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|
|
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
|
|
|
@@ -7,11 +7,12 @@ import { AccessControlService } from './application/services/access-control.serv
|
|
|
7
7
|
import { PermissionGuard } from './application/services/permission.guard';
|
|
8
8
|
import { RbacCacheService } from './application/services/rbac-cache.service';
|
|
9
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,31 @@
|
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { PermissionKey, permissionKeys } from '../types/permission-key';
|
|
3
|
+
|
|
4
|
+
export class CurrentAccessControlResponseDto {
|
|
5
|
+
@ApiProperty({
|
|
6
|
+
example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
|
|
7
|
+
format: 'uuid',
|
|
8
|
+
})
|
|
9
|
+
orgId!: string;
|
|
10
|
+
|
|
11
|
+
@ApiProperty({
|
|
12
|
+
example: '0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3',
|
|
13
|
+
format: 'uuid',
|
|
14
|
+
})
|
|
15
|
+
membershipId!: string;
|
|
16
|
+
|
|
17
|
+
@ApiProperty({
|
|
18
|
+
example: 'f602c057-04f4-4ef8-8c84-1b7c62fbf8c5',
|
|
19
|
+
format: 'uuid',
|
|
20
|
+
})
|
|
21
|
+
roleId!: string;
|
|
22
|
+
|
|
23
|
+
@ApiProperty({ example: true })
|
|
24
|
+
isOwner!: boolean;
|
|
25
|
+
|
|
26
|
+
@ApiProperty({ example: true })
|
|
27
|
+
isBillingContact!: boolean;
|
|
28
|
+
|
|
29
|
+
@ApiProperty({ enum: permissionKeys, example: ['organisations.read', 'settings.read'] })
|
|
30
|
+
permissionKeys!: PermissionKey[];
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
|
2
|
+
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
|
3
|
+
import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
|
|
4
|
+
import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
|
|
5
|
+
import { CurrentUser } from '../../auth/presentation/current-user.decorator';
|
|
6
|
+
import { AuthenticatedUser } from '../../auth/types/authenticated-user';
|
|
7
|
+
import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
|
|
8
|
+
import { RbacCacheService } from '../application/services/rbac-cache.service';
|
|
9
|
+
import { CurrentAccessControlResponseDto } from '../dto/current-access-control-response.dto';
|
|
10
|
+
|
|
11
|
+
@ApiTags('Access control')
|
|
12
|
+
@ApiBearerAuth()
|
|
13
|
+
@ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
|
|
14
|
+
@ApiProtectedErrorResponses()
|
|
15
|
+
@Controller('organisations/:orgId/access-control')
|
|
16
|
+
@UseGuards(JwtAuthGuard, OrgScopeGuard)
|
|
17
|
+
export class CurrentAccessControlController {
|
|
18
|
+
constructor(private readonly rbacCache: RbacCacheService) {}
|
|
19
|
+
|
|
20
|
+
@Get('me')
|
|
21
|
+
@ApiOperation({
|
|
22
|
+
summary: "Return the current user's effective RBAC context for the organisation.",
|
|
23
|
+
})
|
|
24
|
+
@ApiOkResponse({
|
|
25
|
+
description: 'Effective access-control context.',
|
|
26
|
+
type: CurrentAccessControlResponseDto,
|
|
27
|
+
})
|
|
28
|
+
async me(@CurrentUser() user: AuthenticatedUser, @Param('orgId') orgId: string) {
|
|
29
|
+
const context = await this.rbacCache.getContext(user.id, orgId);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
orgId: context.orgId,
|
|
33
|
+
membershipId: context.membershipId,
|
|
34
|
+
roleId: context.roleId,
|
|
35
|
+
isOwner: context.isOwner,
|
|
36
|
+
isBillingContact: context.isBillingContact,
|
|
37
|
+
permissionKeys: context.permissionKeys,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { ConflictException, Injectable } from '@nestjs/common';
|
|
2
|
-
import { Prisma } from '@prisma/client';
|
|
2
|
+
import { MembershipStatus, OrganisationStatus, Prisma } from '@prisma/client';
|
|
3
|
+
import {
|
|
4
|
+
PaginationQueryDto,
|
|
5
|
+
resolvePageLimit,
|
|
6
|
+
toPage,
|
|
7
|
+
} from '../../../../common/dto/pagination-query.dto';
|
|
3
8
|
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
4
9
|
import { RequestContextService } from '../../../request-context/application/services/request-context.service';
|
|
5
10
|
import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
|
|
@@ -18,6 +23,67 @@ export class OrganisationsService {
|
|
|
18
23
|
private readonly requestContext: RequestContextService,
|
|
19
24
|
) {}
|
|
20
25
|
|
|
26
|
+
async listMine(currentUser: AuthenticatedUser, query: PaginationQueryDto) {
|
|
27
|
+
const limit = resolvePageLimit(query.limit);
|
|
28
|
+
|
|
29
|
+
const rows = await this.prisma.membership.findMany({
|
|
30
|
+
where: {
|
|
31
|
+
userId: currentUser.id,
|
|
32
|
+
status: MembershipStatus.ACTIVE,
|
|
33
|
+
organisation: {
|
|
34
|
+
status: OrganisationStatus.ACTIVE,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
select: {
|
|
38
|
+
id: true,
|
|
39
|
+
roleId: true,
|
|
40
|
+
isOwner: true,
|
|
41
|
+
isBillingContact: true,
|
|
42
|
+
organisation: {
|
|
43
|
+
select: {
|
|
44
|
+
id: true,
|
|
45
|
+
name: true,
|
|
46
|
+
slug: true,
|
|
47
|
+
status: true,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
role: {
|
|
51
|
+
select: {
|
|
52
|
+
id: true,
|
|
53
|
+
name: true,
|
|
54
|
+
description: true,
|
|
55
|
+
isSystemSeeded: true,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }, { id: 'asc' }],
|
|
60
|
+
take: limit + 1,
|
|
61
|
+
...(query.cursor
|
|
62
|
+
? {
|
|
63
|
+
cursor: { id: query.cursor },
|
|
64
|
+
skip: 1,
|
|
65
|
+
}
|
|
66
|
+
: {}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const page = toPage(rows, limit);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
items: page.items.map((membership) => ({
|
|
73
|
+
id: membership.organisation.id,
|
|
74
|
+
name: membership.organisation.name,
|
|
75
|
+
slug: membership.organisation.slug,
|
|
76
|
+
status: membership.organisation.status,
|
|
77
|
+
membershipId: membership.id,
|
|
78
|
+
roleId: membership.roleId,
|
|
79
|
+
role: membership.role,
|
|
80
|
+
isOwner: membership.isOwner,
|
|
81
|
+
isBillingContact: membership.isBillingContact,
|
|
82
|
+
})),
|
|
83
|
+
nextCursor: page.nextCursor,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
21
87
|
async createOrganisation(currentUser: AuthenticatedUser, dto: CreateOrganisationDto) {
|
|
22
88
|
const name = dto.name.trim();
|
|
23
89
|
const slug = dto.slug?.trim() || (await this.createAvailableSlug(name));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ApiProperty } from '@nestjs/swagger';
|
|
2
2
|
import { OrganisationStatus } from '@prisma/client';
|
|
3
3
|
import { MembershipResponseDto } from '../../../common/dto/membership-response.dto';
|
|
4
|
+
import { RoleSummaryDto } from '../../../common/dto/role-summary.dto';
|
|
4
5
|
|
|
5
6
|
export class OrganisationResponseDto {
|
|
6
7
|
@ApiProperty({
|
|
@@ -60,3 +61,53 @@ export class CreateOrganisationResponseDto {
|
|
|
60
61
|
@ApiProperty({ type: [SeededOrganisationSettingDto] })
|
|
61
62
|
settings!: SeededOrganisationSettingDto[];
|
|
62
63
|
}
|
|
64
|
+
|
|
65
|
+
export class MyOrganisationResponseDto {
|
|
66
|
+
@ApiProperty({
|
|
67
|
+
example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
|
|
68
|
+
format: 'uuid',
|
|
69
|
+
})
|
|
70
|
+
id!: string;
|
|
71
|
+
|
|
72
|
+
@ApiProperty({ example: 'Acme Operations' })
|
|
73
|
+
name!: string;
|
|
74
|
+
|
|
75
|
+
@ApiProperty({ example: 'acme-operations' })
|
|
76
|
+
slug!: string;
|
|
77
|
+
|
|
78
|
+
@ApiProperty({ enum: OrganisationStatus, example: OrganisationStatus.ACTIVE })
|
|
79
|
+
status!: OrganisationStatus;
|
|
80
|
+
|
|
81
|
+
@ApiProperty({
|
|
82
|
+
example: '0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3',
|
|
83
|
+
format: 'uuid',
|
|
84
|
+
})
|
|
85
|
+
membershipId!: string;
|
|
86
|
+
|
|
87
|
+
@ApiProperty({
|
|
88
|
+
example: 'f602c057-04f4-4ef8-8c84-1b7c62fbf8c5',
|
|
89
|
+
format: 'uuid',
|
|
90
|
+
})
|
|
91
|
+
roleId!: string;
|
|
92
|
+
|
|
93
|
+
@ApiProperty({ type: RoleSummaryDto })
|
|
94
|
+
role!: RoleSummaryDto;
|
|
95
|
+
|
|
96
|
+
@ApiProperty({ example: true })
|
|
97
|
+
isOwner!: boolean;
|
|
98
|
+
|
|
99
|
+
@ApiProperty({ example: true })
|
|
100
|
+
isBillingContact!: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class MyOrganisationListResponseDto {
|
|
104
|
+
@ApiProperty({ type: [MyOrganisationResponseDto] })
|
|
105
|
+
items!: MyOrganisationResponseDto[];
|
|
106
|
+
|
|
107
|
+
@ApiProperty({
|
|
108
|
+
example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
|
|
109
|
+
format: 'uuid',
|
|
110
|
+
nullable: true,
|
|
111
|
+
})
|
|
112
|
+
nextCursor!: string | null;
|
|
113
|
+
}
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
|
2
|
-
import {
|
|
1
|
+
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
|
2
|
+
import {
|
|
3
|
+
ApiBearerAuth,
|
|
4
|
+
ApiCreatedResponse,
|
|
5
|
+
ApiOkResponse,
|
|
6
|
+
ApiOperation,
|
|
7
|
+
ApiTags,
|
|
8
|
+
} from '@nestjs/swagger';
|
|
9
|
+
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
|
3
10
|
import { ApiErrorResponses } from '../../../common/swagger/api-error-responses';
|
|
4
11
|
import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
|
|
5
12
|
import { CurrentUser } from '../../auth/presentation/current-user.decorator';
|
|
6
13
|
import { AuthenticatedUser } from '../../auth/types/authenticated-user';
|
|
7
14
|
import { OrganisationsService } from '../application/services/organisations.service';
|
|
8
15
|
import { CreateOrganisationDto } from '../dto/create-organisation.dto';
|
|
9
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
CreateOrganisationResponseDto,
|
|
18
|
+
MyOrganisationListResponseDto,
|
|
19
|
+
} from '../dto/organisation-response.dto';
|
|
10
20
|
|
|
11
21
|
@ApiTags('Organisations')
|
|
12
22
|
@ApiBearerAuth()
|
|
@@ -14,6 +24,18 @@ import { CreateOrganisationResponseDto } from '../dto/organisation-response.dto'
|
|
|
14
24
|
export class OrganisationsController {
|
|
15
25
|
constructor(private readonly organisationsService: OrganisationsService) {}
|
|
16
26
|
|
|
27
|
+
@Get('mine')
|
|
28
|
+
@UseGuards(JwtAuthGuard)
|
|
29
|
+
@ApiOperation({ summary: 'List active organisations for the current user.' })
|
|
30
|
+
@ApiOkResponse({
|
|
31
|
+
description: 'Current user organisations.',
|
|
32
|
+
type: MyOrganisationListResponseDto,
|
|
33
|
+
})
|
|
34
|
+
@ApiErrorResponses(400, 401)
|
|
35
|
+
mine(@CurrentUser() user: AuthenticatedUser, @Query() query: PaginationQueryDto) {
|
|
36
|
+
return this.organisationsService.listMine(user, query);
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
@Post()
|
|
18
40
|
@UseGuards(JwtAuthGuard)
|
|
19
41
|
@ApiOperation({ summary: 'Create an organisation for the current user.' })
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { MembershipStatus, OrganisationStatus } from '@prisma/client';
|
|
2
|
+
import { validate, parseCorsOrigins } from '../src/config/env.validation';
|
|
3
|
+
import { CurrentAccessControlController } from '../src/modules/access-control/presentation/current-access-control.controller';
|
|
4
|
+
import { OrganisationsService } from '../src/modules/organisations/application/services/organisations.service';
|
|
5
|
+
|
|
6
|
+
describe('frontend bootstrap endpoints', () => {
|
|
7
|
+
it('returns the current effective access-control context without internal cache fields', async () => {
|
|
8
|
+
const controller = new CurrentAccessControlController({
|
|
9
|
+
getContext: jest.fn().mockResolvedValue({
|
|
10
|
+
cacheKey: 'rbac:user-1:org-1',
|
|
11
|
+
userId: 'user-1',
|
|
12
|
+
orgId: 'org-1',
|
|
13
|
+
membershipId: 'membership-1',
|
|
14
|
+
membershipStatus: MembershipStatus.ACTIVE,
|
|
15
|
+
roleId: 'role-1',
|
|
16
|
+
isOwner: false,
|
|
17
|
+
isBillingContact: true,
|
|
18
|
+
permissionKeys: ['organisations.read'],
|
|
19
|
+
}),
|
|
20
|
+
} as never);
|
|
21
|
+
|
|
22
|
+
await expect(
|
|
23
|
+
controller.me(
|
|
24
|
+
{
|
|
25
|
+
id: 'user-1',
|
|
26
|
+
email: 'user@example.com',
|
|
27
|
+
mobile: null,
|
|
28
|
+
displayName: 'User',
|
|
29
|
+
isActive: true,
|
|
30
|
+
},
|
|
31
|
+
'org-1',
|
|
32
|
+
),
|
|
33
|
+
).resolves.toEqual({
|
|
34
|
+
orgId: 'org-1',
|
|
35
|
+
membershipId: 'membership-1',
|
|
36
|
+
roleId: 'role-1',
|
|
37
|
+
isOwner: false,
|
|
38
|
+
isBillingContact: true,
|
|
39
|
+
permissionKeys: ['organisations.read'],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('queries only active current-user memberships in active organisations', async () => {
|
|
44
|
+
const prisma = {
|
|
45
|
+
membership: {
|
|
46
|
+
findMany: jest.fn().mockResolvedValue([
|
|
47
|
+
{
|
|
48
|
+
id: 'membership-1',
|
|
49
|
+
roleId: 'role-1',
|
|
50
|
+
isOwner: true,
|
|
51
|
+
isBillingContact: true,
|
|
52
|
+
organisation: {
|
|
53
|
+
id: 'org-1',
|
|
54
|
+
name: 'Acme Operations',
|
|
55
|
+
slug: 'acme',
|
|
56
|
+
status: OrganisationStatus.ACTIVE,
|
|
57
|
+
},
|
|
58
|
+
role: {
|
|
59
|
+
id: 'role-1',
|
|
60
|
+
name: 'Owner',
|
|
61
|
+
description: null,
|
|
62
|
+
isSystemSeeded: true,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'membership-2',
|
|
67
|
+
roleId: 'role-2',
|
|
68
|
+
isOwner: false,
|
|
69
|
+
isBillingContact: false,
|
|
70
|
+
organisation: {
|
|
71
|
+
id: 'org-2',
|
|
72
|
+
name: 'Beta Operations',
|
|
73
|
+
slug: 'beta',
|
|
74
|
+
status: OrganisationStatus.ACTIVE,
|
|
75
|
+
},
|
|
76
|
+
role: {
|
|
77
|
+
id: 'role-2',
|
|
78
|
+
name: 'Viewer',
|
|
79
|
+
description: null,
|
|
80
|
+
isSystemSeeded: true,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
]),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const service = new OrganisationsService({} as never, prisma as never, {} as never);
|
|
87
|
+
|
|
88
|
+
await expect(
|
|
89
|
+
service.listMine(
|
|
90
|
+
{
|
|
91
|
+
id: 'user-1',
|
|
92
|
+
email: 'user@example.com',
|
|
93
|
+
mobile: null,
|
|
94
|
+
displayName: 'User',
|
|
95
|
+
isActive: true,
|
|
96
|
+
},
|
|
97
|
+
{ limit: 1 },
|
|
98
|
+
),
|
|
99
|
+
).resolves.toEqual({
|
|
100
|
+
items: [
|
|
101
|
+
{
|
|
102
|
+
id: 'org-1',
|
|
103
|
+
name: 'Acme Operations',
|
|
104
|
+
slug: 'acme',
|
|
105
|
+
status: OrganisationStatus.ACTIVE,
|
|
106
|
+
membershipId: 'membership-1',
|
|
107
|
+
roleId: 'role-1',
|
|
108
|
+
role: {
|
|
109
|
+
id: 'role-1',
|
|
110
|
+
name: 'Owner',
|
|
111
|
+
description: null,
|
|
112
|
+
isSystemSeeded: true,
|
|
113
|
+
},
|
|
114
|
+
isOwner: true,
|
|
115
|
+
isBillingContact: true,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
nextCursor: 'membership-1',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(prisma.membership.findMany).toHaveBeenCalledWith(
|
|
122
|
+
expect.objectContaining({
|
|
123
|
+
where: {
|
|
124
|
+
userId: 'user-1',
|
|
125
|
+
status: MembershipStatus.ACTIVE,
|
|
126
|
+
organisation: {
|
|
127
|
+
status: OrganisationStatus.ACTIVE,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
take: 2,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('CORS environment config', () => {
|
|
137
|
+
it('is disabled by default', () => {
|
|
138
|
+
const env = validate(baseEnv());
|
|
139
|
+
|
|
140
|
+
expect(env.CORS_ENABLED).toBe(false);
|
|
141
|
+
expect(env.CORS_ORIGINS).toBe('');
|
|
142
|
+
expect(env.CORS_CREDENTIALS).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('accepts enabled CORS with exact allowed origins', () => {
|
|
146
|
+
const env = validate({
|
|
147
|
+
...baseEnv(),
|
|
148
|
+
CORS_ENABLED: 'true',
|
|
149
|
+
CORS_ORIGINS: 'http://localhost:5173, https://app.example.com',
|
|
150
|
+
CORS_CREDENTIALS: 'true',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(env.CORS_ENABLED).toBe(true);
|
|
154
|
+
expect(env.CORS_CREDENTIALS).toBe(true);
|
|
155
|
+
expect(parseCorsOrigins(env.CORS_ORIGINS)).toEqual([
|
|
156
|
+
'http://localhost:5173',
|
|
157
|
+
'https://app.example.com',
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('rejects wildcard origins when credentials are enabled', () => {
|
|
162
|
+
expect(() =>
|
|
163
|
+
validate({
|
|
164
|
+
...baseEnv(),
|
|
165
|
+
CORS_ENABLED: 'true',
|
|
166
|
+
CORS_ORIGINS: '*',
|
|
167
|
+
CORS_CREDENTIALS: 'true',
|
|
168
|
+
}),
|
|
169
|
+
).toThrow(/CORS_ORIGINS cannot include \*/u);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
function baseEnv() {
|
|
174
|
+
return {
|
|
175
|
+
NODE_ENV: 'test',
|
|
176
|
+
PORT: '3000',
|
|
177
|
+
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/app_test',
|
|
178
|
+
JWT_SECRET: 'test-jwt-secret-at-least-32-characters',
|
|
179
|
+
AUTH_GOOGLE_ENABLED: 'false',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Controller, Get } from '@nestjs/common';
|
|
2
2
|
import { MetadataScanner, Reflector } from '@nestjs/core';
|
|
3
3
|
import { AccessControlController } from '../src/modules/access-control/presentation/access-control.controller';
|
|
4
|
+
import { CurrentAccessControlController } from '../src/modules/access-control/presentation/current-access-control.controller';
|
|
4
5
|
import { RequirePermissions } from '../src/modules/access-control/presentation/permissions.decorator';
|
|
5
6
|
import { RouteRegistryValidator } from '../src/modules/access-control/application/route-registry.validator';
|
|
6
7
|
import { PermissionKey } from '../src/modules/access-control/types/permission-key';
|
|
8
|
+
import { routePermissionRegistry } from '../src/modules/access-control/types/route-permission-registry';
|
|
7
9
|
import { AuditController } from '../src/modules/audit/presentation/audit.controller';
|
|
8
10
|
import { AuthController } from '../src/modules/auth/presentation/auth.controller';
|
|
9
11
|
import { InvitationsController } from '../src/modules/invitations/presentation/invitations.controller';
|
|
@@ -14,6 +16,7 @@ import { SettingsController } from '../src/modules/settings/presentation/setting
|
|
|
14
16
|
|
|
15
17
|
const controllers = [
|
|
16
18
|
AccessControlController,
|
|
19
|
+
CurrentAccessControlController,
|
|
17
20
|
AuditController,
|
|
18
21
|
AuthController,
|
|
19
22
|
InvitationsController,
|
|
@@ -28,6 +31,15 @@ describe('RouteRegistryValidator', () => {
|
|
|
28
31
|
expect(() => createValidator(controllers).onApplicationBootstrap()).not.toThrow();
|
|
29
32
|
});
|
|
30
33
|
|
|
34
|
+
it('does not register the current access-control bootstrap route as permission-gated', () => {
|
|
35
|
+
expect(routePermissionRegistry).not.toContainEqual(
|
|
36
|
+
expect.objectContaining({
|
|
37
|
+
method: 'GET',
|
|
38
|
+
path: '/organisations/:orgId/access-control/me',
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
31
43
|
it('fails when a permission-gated route is missing from the registry', () => {
|
|
32
44
|
class DriftController {
|
|
33
45
|
probe() {
|
|
@@ -72,7 +72,101 @@ describe('Security invariants (e2e)', () => {
|
|
|
72
72
|
.expect(403);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it('requires authentication for frontend bootstrap endpoints', async () => {
|
|
76
|
+
const { orgId } = await createUserAndOrg('bootstrap-auth');
|
|
77
|
+
|
|
78
|
+
await request(app.getHttpServer()).get('/organisations/mine').expect(401);
|
|
79
|
+
await request(app.getHttpServer()).get(`/organisations/${orgId}/access-control/me`).expect(401);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('lists only active current-user organisations with cursor pagination shape', async () => {
|
|
83
|
+
const first = await createUserAndOrg('mine-active');
|
|
84
|
+
const second = await createOrganisation(first.accessToken, 'mine-suspended');
|
|
85
|
+
|
|
86
|
+
await prisma.membership.update({
|
|
87
|
+
where: { id: second.membershipId },
|
|
88
|
+
data: { status: MembershipStatus.SUSPENDED },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const response = await request(app.getHttpServer())
|
|
92
|
+
.get('/organisations/mine')
|
|
93
|
+
.set('Authorization', `Bearer ${first.accessToken}`)
|
|
94
|
+
.expect(200);
|
|
95
|
+
|
|
96
|
+
expect(response.body).toEqual({
|
|
97
|
+
items: [
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
id: first.orgId,
|
|
100
|
+
membershipId: first.membershipId,
|
|
101
|
+
roleId: expect.any(String),
|
|
102
|
+
role: expect.objectContaining({ name: 'Owner' }),
|
|
103
|
+
isOwner: true,
|
|
104
|
+
isBillingContact: true,
|
|
105
|
+
}),
|
|
106
|
+
],
|
|
107
|
+
nextCursor: null,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns effective access-control context for an active member without roles.read', async () => {
|
|
112
|
+
const owner = await createUserAndOrg('access-me-owner');
|
|
113
|
+
const member = await createMemberInOrg(owner.orgId, 'access-me-member');
|
|
114
|
+
|
|
115
|
+
const response = await request(app.getHttpServer())
|
|
116
|
+
.get(`/organisations/${owner.orgId}/access-control/me`)
|
|
117
|
+
.set('Authorization', `Bearer ${member.accessToken}`)
|
|
118
|
+
.expect(200);
|
|
119
|
+
|
|
120
|
+
expect(response.body).toEqual({
|
|
121
|
+
orgId: owner.orgId,
|
|
122
|
+
membershipId: member.membershipId,
|
|
123
|
+
roleId: member.roleId,
|
|
124
|
+
isOwner: false,
|
|
125
|
+
isBillingContact: false,
|
|
126
|
+
permissionKeys: [],
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('denies access-control context to non-members', async () => {
|
|
131
|
+
const first = await createUserAndOrg('access-me-a');
|
|
132
|
+
const second = await createUserAndOrg('access-me-b');
|
|
133
|
+
|
|
134
|
+
await request(app.getHttpServer())
|
|
135
|
+
.get(`/organisations/${second.orgId}/access-control/me`)
|
|
136
|
+
.set('Authorization', `Bearer ${first.accessToken}`)
|
|
137
|
+
.expect(403);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it.each([MembershipStatus.SUSPENDED, MembershipStatus.REVOKED])(
|
|
141
|
+
'denies access-control context for %s memberships',
|
|
142
|
+
async (status) => {
|
|
143
|
+
const owner = await createUserAndOrg(`access-me-${status.toLowerCase()}`);
|
|
144
|
+
const member = await createMemberInOrg(owner.orgId, `member-${status.toLowerCase()}`);
|
|
145
|
+
|
|
146
|
+
await prisma.membership.update({
|
|
147
|
+
where: { id: member.membershipId },
|
|
148
|
+
data: { status },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await request(app.getHttpServer())
|
|
152
|
+
.get(`/organisations/${owner.orgId}/access-control/me`)
|
|
153
|
+
.set('Authorization', `Bearer ${member.accessToken}`)
|
|
154
|
+
.expect(403);
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
75
158
|
async function createUserAndOrg(label: string) {
|
|
159
|
+
const user = await createUser(label);
|
|
160
|
+
const org = await createOrganisation(user.accessToken, label);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
...user,
|
|
164
|
+
orgId: org.orgId,
|
|
165
|
+
membershipId: org.membershipId,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function createUser(label: string) {
|
|
76
170
|
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
77
171
|
const signup = await request(app.getHttpServer())
|
|
78
172
|
.post('/auth/signup')
|
|
@@ -83,7 +177,14 @@ describe('Security invariants (e2e)', () => {
|
|
|
83
177
|
})
|
|
84
178
|
.expect(201);
|
|
85
179
|
|
|
86
|
-
|
|
180
|
+
return {
|
|
181
|
+
accessToken: signup.body.accessToken as string,
|
|
182
|
+
userId: signup.body.user.id as string,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function createOrganisation(accessToken: string, label: string) {
|
|
187
|
+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
87
188
|
const org = await request(app.getHttpServer())
|
|
88
189
|
.post('/organisations')
|
|
89
190
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
@@ -94,9 +195,40 @@ describe('Security invariants (e2e)', () => {
|
|
|
94
195
|
.expect(201);
|
|
95
196
|
|
|
96
197
|
return {
|
|
97
|
-
accessToken,
|
|
98
198
|
orgId: org.body.organisation.id as string,
|
|
99
199
|
membershipId: org.body.membership.id as string,
|
|
100
200
|
};
|
|
101
201
|
}
|
|
202
|
+
|
|
203
|
+
async function createMemberInOrg(orgId: string, label: string) {
|
|
204
|
+
const user = await createUser(label);
|
|
205
|
+
const role = await prisma.role.findUniqueOrThrow({
|
|
206
|
+
where: {
|
|
207
|
+
orgId_name: {
|
|
208
|
+
orgId,
|
|
209
|
+
name: 'Viewer',
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
select: {
|
|
213
|
+
id: true,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
const membership = await prisma.membership.create({
|
|
217
|
+
data: {
|
|
218
|
+
userId: user.userId,
|
|
219
|
+
orgId,
|
|
220
|
+
roleId: role.id,
|
|
221
|
+
},
|
|
222
|
+
select: {
|
|
223
|
+
id: true,
|
|
224
|
+
roleId: true,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
...user,
|
|
230
|
+
membershipId: membership.id,
|
|
231
|
+
roleId: membership.roleId,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
102
234
|
});
|