Harden platform security, reliability, and CI after full audit.
Close deployment IDOR and gate stub payment endpoints, add production secret validation, health probes, Redis-backed build progress, GitHub Actions CI, expanded tests, billing/k8s refactors, and ops runbooks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { DeploymentsService } from './deployments.service';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { UserRole } from '../common/enums';
|
||||
|
||||
@ApiTags('Deployments')
|
||||
@ApiBearerAuth()
|
||||
@@ -18,6 +19,12 @@ import { RolesGuard } from '../common/guards/roles.guard';
|
||||
export class DeploymentsController {
|
||||
constructor(private readonly deploymentsService: DeploymentsService) {}
|
||||
|
||||
private ownershipUserId(req: { user: { id: string; role: string } }): string | undefined {
|
||||
const isStaff =
|
||||
req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||
return isStaff ? undefined : req.user.id;
|
||||
}
|
||||
|
||||
@Post('applications/:appId/deploy')
|
||||
@ApiOperation({ summary: 'Trigger a new deployment' })
|
||||
async triggerDeployment(@Param('appId') appId: string, @Request() req: any) {
|
||||
@@ -26,14 +33,14 @@ export class DeploymentsController {
|
||||
|
||||
@Get('applications/:appId')
|
||||
@ApiOperation({ summary: 'List deployments for an application' })
|
||||
async findByApplication(@Param('appId') appId: string) {
|
||||
return this.deploymentsService.findByApplication(appId);
|
||||
async findByApplication(@Param('appId') appId: string, @Request() req: any) {
|
||||
return this.deploymentsService.findByApplication(appId, this.ownershipUserId(req));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get deployment details' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.deploymentsService.findOne(id);
|
||||
async findOne(@Param('id') id: string, @Request() req: any) {
|
||||
return this.deploymentsService.findOne(id, this.ownershipUserId(req));
|
||||
}
|
||||
|
||||
@Get('applications/:appId/logs')
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { DeploymentsService } from './deployments.service';
|
||||
import { Deployment } from './entities/deployment.entity';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { BuildService } from '../build/build.service';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
|
||||
describe('DeploymentsService authorization', () => {
|
||||
let service: DeploymentsService;
|
||||
|
||||
const deploymentsRepository = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const applicationsService = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DeploymentsService,
|
||||
{ provide: getRepositoryToken(Deployment), useValue: deploymentsRepository },
|
||||
{ provide: ApplicationsService, useValue: applicationsService },
|
||||
{ provide: KubernetesService, useValue: {} },
|
||||
{ provide: BuildService, useValue: {} },
|
||||
{ provide: ClustersService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(DeploymentsService);
|
||||
});
|
||||
|
||||
describe('findByApplication', () => {
|
||||
it('verifies application ownership before listing deployments', async () => {
|
||||
const appId = 'app-1';
|
||||
const userId = 'user-1';
|
||||
const deployments = [{ id: 'd-1', applicationId: appId }] as Deployment[];
|
||||
|
||||
applicationsService.findOne.mockResolvedValue({ id: appId, userId });
|
||||
deploymentsRepository.find.mockResolvedValue(deployments);
|
||||
|
||||
const result = await service.findByApplication(appId, userId);
|
||||
|
||||
expect(applicationsService.findOne).toHaveBeenCalledWith(appId, userId);
|
||||
expect(result).toEqual(deployments);
|
||||
});
|
||||
|
||||
it('propagates NotFoundException when user does not own the app', async () => {
|
||||
applicationsService.findOne.mockRejectedValue(new NotFoundException('Application not found'));
|
||||
|
||||
await expect(service.findByApplication('app-1', 'other-user')).rejects.toThrow(NotFoundException);
|
||||
expect(deploymentsRepository.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('verifies application ownership before returning deployment', async () => {
|
||||
const deployment = {
|
||||
id: 'd-1',
|
||||
applicationId: 'app-1',
|
||||
application: { id: 'app-1' },
|
||||
} as Deployment;
|
||||
|
||||
deploymentsRepository.findOne.mockResolvedValue(deployment);
|
||||
applicationsService.findOne.mockResolvedValue({ id: 'app-1', userId: 'user-1' });
|
||||
|
||||
const result = await service.findOne('d-1', 'user-1');
|
||||
|
||||
expect(applicationsService.findOne).toHaveBeenCalledWith('app-1', 'user-1');
|
||||
expect(result).toBe(deployment);
|
||||
});
|
||||
|
||||
it('throws when deployment does not exist', async () => {
|
||||
deploymentsRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('missing', 'user-1')).rejects.toThrow(NotFoundException);
|
||||
expect(applicationsService.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -463,14 +463,15 @@ export class DeploymentsService {
|
||||
}
|
||||
}
|
||||
|
||||
async findByApplication(applicationId: string): Promise<Deployment[]> {
|
||||
async findByApplication(applicationId: string, userId?: string): Promise<Deployment[]> {
|
||||
await this.applicationsService.findOne(applicationId, userId);
|
||||
return this.deploymentsRepository.find({
|
||||
where: { applicationId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Deployment> {
|
||||
async findOne(id: string, userId?: string): Promise<Deployment> {
|
||||
const deployment = await this.deploymentsRepository.findOne({
|
||||
where: { id },
|
||||
relations: { application: true },
|
||||
@@ -478,6 +479,7 @@ export class DeploymentsService {
|
||||
if (!deployment) {
|
||||
throw new NotFoundException('Deployment not found');
|
||||
}
|
||||
await this.applicationsService.findOne(deployment.applicationId, userId);
|
||||
return deployment;
|
||||
}
|
||||
|
||||
@@ -537,7 +539,7 @@ export class DeploymentsService {
|
||||
|
||||
if (!latest) return null;
|
||||
|
||||
const progress = this.buildService.getProgress(latest.id);
|
||||
const progress = await this.buildService.getProgress(latest.id);
|
||||
if (progress) return progress;
|
||||
|
||||
// No in-memory progress — infer from deployment status
|
||||
|
||||
Reference in New Issue
Block a user