fix: close admin user security gaps
This commit is contained in:
@@ -98,6 +98,17 @@ describe('admin user DTOs', () => {
|
||||
expect(await validate(status)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, '1'])(
|
||||
'rejects non-number role and status bodies: %p',
|
||||
async (value) => {
|
||||
const role = plainToInstance(AdminUserRoleDto, { role: value });
|
||||
const status = plainToInstance(AdminUserStatusDto, { status: value });
|
||||
|
||||
expect(await validate(role)).not.toEqual([]);
|
||||
expect(await validate(status)).not.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('validates player assignment filters and pagination bounds', async () => {
|
||||
const valid = plainToInstance(AdminPlayerQueryDto, {
|
||||
assignment: 'assigned',
|
||||
|
||||
173
myteamwallet_backend/src/users/admin-users.http.spec.ts
Normal file
173
myteamwallet_backend/src/users/admin-users.http.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
INestApplication,
|
||||
UnauthorizedException,
|
||||
ValidationPipe,
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as request from 'supertest';
|
||||
import { useContainer } from 'class-validator';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuthController } from '../auth/auth.controller';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import validationOptions from '../utils/validation-options';
|
||||
import { IsNotExist } from '../utils/validators/is-not-exists.validator';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
describe('admin user HTTP security boundary', () => {
|
||||
let app: INestApplication;
|
||||
const safeUser = {
|
||||
id: 2,
|
||||
firstName: 'Target',
|
||||
lastName: 'User',
|
||||
email: 'target@example.com',
|
||||
role: { id: RoleEnum.user, name: 'User' },
|
||||
status: { id: 1, name: 'Active' },
|
||||
assignments: [],
|
||||
};
|
||||
const adminService = {
|
||||
updateProfile: jest.fn(() => safeUser),
|
||||
updateRole: jest.fn(() => safeUser),
|
||||
updateStatus: jest.fn(() => safeUser),
|
||||
findPlayers: jest.fn(),
|
||||
assignPlayer: jest.fn(),
|
||||
unlinkPlayer: jest.fn(),
|
||||
};
|
||||
const usersService = {
|
||||
findDirectory: jest.fn(),
|
||||
findMyTeams: jest.fn(() => [
|
||||
{
|
||||
id: 11,
|
||||
firstName: 'Pat',
|
||||
lastName: 'Player',
|
||||
team: { id: 4, name: 'Alpha' },
|
||||
},
|
||||
]),
|
||||
};
|
||||
const authService = { register: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [AdminUsersController, UsersController, AuthController],
|
||||
providers: [
|
||||
Reflector,
|
||||
RolesGuard,
|
||||
IsNotExist,
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: {
|
||||
getRepository: () => ({ findOne: jest.fn(() => undefined) }),
|
||||
},
|
||||
},
|
||||
{ provide: AdminUsersService, useValue: adminService },
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard('jwt'))
|
||||
.useValue({
|
||||
canActivate(context) {
|
||||
const httpRequest = context.switchToHttp().getRequest();
|
||||
const token = httpRequest.headers.authorization;
|
||||
if (token === 'Bearer admin') {
|
||||
httpRequest.user = { id: 1, role: { id: RoleEnum.admin } };
|
||||
return true;
|
||||
}
|
||||
if (token === 'Bearer user') {
|
||||
httpRequest.user = { id: 7, role: { id: RoleEnum.user } };
|
||||
return true;
|
||||
}
|
||||
throw new UnauthorizedException();
|
||||
},
|
||||
})
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
useContainer(app, { fallbackOnErrors: true });
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableVersioning({ type: VersioningType.URI });
|
||||
app.useGlobalPipes(new ValidationPipe(validationOptions));
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(() => app.close());
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('enforces JWT and current global admin role on an admin mutation', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/users/2/role')
|
||||
.send({ role: RoleEnum.user })
|
||||
.expect(401);
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/users/2/role')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({ role: RoleEnum.user })
|
||||
.expect(403);
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/users/2/role')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.send({ role: RoleEnum.user })
|
||||
.expect(200, safeUser);
|
||||
});
|
||||
|
||||
it.each([true, '1'])(
|
||||
'rejects non-numeric role JSON at the HTTP validation boundary: %p',
|
||||
async (role) => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/users/2/role')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.send({ role })
|
||||
.expect(422);
|
||||
expect(adminService.updateRole).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('strips public player assignment input from registration', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/email/register')
|
||||
.send({
|
||||
email: 'new@example.com',
|
||||
password: 'password',
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
linkPlayerId: 101,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(authService.register).toHaveBeenCalledWith({
|
||||
email: 'new@example.com',
|
||||
password: 'password',
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
});
|
||||
});
|
||||
|
||||
it('restricts team bootstrap to self and serializes only the safe projection', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/users/8/teams')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(403);
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/users/7/teams')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
id: 11,
|
||||
firstName: 'Pat',
|
||||
lastName: 'Player',
|
||||
team: { id: 4, name: 'Alpha' },
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(response.body)).not.toMatch(
|
||||
/provider|socialId|password|hash|balance|createdAt|updatedAt/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,13 +24,13 @@ export class AdminUserProfileDto {
|
||||
}
|
||||
|
||||
export class AdminUserRoleDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsIn([RoleEnum.admin, RoleEnum.user])
|
||||
role: RoleEnum;
|
||||
}
|
||||
|
||||
export class AdminUserStatusDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsIn([StatusEnum.active, StatusEnum.inactive])
|
||||
status: StatusEnum;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Role } from '../../roles/entities/role.entity';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
MinLength,
|
||||
Validate,
|
||||
} from 'class-validator';
|
||||
import { IsEmail, IsNotEmpty, MinLength, Validate } from 'class-validator';
|
||||
import { Status } from '../../statuses/entities/status.entity';
|
||||
import { IsNotExist } from '../../utils/validators/is-not-exists.validator';
|
||||
import { IsExist } from '../../utils/validators/is-exists.validator';
|
||||
@@ -51,6 +45,4 @@ export class CreateUserDto {
|
||||
status?: Status;
|
||||
|
||||
hash?: string | null;
|
||||
|
||||
linkPlayerId?: number | null;
|
||||
}
|
||||
|
||||
11
myteamwallet_backend/src/users/dto/user-team-response.dto.ts
Normal file
11
myteamwallet_backend/src/users/dto/user-team-response.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class UserTeamReferenceDto {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class UserTeamPlayerDto {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
team: UserTeamReferenceDto;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UsersController } from './users.controller';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
describe('UsersController admin mutation isolation', () => {
|
||||
it('does not expose generic create, update, or delete handlers that bypass safeguards', () => {
|
||||
@@ -8,4 +9,17 @@ describe('UsersController admin mutation isolation', () => {
|
||||
expect(UsersController.prototype).not.toHaveProperty('findAll');
|
||||
expect(UsersController.prototype).not.toHaveProperty('findOne');
|
||||
});
|
||||
|
||||
it('restricts team bootstrap to the authenticated user id', async () => {
|
||||
const service = { findMyTeams: jest.fn(() => []) };
|
||||
const controller = new UsersController(service as any);
|
||||
const request = { user: { id: 7, role: { id: 2 } } };
|
||||
|
||||
expect(() => (controller as any).findTeamsOfPlayer(request, 8)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
await (controller as any).findTeamsOfPlayer(request, 7);
|
||||
|
||||
expect(service.findMyTeams).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
HttpStatus,
|
||||
HttpCode,
|
||||
Request,
|
||||
ForbiddenException,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
@@ -40,7 +42,13 @@ export class UsersController {
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@Get(':id/teams')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
findTeamsOfPlayer(@Param('id') id: string) {
|
||||
return this.usersService.findTeams({ id: +id });
|
||||
findTeamsOfPlayer(
|
||||
@Request() request: { user: Pick<User, 'id' | 'role'> },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
if (request.user.id !== id) {
|
||||
throw new ForbiddenException('Users may only load their own teams');
|
||||
}
|
||||
return this.usersService.findMyTeams(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
UserDirectorySummaryDto,
|
||||
} from './dto/user-directory-response.dto';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UserTeamPlayerDto } from './dto/user-team-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -113,33 +114,32 @@ export class UsersService {
|
||||
await this.usersRepository.softDelete(id);
|
||||
}
|
||||
|
||||
async findTeams(fields: EntityCondition<User>) {
|
||||
const user = await this.findOne(fields);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const players = await this.playersRepository.find({
|
||||
where: {
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
relations: ['team'],
|
||||
});
|
||||
return players;
|
||||
}
|
||||
|
||||
async linkPlayerToUserId(user: User, playerId: number): Promise<boolean> {
|
||||
return new Promise<boolean>(async (resolve) => {
|
||||
const player = await this.playersRepository.findOneByOrFail({
|
||||
id: playerId,
|
||||
});
|
||||
|
||||
player.user = user;
|
||||
await this.playersRepository.save(player);
|
||||
return resolve(true);
|
||||
});
|
||||
async findMyTeams(userId: number): Promise<UserTeamPlayerDto[]> {
|
||||
const rows = await this.playersRepository
|
||||
.createQueryBuilder('player')
|
||||
.innerJoin('player.team', 'team')
|
||||
.select([
|
||||
'player.id AS player_id',
|
||||
'player.firstName AS first_name',
|
||||
'player.lastName AS last_name',
|
||||
'team.id AS team_id',
|
||||
'team.name AS team_name',
|
||||
])
|
||||
.where('player.userId = :userId', { userId })
|
||||
.orderBy('player.id', 'ASC')
|
||||
.getRawMany<{
|
||||
player_id: number | string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
team_id: number | string;
|
||||
team_name: string;
|
||||
}>();
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.player_id),
|
||||
firstName: row.first_name,
|
||||
lastName: row.last_name,
|
||||
team: { id: Number(row.team_id), name: row.team_name },
|
||||
}));
|
||||
}
|
||||
|
||||
private createSharedTeamsQuery(requesterId: number) {
|
||||
|
||||
49
myteamwallet_backend/src/users/users.teams.spec.ts
Normal file
49
myteamwallet_backend/src/users/users.teams.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
describe('UsersService safe current-user teams', () => {
|
||||
it('returns only the player and team fields required by team bootstrap', async () => {
|
||||
const rows = [
|
||||
{
|
||||
player_id: 11,
|
||||
first_name: 'Pat',
|
||||
last_name: 'Player',
|
||||
team_id: 4,
|
||||
team_name: 'Alpha',
|
||||
user_id: 7,
|
||||
provider: 'google',
|
||||
social_id: 'must-not-leak',
|
||||
balance: '100.00',
|
||||
},
|
||||
];
|
||||
const query = chain({ getRawMany: jest.fn(() => rows) });
|
||||
const playersRepository = {
|
||||
createQueryBuilder: jest.fn(() => query),
|
||||
};
|
||||
const service = new UsersService({} as any, playersRepository as any);
|
||||
|
||||
const result = await service.findMyTeams(7);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 11,
|
||||
firstName: 'Pat',
|
||||
lastName: 'Player',
|
||||
team: { id: 4, name: 'Alpha' },
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toMatch(
|
||||
/user|provider|social|role|status|password|hash|balance|createdAt|updatedAt/i,
|
||||
);
|
||||
expect(query.where).toHaveBeenCalledWith('player.userId = :userId', {
|
||||
userId: 7,
|
||||
});
|
||||
});
|
||||
|
||||
function chain(overrides: Record<string, jest.Mock>) {
|
||||
const query: Record<string, jest.Mock> = {};
|
||||
['innerJoin', 'select', 'where', 'orderBy'].forEach((method) => {
|
||||
query[method] = jest.fn(() => query);
|
||||
});
|
||||
return Object.assign(query, overrides);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user