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
53 changes: 53 additions & 0 deletions backend/src/access-logs/access-logs.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AccessLogsController } from './access-logs.controller';
import { AccessLogsService } from './access-logs.service';
import { FilterAccessLogsDto } from './dto/filter-access-logs.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';

describe('AccessLogsController', () => {
let controller: AccessLogsController;
let service: AccessLogsService;

const mockAccessLogsService = {
findAll: jest.fn().mockResolvedValue({
data: [],
total: 0,
page: 1,
limit: 10,
}),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AccessLogsController],
providers: [
{
provide: AccessLogsService,
useValue: mockAccessLogsService,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();

controller = module.get<AccessLogsController>(AccessLogsController);
service = module.get<AccessLogsService>(AccessLogsService);
});

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

it('should return paginated access logs for admin user', async () => {
const filterDto: FilterAccessLogsDto = { page: 1, limit: 10 };
const result = await controller.getAccessLogs(filterDto);

expect(service.findAll).toHaveBeenCalledWith(filterDto);
expect(result).toHaveProperty('data');
expect(result).toHaveProperty('total');
});
});
27 changes: 27 additions & 0 deletions backend/src/access-logs/access-logs.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
Controller,
Get,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AccessLogsService } from './access-logs.service';
import { FilterAccessLogsDto } from './dto/filter-access-logs.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/guards/roles.decorator';
import { UserRole } from '../users/entities/user.entity';

@Controller('admin/access-logs')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class AccessLogsController {
constructor(private readonly accessLogsService: AccessLogsService) {}

@Get()
@HttpCode(HttpStatus.OK)
async getAccessLogs(@Query() filterDto: FilterAccessLogsDto) {
return this.accessLogsService.findAll(filterDto);
}
}
13 changes: 13 additions & 0 deletions backend/src/access-logs/access-logs.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AccessLog } from './entities/access-log.entity';
import { AccessLogsService } from './access-logs.service';
import { AccessLogsController } from './access-logs.controller';

@Module({
imports: [TypeOrmModule.forFeature([AccessLog])],
controllers: [AccessLogsController],
providers: [AccessLogsService],
exports: [AccessLogsService],
})
export class AccessLogsModule {}
18 changes: 16 additions & 2 deletions backend/src/access-logs/dto/create-access-log.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { IsNumber, IsOptional, IsString } from 'class-validator';

export class CreateAccessLogDto {
userId?: string;
@IsOptional()
@IsString()
userId?: string | null;

@IsString()
routePath: string;

@IsString()
httpMethod: string;
ipAddress?: string;

@IsString()
ipAddress: string;

@IsOptional()
@IsNumber()
statusCode?: number;
}
3 changes: 3 additions & 0 deletions backend/src/access-logs/entities/access-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ export class AccessLog {

@CreateDateColumn()
createdAt: Date;

@Column({ type: 'int', nullable: true })
statusCode: number;
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { WinstonModule } from 'nest-winston';

import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AccessLogsModule } from './access-logs/access-logs.module';
import { AuthModule } from './auth/auth.module';
import { buildWinstonOptions } from './common/logger.config';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
Expand Down Expand Up @@ -50,6 +51,7 @@ import { ConfigValidationSchema } from './config/config.validation';
synchronize: true,
}),
}),
AccessLogsModule,
UsersModule,
AuthModule,
DocumentsModule,
Expand Down
27 changes: 24 additions & 3 deletions backend/src/common/middleware/logger.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import type { Logger } from 'winston';
import { Request, Response, NextFunction } from 'express';
import { AccessLogsService } from '../../access-logs/access-logs.service';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
constructor(
@Inject(WINSTON_MODULE_NEST_PROVIDER)
private readonly logger: Logger,
private readonly accessLogsService: AccessLogsService,
) {}

