Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24,819 changes: 11,294 additions & 13,525 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { UsageBillingModule } from './usage-billing/usage-billing.module';
import { RedisModule } from './redis/redis.module';
import { CacheModule } from './common/cache/cache.module';
import { CacheMiddleware } from './common/middleware/cache.middleware';
import { SecretsModule } from './secrets/secrets.module';

@Module({
imports: [
Expand Down Expand Up @@ -85,7 +86,7 @@ import { CacheMiddleware } from './common/middleware/cache.middleware';
RateLimitModule.forRoot(),
CacheWarmupModule,
EncryptionModule,
ApiSecurityModule, UsageBillingModule, RedisModule,
ApiSecurityModule, UsageBillingModule, RedisModule, SecretsModule,
],
providers: [
{
Expand Down
1 change: 1 addition & 0 deletions src/secrets/dto/create-secret.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class CreateSecretDto {}
4 changes: 4 additions & 0 deletions src/secrets/dto/update-secret.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateSecretDto } from './create-secret.dto';

export class UpdateSecretDto extends PartialType(CreateSecretDto) {}
1 change: 1 addition & 0 deletions src/secrets/entities/secret.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class Secret {}
32 changes: 32 additions & 0 deletions src/secrets/secrets-rotation.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { VaultIntegrationService } from './vault-integration.service';
import * as crypto from 'crypto';

@Injectable()
export class SecretsRotationService {
private readonly logger = new Logger(SecretsRotationService.name);

constructor(private readonly vaultService: VaultIntegrationService) {}

@Cron('0 */12 * * *')
async handleCron() {
this.logger.log('Starting scheduled secrets rotation.');
const secretPath = 'api-keys/external-service';

try {
// Generate a new, secure secret
const newApiKey = crypto.randomBytes(32).toString('hex');

// Store the new secret in Vault
await this.vaultService.writeSecret(secretPath, { apiKey: newApiKey });

this.logger.log(`Successfully rotated secret at path: ${secretPath}`);
} catch (error) {
this.logger.error(
`Failed to rotate secret at path: ${secretPath}`,
error.message,
);
}
}
}
20 changes: 20 additions & 0 deletions src/secrets/secrets.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SecretsController } from './secrets.controller';
import { SecretsService } from './secrets.service';

describe('SecretsController', () => {
let controller: SecretsController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SecretsController],
providers: [SecretsService],
}).compile();

controller = module.get<SecretsController>(SecretsController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
34 changes: 34 additions & 0 deletions src/secrets/secrets.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { SecretsService } from './secrets.service';
import { CreateSecretDto } from './dto/create-secret.dto';
import { UpdateSecretDto } from './dto/update-secret.dto';

@Controller('secrets')
export class SecretsController {
constructor(private readonly secretsService: SecretsService) {}

@Post()
create(@Body() createSecretDto: CreateSecretDto) {
return this.secretsService.create(createSecretDto);
}

@Get()
findAll() {
return this.secretsService.findAll();
}

@Get(':id')
findOne(@Param('id') id: string) {
return this.secretsService.findOne(+id);
}

@Patch(':id')
update(@Param('id') id: string, @Body() updateSecretDto: UpdateSecretDto) {
return this.secretsService.update(+id, updateSecretDto);
}

@Delete(':id')
remove(@Param('id') id: string) {
return this.secretsService.remove(+id);
}
}
12 changes: 12 additions & 0 deletions src/secrets/secrets.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { SecretsService } from './secrets.service';
import { VaultIntegrationService } from './vault-integration.service';
import { SecretsRotationService } from './secrets-rotation.service';

@Module({
imports: [ScheduleModule.forRoot()],
providers: [VaultIntegrationService, SecretsRotationService, SecretsService],
exports: [SecretsService],
})
export class SecretsModule {}
18 changes: 18 additions & 0 deletions src/secrets/secrets.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SecretsService } from './secrets.service';

describe('SecretsService', () => {
let service: SecretsService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SecretsService],
}).compile();

