@gadmin2n/schematics 0.0.157 → 0.0.158

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.
Files changed (16) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +2 -1
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.controller.ts +2 -2
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/transfer-export.decorator.spec.ts +34 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/transfer-export.decorator.ts +42 -0
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.controller.ts +2 -2
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.controller.ts +2 -2
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.ts +2 -2
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/require-user-group.decorator.spec.ts +23 -0
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/require-user-group.decorator.ts +15 -0
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.guard.spec.ts +90 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.guard.ts +51 -0
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.service.spec.ts +18 -0
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.service.ts +7 -0
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/userGroup.module.ts +3 -2
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +2 -1
  16. package/package.json +1 -1
@@ -18,6 +18,7 @@ import { ApiTags } from '@nestjs/swagger';
18
18
  import { UseRoles } from 'nest-access-control';
19
19
  import { PrismaService } from 'nestjs-prisma';
20
20
  import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
21
+ import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
21
22
  import { ImportPayloadDto } from './canvas-export.dto';
22
23
  import { CanvasExportService } from './canvas-export.service';
23
24
  import {
@@ -149,7 +150,7 @@ export class CanvasController {
149
150
  }
150
151
 
151
152
  @Get('export/:code')
152
- @AllowUnauthorizedRequest()
153
+ @AllowTransferExportRequest()
153
154
  async getExportPayload(
154
155
  @Param('code') code: string,
155
156
  @Query('token') token: string,
@@ -46,9 +46,9 @@ import {
46
46
  } from '../../generated/page/dto/page-types.dto';
47
47
  import { UpdatePageDto } from '../../generated/page/dto/update-page.dto';
48
48
  import { PageEntity } from '../../generated/page/entities/page.entity';
49
- import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
50
49
  import { PageTransferService } from '../permTransfer/page-transfer.service';
51
50
  import { ImportPayloadDto } from '../permTransfer/perm-transfer.dto';
51
+ import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
52
52
  import { PageService } from './page.service';
53
53
 
54
54
  import { ConfigService } from '@nestjs/config';
@@ -248,7 +248,7 @@ export class PageController {
248
248
  }
249
249
 
250
250
  @Get('export/:code')
251
- @AllowUnauthorizedRequest()
251
+ @AllowTransferExportRequest()
252
252
  async getExportPayload(
253
253
  @Param('code') code: string,
254
254
  @Query('token') token: string,
@@ -0,0 +1,34 @@
1
+ import { setCredentialedExportCorsHeaders } from './transfer-export.decorator';
2
+
3
+ describe('setCredentialedExportCorsHeaders', () => {
4
+ it('reflects the request origin for credentialed export responses', () => {
5
+ const response = {
6
+ setHeader: jest.fn(),
7
+ vary: jest.fn(),
8
+ };
9
+
10
+ setCredentialedExportCorsHeaders('https://target.example.com', response);
11
+
12
+ expect(response.setHeader).toHaveBeenCalledWith(
13
+ 'Access-Control-Allow-Origin',
14
+ 'https://target.example.com',
15
+ );
16
+ expect(response.setHeader).toHaveBeenCalledWith(
17
+ 'Access-Control-Allow-Credentials',
18
+ 'true',
19
+ );
20
+ expect(response.vary).toHaveBeenCalledWith('Origin');
21
+ });
22
+
23
+ it('leaves non-browser responses unchanged when Origin is absent', () => {
24
+ const response = {
25
+ setHeader: jest.fn(),
26
+ vary: jest.fn(),
27
+ };
28
+
29
+ setCredentialedExportCorsHeaders(undefined, response);
30
+
31
+ expect(response.setHeader).not.toHaveBeenCalled();
32
+ expect(response.vary).not.toHaveBeenCalled();
33
+ });
34
+ });
@@ -0,0 +1,42 @@
1
+ import {
2
+ applyDecorators,
3
+ NestInterceptor,
4
+ UseInterceptors,
5
+ } from '@nestjs/common';
6
+ import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
7
+
8
+ interface CorsResponse {
9
+ setHeader(name: string, value: string): unknown;
10
+ vary(field: string): unknown;
11
+ }
12
+
13
+ export function setCredentialedExportCorsHeaders(
14
+ origin: unknown,
15
+ response: CorsResponse,
16
+ ): void {
17
+ if (typeof origin !== 'string' || !origin) return;
18
+
19
+ response.setHeader('Access-Control-Allow-Origin', origin);
20
+ response.setHeader('Access-Control-Allow-Credentials', 'true');
21
+ response.vary('Origin');
22
+ }
23
+
24
+ const credentialedExportCorsInterceptor: NestInterceptor = {
25
+ intercept(context, next) {
26
+ const request = context.switchToHttp().getRequest();
27
+ const response = context.switchToHttp().getResponse();
28
+
29
+ setCredentialedExportCorsHeaders(request.headers.origin, response);
30
+ return next.handle();
31
+ },
32
+ };
33
+
34
+ /**
35
+ * Public export endpoints authenticate with their short-lived transfer token,
36
+ * while the browser may include its gateway cookie from any deployment origin.
37
+ */
38
+ export const AllowTransferExportRequest = () =>
39
+ applyDecorators(
40
+ AllowUnauthorizedRequest(),
41
+ UseInterceptors(credentialedExportCorsInterceptor),
42
+ );
@@ -46,9 +46,9 @@ import {
46
46
  } from '../../generated/resource/dto/resource-types.dto';
47
47
  import { UpdateResourceDto } from '../../generated/resource/dto/update-resource.dto';
48
48
  import { ResourceEntity } from '../../generated/resource/entities/resource.entity';
49
- import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
50
49
  import { ImportPayloadDto } from '../permTransfer/perm-transfer.dto';
51
50
  import { ResourceTransferService } from '../permTransfer/resource-transfer.service';
51
+ import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
52
52
  import { ResourceService } from './resource.service';
53
53
 
54
54
  import { ConfigService } from '@nestjs/config';
@@ -194,7 +194,7 @@ export class ResourceController {
194
194
  }
195
195
 
196
196
  @Get('export/:code')
197
- @AllowUnauthorizedRequest()
197
+ @AllowTransferExportRequest()
198
198
  async getExportPayload(
199
199
  @Param('code') code: string,
200
200
  @Query('token') token: string,
@@ -46,9 +46,9 @@ import {
46
46
  } from '../../generated/role/dto/role-types.dto';
47
47
  import { UpdateRoleDto } from '../../generated/role/dto/update-role.dto';
48
48
  import { RoleEntity } from '../../generated/role/entities/role.entity';
49
- import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
50
49
  import { ImportPayloadDto } from '../permTransfer/perm-transfer.dto';
51
50
  import { RoleTransferService } from '../permTransfer/role-transfer.service';
51
+ import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
52
52
  import { RoleService } from './role.service';
53
53
 
54
54
  import { ConfigService } from '@nestjs/config';
@@ -208,7 +208,7 @@ export class RoleController {
208
208
  }
209
209
 
210
210
  @Get('export/:name')
211
- @AllowUnauthorizedRequest()
211
+ @AllowTransferExportRequest()
212
212
  async getExportPayload(
213
213
  @Param('name') name: string,
214
214
  @Query('token') token: string,
@@ -46,8 +46,8 @@ import {
46
46
  UserWhereSelect,
47
47
  } from '../../generated/user/dto/user-types.dto';
48
48
  import { UserEntity } from '../../generated/user/entities/user.entity';
49
- import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
50
49
  import { ImportPayloadDto } from '../permTransfer/perm-transfer.dto';
50
+ import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
51
51
  import { UserTransferService } from '../permTransfer/user-transfer.service';
52
52
  import { UserService } from './user.service';
53
53
 
@@ -207,7 +207,7 @@ export class UserController {
207
207
  }
208
208
 
209
209
  @Get('export/:userid')
210
- @AllowUnauthorizedRequest()
210
+ @AllowTransferExportRequest()
211
211
  async getExportPayload(
212
212
  @Param('userid') userid: string,
213
213
  @Query('token') token: string,
@@ -0,0 +1,23 @@
1
+ import {
2
+ REQUIRED_USER_GROUP_KEY,
3
+ RequireUserGroup,
4
+ } from './require-user-group.decorator';
5
+
6
+ describe('RequireUserGroup', () => {
7
+ it('stores a normalized group code as route metadata', () => {
8
+ class Controller {
9
+ @RequireUserGroup(' it_ops ')
10
+ create() {}
11
+ }
12
+
13
+ expect(
14
+ Reflect.getMetadata(REQUIRED_USER_GROUP_KEY, Controller.prototype.create),
15
+ ).toBe('it_ops');
16
+ });
17
+
18
+ it('rejects an empty group code instead of disabling authorization', () => {
19
+ expect(() => RequireUserGroup(' ')).toThrow(
20
+ 'RequireUserGroup requires a non-empty group code',
21
+ );
22
+ });
23
+ });
@@ -0,0 +1,15 @@
1
+ import { SetMetadata } from '@nestjs/common';
2
+
3
+ export const REQUIRED_USER_GROUP_KEY = 'required_user_group';
4
+
5
+ /**
6
+ * Restricts an endpoint to authenticated users resolved from the given active
7
+ * user group. Nested active child groups are included by UserGroupService.
8
+ */
9
+ export function RequireUserGroup(groupCode: string) {
10
+ const normalizedGroupCode = groupCode.trim();
11
+ if (!normalizedGroupCode) {
12
+ throw new Error('RequireUserGroup requires a non-empty group code');
13
+ }
14
+ return SetMetadata(REQUIRED_USER_GROUP_KEY, normalizedGroupCode);
15
+ }
@@ -0,0 +1,90 @@
1
+ import {
2
+ ExecutionContext,
3
+ ForbiddenException,
4
+ UnauthorizedException,
5
+ } from '@nestjs/common';
6
+ import { Reflector } from '@nestjs/core';
7
+ import { UserGroupDomainError } from './user-group.errors';
8
+ import { UserGroupGuard } from './user-group.guard';
9
+ import { UserGroupService } from './user-group.service';
10
+
11
+ function context(user?: { userid?: string | number; username?: string }) {
12
+ return {
13
+ getHandler: () => function handler() {},
14
+ getClass: () => class Controller {},
15
+ switchToHttp: () => ({ getRequest: () => ({ user }) }),
16
+ } as unknown as ExecutionContext;
17
+ }
18
+
19
+ describe('UserGroupGuard', () => {
20
+ const reflector = {
21
+ getAllAndOverride: jest.fn(),
22
+ };
23
+ const groups = {
24
+ isUserInGroup: jest.fn(),
25
+ };
26
+ const guard = new UserGroupGuard(
27
+ reflector as unknown as Reflector,
28
+ groups as unknown as UserGroupService,
29
+ );
30
+
31
+ beforeEach(() => jest.clearAllMocks());
32
+
33
+ it('allows endpoints without a required user group', async () => {
34
+ reflector.getAllAndOverride.mockReturnValue(undefined);
35
+
36
+ await expect(guard.canActivate(context())).resolves.toBe(true);
37
+ expect(groups.isUserInGroup).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it('allows an authenticated member of the required group', async () => {
41
+ reflector.getAllAndOverride.mockReturnValue('it_ops');
42
+ groups.isUserInGroup.mockResolvedValue(true);
43
+
44
+ await expect(guard.canActivate(context({ userid: 'alice' }))).resolves.toBe(
45
+ true,
46
+ );
47
+ expect(groups.isUserInGroup).toHaveBeenCalledWith('it_ops', 'alice');
48
+ });
49
+
50
+ it('falls back to username when userid is unavailable', async () => {
51
+ reflector.getAllAndOverride.mockReturnValue('it_ops');
52
+ groups.isUserInGroup.mockResolvedValue(true);
53
+
54
+ await guard.canActivate(context({ username: 'alice' }));
55
+
56
+ expect(groups.isUserInGroup).toHaveBeenCalledWith('it_ops', 'alice');
57
+ });
58
+
59
+ it('rejects a request without an authenticated identity', async () => {
60
+ reflector.getAllAndOverride.mockReturnValue('it_ops');
61
+
62
+ await expect(guard.canActivate(context())).rejects.toBeInstanceOf(
63
+ UnauthorizedException,
64
+ );
65
+ });
66
+
67
+ it('rejects a user outside the required group', async () => {
68
+ reflector.getAllAndOverride.mockReturnValue('it_ops');
69
+ groups.isUserInGroup.mockResolvedValue(false);
70
+
71
+ await expect(
72
+ guard.canActivate(context({ userid: 'bob' })),
73
+ ).rejects.toBeInstanceOf(ForbiddenException);
74
+ });
75
+
76
+ it('fails closed when the configured group is missing', async () => {
77
+ reflector.getAllAndOverride.mockReturnValue('missing');
78
+ groups.isUserInGroup.mockRejectedValue(
79
+ new UserGroupDomainError(
80
+ 'USER_GROUP_NOT_FOUND',
81
+ 'User group not found',
82
+ 404,
83
+ ),
84
+ );
85
+
86
+ await expect(
87
+ guard.canActivate(context({ userid: 'alice' })),
88
+ ).rejects.toBeInstanceOf(ForbiddenException);
89
+ });
90
+ });
@@ -0,0 +1,51 @@
1
+ import {
2
+ CanActivate,
3
+ ExecutionContext,
4
+ ForbiddenException,
5
+ Injectable,
6
+ UnauthorizedException,
7
+ } from '@nestjs/common';
8
+ import { Reflector } from '@nestjs/core';
9
+ import { Request } from 'express';
10
+ import { UserGroupDomainError } from './user-group.errors';
11
+ import { REQUIRED_USER_GROUP_KEY } from './require-user-group.decorator';
12
+ import { UserGroupService } from './user-group.service';
13
+
14
+ type AuthenticatedRequest = Request & {
15
+ user?: {
16
+ userid?: string | number;
17
+ username?: string;
18
+ };
19
+ };
20
+
21
+ @Injectable()
22
+ export class UserGroupGuard implements CanActivate {
23
+ constructor(
24
+ private readonly reflector: Reflector,
25
+ private readonly groups: UserGroupService,
26
+ ) {}
27
+
28
+ async canActivate(context: ExecutionContext): Promise<boolean> {
29
+ const groupCode = this.reflector.getAllAndOverride<string>(
30
+ REQUIRED_USER_GROUP_KEY,
31
+ [context.getHandler(), context.getClass()],
32
+ );
33
+ if (!groupCode) return true;
34
+
35
+ const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
36
+ const userId = request.user?.userid ?? request.user?.username;
37
+ if (userId === undefined || String(userId).trim() === '') {
38
+ throw new UnauthorizedException('Authenticated user is required');
39
+ }
40
+
41
+ try {
42
+ if (await this.groups.isUserInGroup(groupCode, String(userId))) {
43
+ return true;
44
+ }
45
+ } catch (error) {
46
+ if (!(error instanceof UserGroupDomainError)) throw error;
47
+ }
48
+
49
+ throw new ForbiddenException('User is not a member of the required group');
50
+ }
51
+ }
@@ -78,6 +78,24 @@ describe('UserGroupService', () => {
78
78
  });
79
79
  });
80
80
 
81
+ it('checks membership using resolved active users', async () => {
82
+ const { service } = createHarness();
83
+ jest
84
+ .spyOn(service, 'resolveUsers')
85
+ .mockResolvedValue([{ userid: 'alice', username: 'Alice' }]);
86
+
87
+ await expect(service.isUserInGroup('finance', 'alice')).resolves.toBe(true);
88
+ await expect(service.isUserInGroup('finance', 'bob')).resolves.toBe(false);
89
+ });
90
+
91
+ it('rejects blank membership identities without resolving the group', async () => {
92
+ const { service } = createHarness();
93
+ const resolveUsers = jest.spyOn(service, 'resolveUsers');
94
+
95
+ await expect(service.isUserInGroup('finance', ' ')).resolves.toBe(false);
96
+ expect(resolveUsers).not.toHaveBeenCalled();
97
+ });
98
+
81
99
  it('replaces members and bumps revision', async () => {
82
100
  const { service, tx } = createHarness();
83
101
  tx.user.findMany.mockResolvedValue([{ userid: 'u1' }]);
@@ -334,6 +334,13 @@ export class UserGroupService {
334
334
  return [...users.values()].sort((a, b) => a.userid.localeCompare(b.userid));
335
335
  }
336
336
 
337
+ async isUserInGroup(code: string, userId: string): Promise<boolean> {
338
+ const normalizedUserId = userId.trim();
339
+ if (!normalizedUserId) return false;
340
+ const users = await this.resolveUsers(code);
341
+ return users.some((user) => user.userid === normalizedUserId);
342
+ }
343
+
337
344
  private async findGroup(tx: Transaction, code: string) {
338
345
  const group = await tx.userGroup.findUnique({ where: { code } });
339
346
  if (!group) throw userGroupNotFound(code);
@@ -1,10 +1,11 @@
1
1
  import { Module } from '@nestjs/common';
2
2
  import { UserGroupController } from './user-group.controller';
3
+ import { UserGroupGuard } from './user-group.guard';
3
4
  import { UserGroupService } from './user-group.service';
4
5
 
5
6
  @Module({
6
7
  controllers: [UserGroupController],
7
- providers: [UserGroupService],
8
- exports: [UserGroupService],
8
+ providers: [UserGroupService, UserGroupGuard],
9
+ exports: [UserGroupService, UserGroupGuard],
9
10
  })
10
11
  export class UserGroupModule {}
@@ -24,6 +24,7 @@ import { Request } from 'express';
24
24
  import { UseRoles } from 'nest-access-control';
25
25
  import { PrismaService } from 'nestjs-prisma';
26
26
  import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
27
+ import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
27
28
  import { TemporalService } from './temporal.service';
28
29
  import { listWorkflowDatabaseFunctions } from './workflow-database-function';
29
30
  import {
@@ -196,7 +197,7 @@ export class WorkflowController {
196
197
  }
197
198
 
198
199
  @Get('export/:code')
199
- @AllowUnauthorizedRequest()
200
+ @AllowTransferExportRequest()
200
201
  async getExportPayload(
201
202
  @Param('code') code: string,
202
203
  @Query('token') token: string,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gadmin2n/schematics",
3
- "version": "0.0.157",
3
+ "version": "0.0.158",
4
4
  "description": "Gadmin - modern, fast, powerful node.js web framework (@schematics)",
5
5
  "main": "dist/index.js",
6
6
  "files": [