use(req: Request, res: Response, next: NextFunction) {
Expand All @@ -21,20 +23,39 @@ export class LoggerMiddleware implements NestMiddleware {

res.on('finish', () => {
const duration = Date.now() - start;
const routePath = req.originalUrl || req.url;
const httpMethod = req.method;
const statusCode = res.statusCode;
const userId = (req as any).user?.id || null;

const payload: Record<string, unknown> = {
method: req.method,
path: req.originalUrl || req.url,
status: res.statusCode,
method: httpMethod,
path: routePath,
status: statusCode,
duration_ms: duration,
user_agent: userAgent,
ip,
userId,
};

if (req.headers.authorization) {
payload.authorization = '[REDACTED]';
}

this.logger.info('http-request', payload);

// Persist access log via AccessLogsService asynchronously
this.accessLogsService
.create({
userId,
routePath,
httpMethod,
ipAddress: ip || '',
statusCode,
})
.catch((err) => {
this.logger.error('Failed to save access log record:', err);
});
});

next();
Expand Down
61 changes: 54 additions & 7 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use tracing::{info, warn};
use cache::CacheBackend;
use hash_validator::{HashValidator, ValidationError as HashValidationError};
use metrics::MetricsRegistry;
use stellar::{StellarClient, TransactionRecord};
use stellar::{derive_account_id, StellarClient, TransactionRecord};

// Application state
#[derive(Clone)]
Expand Down Expand Up @@ -261,7 +261,21 @@ pub async fn record_transfer(
let transfer_hash = compute_transfer_hash(&req);
let memo = build_transfer_memo(&transfer_hash);

if let Err(e) = state.stellar.anchor_transfer(&transfer_hash, &memo).await {
let anchor_account_id = derive_account_id(&state.stellar_secret_key).map_err(|e| {
warn!("Failed to derive anchor account id: {}", e);
state.metrics.increment_error_count();
StatusCode::INTERNAL_SERVER_ERROR
})?;

if let Err(e) = state
.stellar
.anchor_transfer(
&transfer_hash,
&anchor_account_id,
&state.stellar_secret_key,
)
.await
{
warn!("Failed to anchor transfer on Stellar: {}", e);
state.metrics.increment_error_count();
return Err(StatusCode::INTERNAL_SERVER_ERROR);
Expand Down Expand Up @@ -346,8 +360,21 @@ pub async fn verify_document(

state.metrics.increment_cache_misses();

let anchor_account_id = match derive_account_id(&state.stellar_secret_key) {
Ok(id) => id,
Err(e) => {
warn!("Failed to derive anchor account id: {}", e);
state.metrics.increment_error_count();
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};

// Query Stellar blockchain
let result = match state.stellar.verify_hash(&normalized_hash).await {
let result = match state
.stellar
.verify_hash(&normalized_hash, &anchor_account_id)
.await
{
Ok(verification) => verification,
Err(e) => {
warn!("Stellar query failed: {}", e);
Expand All @@ -357,7 +384,7 @@ pub async fn verify_document(
};

let response = VerifyResponse {
verified: result.verified,
verified: result.anchored,
transaction_id: result.transaction_id,
timestamp: result.timestamp,
cached: false,
Expand Down Expand Up @@ -514,8 +541,28 @@ async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem {

state.metrics.increment_cache_misses();

let anchor_account_id = match derive_account_id(&state.stellar_secret_key) {
Ok(id) => id,
Err(e) => {
warn!("Failed to derive anchor account id: {}", e);
state.metrics.increment_error_count();

return BatchVerifyItem {
hash,
verified: false,
transaction_id: None,
timestamp: None,
error: Some(format!("failed to derive anchor account id: {}", e)),
};
}
};

// Query Stellar blockchain
let result = match state.stellar.verify_hash(&normalized_hash).await {
let result = match state
.stellar
.verify_hash(&normalized_hash, &anchor_account_id)
.await
{
Ok(verification) => verification,
Err(e) => {
warn!("Stellar query failed for hash {}: {}", normalized_hash, e);
Expand All @@ -533,7 +580,7 @@ async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem {

// Cache the result
let cache_response = VerifyResponse {
verified: result.verified,
verified: result.anchored,
transaction_id: result.transaction_id.clone(),
timestamp: result.timestamp,
cached: false,
Expand All @@ -551,7 +598,7 @@ async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem {

BatchVerifyItem {
hash,
verified: result.verified,
verified: result.anchored,
transaction_id: result.transaction_id,
timestamp: result.timestamp,
error: None,
Expand Down
Loading
Loading