service = module.get<SecretsService>(SecretsService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
41 changes: 41 additions & 0 deletions src/secrets/secrets.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { VaultIntegrationService } from './vault-integration.service';

@Injectable()
export class SecretsService {
private readonly logger = new Logger(SecretsService.name);

constructor(private readonly vaultService: VaultIntegrationService) {}

private checkAccess(userId: string, requiredRole: string): boolean {
const userRoles = { 'user-123': ['admin'] };
const hasPermission = userRoles[userId]?.includes(requiredRole);
if (!hasPermission) {
this.logger.warn(
`Unauthorized access attempt for secret by user: ${userId}`,
);
}
return hasPermission;
}

async getSecret(
userId: string,
path: string,
requiredRole: string,
): Promise<any> {
this.logger.log(
`Audit: User ${userId} attempting to access secret at ${path}`,
);
if (!this.checkAccess(userId, requiredRole)) {
throw new UnauthorizedException('Access denied to this secret.');
}

const secret = await this.vaultService.readSecret(path);

this.logger.log(
`Audit: User ${userId} successfully accessed secret at ${path}`,
);

return secret;
}
}
68 changes: 68 additions & 0 deletions src/secrets/vault-integration.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';

@Injectable()
export class VaultIntegrationService implements OnModuleInit {
private readonly logger = new Logger(VaultIntegrationService.name);
private vaultClient: AxiosInstance;

private readonly vaultApiUrl = 'http://127.0.0.1:8200/v1/secret';
private readonly vaultToken = 'my-vault-root-token';

async onModuleInit() {
this.vaultClient = axios.create({
baseURL: this.vaultApiUrl,
headers: { 'X-Vault-Token': this.vaultToken },
});

try {
await this.vaultClient.get('/health');
this.logger.log('Successfully connected to HashiCorp Vault.');
} catch (error) {
this.logger.error('Failed to connect to HashiCorp Vault.', error.message);
}
}

async readSecret(path: string): Promise<any> {
this.logger.log(`Attempting to read secret at path: ${path}`);
try {
const response = await this.vaultClient.get(`/data/${path}`);
this.logger.log(`Successfully read secret from: ${path}`);
return response.data.data.data;
} catch (error) {
this.logger.error(
`Failed to read secret from path: ${path}`,
error.message,
);
throw new Error(`Failed to read secret: ${error.message}`);
}
}

async writeSecret(path: string, data: any): Promise<void> {
this.logger.log(`Attempting to write secret to path: ${path}`);
try {
await this.vaultClient.post(`/data/${path}`, { data });
this.logger.log(`Successfully wrote secret to: ${path}`);
} catch (error) {
this.logger.error(
`Failed to write secret to path: ${path}`,
error.message,
);
throw new Error(`Failed to write secret: ${error.message}`);
}
}

async deleteSecret(path: string): Promise<void> {
this.logger.log(`Attempting to delete secret at path: ${path}`);
try {
await this.vaultClient.delete(`/data/${path}`);
this.logger.log(`Successfully deleted secret from: ${path}`);
} catch (error) {
this.logger.error(
`Failed to delete secret from path: ${path}`,
error.message,
);
throw new Error(`Failed to delete secret: ${error.message}`);
}
}
}
97 changes: 97 additions & 0 deletions test/secrets.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SecretsService } from '../src/secrets/secrets.service';
import { VaultIntegrationService } from '../src/secrets/vault-integration.service';
import { UnauthorizedException } from '@nestjs/common';

// Mock the VaultIntegrationService to prevent actual HTTP calls to Vault
const mockVaultIntegrationService = {
readSecret: jest.fn(),
writeSecret: jest.fn(),
};

describe('SecretsService', () => {
let service: SecretsService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SecretsService,
{
provide: VaultIntegrationService,
useValue: mockVaultIntegrationService,
},
],
}).compile();

service = module.get<SecretsService>(SecretsService);
});

afterEach(() => {
jest.clearAllMocks();
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('getSecret', () => {
const userId = 'user-123';
const path = 'test/secret';
const role = 'admin';
const mockSecret = { value: 'my-super-secret' };

it('should successfully retrieve a secret when user has the required role', async () => {
// Mock the successful read operation
mockVaultIntegrationService.readSecret.mockResolvedValue(mockSecret);

const secret = await service.getSecret(userId, path, role);

expect(secret).toEqual(mockSecret);
expect(mockVaultIntegrationService.readSecret).toHaveBeenCalledWith(path);
});

it('should throw UnauthorizedException when user does not have the required role', async () => {
const unauthorizedUserId = 'unauthorized-user';
const unauthorizedRole = 'guest';

await expect(
service.getSecret(unauthorizedUserId, path, unauthorizedRole),
).rejects.toThrow(UnauthorizedException);
expect(mockVaultIntegrationService.readSecret).not.toHaveBeenCalled();
});
});

describe('SecretsRotationService Integration Test', () => {
let rotationService: SecretsRotationService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SecretsRotationService,
{
provide: VaultIntegrationService,
useValue: mockVaultIntegrationService,
},
],
}).compile();

rotationService = module.get<SecretsRotationService>(
SecretsRotationService,
);
});

it('should rotate the secret by calling the writeSecret method', async () => {
// Mock the successful write operation for rotation
mockVaultIntegrationService.writeSecret.mockResolvedValue(undefined);

await rotationService.handleCron();

expect(mockVaultIntegrationService.writeSecret).toHaveBeenCalledTimes(1);
// The first argument should be the path, and the second should be the new secret data.
expect(mockVaultIntegrationService.writeSecret).toHaveBeenCalledWith(
'api-keys/external-service',
expect.objectContaining({ apiKey: expect.any(String) }),
);
});
});
});
Loading