Skip to content
Open
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
1,485 changes: 864 additions & 621 deletions apps/backend/pnpm-lock.yaml

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import { AdminModule } from './modules/admin/admin.module';
import { StellarEventModule } from './modules/stellar/stellar-event.module';
import { AssetsModule } from './modules/assets/assets.module';
import { AllowedAsset } from './modules/assets/entities/allowed-asset.entity';
import { IpfsModule } from './modules/ipfs/ipfs.module';
import { AuditLog } from './modules/audit-log/entities/audit-log.entity';
import { AuditLogModule } from './modules/audit-log/audit-log.module';
import { HealthModule } from './modules/health/health.module';
import { AppVersionModule } from './app-version/app-version.module';
import { EscrowGateway } from './gateways/escrow.gateway';
Expand Down Expand Up @@ -68,6 +69,7 @@ import ipfsConfig from './config/ipfs.config';
Webhook,
StellarEvent,
AllowedAsset,
AuditLog,
],
synchronize: process.env.NODE_ENV === 'test',
migrations: [__dirname + '/migrations/*.ts'],
Expand All @@ -85,6 +87,7 @@ import ipfsConfig from './config/ipfs.config';
ApiKeyModule,
forwardRef(() => StellarEventModule),
AssetsModule,
AuditLogModule,
IpfsModule,
HealthModule,
AppVersionModule,
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { AdminAuditLog } from './modules/admin/entities/admin-audit-log.entity';
import { Webhook } from './modules/webhook/webhook.entity';
import { StellarEvent } from './modules/stellar/entities/stellar-event.entity';
import { AllowedAsset } from './modules/assets/entities/allowed-asset.entity';
import { AuditLog } from './modules/audit-log/entities/audit-log.entity';

config(); // Load .env file

Expand All @@ -35,6 +36,7 @@ export default new DataSource({
Webhook,
StellarEvent,
AllowedAsset,
AuditLog,
],
migrations: ['./src/migrations/*.ts'],
synchronize: false,
Expand Down
45 changes: 45 additions & 0 deletions apps/backend/src/migrations/1800000000000-CreateAuditLogsTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateAuditLogsTable1800000000000 implements MigrationInterface {
name = 'CreateAuditLogsTable1800000000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "audit_logs" (
"id" varchar PRIMARY KEY NOT NULL,
"entityType" varchar(64) NOT NULL,
"entityId" varchar(128) NOT NULL,
"action" varchar(128) NOT NULL,
"actorId" varchar(128),
"actorRole" varchar(64),
"previousState" text,
"newState" text,
"ipAddress" varchar(64),
"userAgent" varchar(512),
"metadata" text,
"createdAt" datetime NOT NULL DEFAULT (datetime('now'))
)`,
);

await queryRunner.query(
`CREATE INDEX "idx_audit_entity_id" ON "audit_logs" ("entityId")`,
);
await queryRunner.query(
`CREATE INDEX "idx_audit_action" ON "audit_logs" ("action")`,
);
await queryRunner.query(
`CREATE INDEX "idx_audit_created_at" ON "audit_logs" ("createdAt")`,
);
await queryRunner.query(
`CREATE INDEX "idx_audit_entity_action_created" ON "audit_logs" ("entityType", "entityId", "createdAt")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "idx_audit_entity_action_created"`);
await queryRunner.query(`DROP INDEX "idx_audit_created_at"`);
await queryRunner.query(`DROP INDEX "idx_audit_action"`);
await queryRunner.query(`DROP INDEX "idx_audit_entity_id"`);
await queryRunner.query(`DROP TABLE "audit_logs"`);
}
}
35 changes: 23 additions & 12 deletions apps/backend/src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { AuthGuard } from '../auth/middleware/auth.guard';
import { AdminGuard } from '../auth/middleware/admin.guard';
import { AdminService } from './admin.service';
import { AdminAuditLogService } from './services/admin-audit-log.service';
import { AuditLogService } from '../audit-log/audit-log.service';
import { EscrowStatus } from '../escrow/entities/escrow.entity';
import { UserRole } from '../user/entities/user.entity';

interface AuditLogQuery {
actorId?: string;
Expand Down Expand Up @@ -44,34 +46,33 @@ export class AdminController {
constructor(
private readonly adminService: AdminService,
private readonly adminAuditLogService: AdminAuditLogService,
private readonly auditLogService: AuditLogService,
) {}

@Get('audit-logs')
async getAuditLogs(
@Query('entityType') entityType?: string,
@Query('entityId') entityId?: string,
@Query('action') action?: string,
@Query('actorId') actorId?: string,
@Query('actionType') actionType?: string,
@Query('resourceType') resourceType?: string,
@Query('resourceId') resourceId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
@Query('pageSize') pageSize = '50',
) {
const parsedPage = Number.parseInt(page, 10);
const parsedPageSize = Number.parseInt(pageSize, 10);

const filters: AuditLogQuery = {
return this.auditLogService.findAll({
entityType,
entityId,
action,
actorId,
actionType,
resourceType,
resourceId,
page: Number.isNaN(parsedPage) ? 1 : parsedPage,
pageSize: Number.isNaN(parsedPageSize) ? 20 : parsedPageSize,
pageSize: Number.isNaN(parsedPageSize) ? 50 : parsedPageSize,
from: from ? new Date(from) : undefined,
to: to ? new Date(to) : undefined,
};

return this.adminAuditLogService.findAll(filters);
});
}

@Get('escrows')
Expand Down Expand Up @@ -100,4 +101,14 @@ export class AdminController {
) {
return this.adminService.suspendUser(id, actorId);
}

@Post('users/:id/role')
@HttpCode(HttpStatus.OK)
async changeUserRole(
@Param('id') id: string,
@Query('role') role: string,
@Query('actorId') actorId?: string,
) {
return this.adminService.changeUserRole(id, role as UserRole, actorId);
}
}
2 changes: 2 additions & 0 deletions apps/backend/src/modules/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AdminAuditLogService } from './services/admin-audit-log.service';
import { AnalyticsService } from './services/analytics.service';
import { AnalyticsController } from './controllers/analytics.controller';
import { Dispute } from '../escrow/entities/dispute.entity';
import { AuditLogModule } from '../audit-log/audit-log.module';

@Module({
imports: [
Expand All @@ -29,6 +30,7 @@ import { Dispute } from '../escrow/entities/dispute.entity';
Dispute,
]),
EscrowModule,
AuditLogModule,
],
controllers: [
AdminController,
Expand Down
97 changes: 93 additions & 4 deletions apps/backend/src/modules/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { Escrow, EscrowStatus } from '../escrow/entities/escrow.entity';
import { Party } from '../escrow/entities/party.entity';
import { EscrowEvent } from '../escrow/entities/escrow-event.entity';
import { AdminAuditLogService } from './services/admin-audit-log.service';
import { Repository } from 'typeorm';
import { AuditLogService } from '../audit-log/audit-log.service';

describe('AdminService', () => {
let service: AdminService;
let userRepo: jest.Mocked<any>;
let escrowRepo: jest.Mocked<any>;
let auditLogService: jest.Mocked<AdminAuditLogService>;
let adminAuditLogService: jest.Mocked<AdminAuditLogService>;
let auditLogService: jest.Mocked<Pick<AuditLogService, 'log'>>;

beforeEach(async () => {
userRepo = {
Expand Down Expand Up @@ -70,11 +71,18 @@ describe('AdminService', () => {
create: jest.fn(),
},
},
{
provide: AuditLogService,
useValue: {
log: jest.fn(),
},
},
],
}).compile();

service = module.get<AdminService>(AdminService);
auditLogService = module.get(AdminAuditLogService);
adminAuditLogService = module.get(AdminAuditLogService);
auditLogService = module.get(AuditLogService);
});

describe('getAllUsers', () => {
Expand Down Expand Up @@ -118,7 +126,8 @@ describe('AdminService', () => {

expect(mockUser.isActive).toBe(false);
expect(userRepo.save).toHaveBeenCalled();
expect(auditLogService.create).toHaveBeenCalled();
expect(adminAuditLogService.create).toHaveBeenCalled();
expect(auditLogService.log).toHaveBeenCalled();
expect(result.message).toBe('User suspended successfully');
});

Expand All @@ -135,4 +144,84 @@ describe('AdminService', () => {
);
});
});

describe('changeUserRole', () => {
it('should change a user role and log audits', async () => {
const mockUser = { id: 'u1', role: UserRole.USER, isActive: true };
userRepo.findOne.mockResolvedValue(mockUser);

const result = await service.changeUserRole('u1', UserRole.ADMIN, 'admin-id');

expect(mockUser.role).toBe(UserRole.ADMIN);
expect(userRepo.save).toHaveBeenCalled();
expect(adminAuditLogService.create).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'admin-id',
actionType: 'ROLE_CHANGE',
resourceType: 'USER',
resourceId: 'u1',
metadata: { oldRole: UserRole.USER, newRole: UserRole.ADMIN },
}),
);
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'user',
entityId: 'u1',
action: 'user.role_changed',
previousState: { role: UserRole.USER },
newState: { role: UserRole.ADMIN },
}),
);
expect(result.message).toBe('User role updated successfully');
});

it('should throw if user not found', async () => {
userRepo.findOne.mockResolvedValue(null);
await expect(
service.changeUserRole('u1', UserRole.ADMIN),
).rejects.toThrow('User not found');
});

it('should throw if user is super admin', async () => {
const superAdmin = { id: 's1', role: UserRole.SUPER_ADMIN };
userRepo.findOne.mockResolvedValue(superAdmin);
await expect(
service.changeUserRole('s1', UserRole.ADMIN),
).rejects.toThrow('Cannot change super admin role');
});

it('should throw if trying to change own role', async () => {
const mockUser = { id: 'admin-id', role: UserRole.ADMIN };
userRepo.findOne.mockResolvedValue(mockUser);
await expect(
service.changeUserRole('admin-id', UserRole.USER, 'admin-id'),
).rejects.toThrow('Cannot change your own role');
});

it('should throw for invalid role', async () => {
const mockUser = { id: 'u1', role: UserRole.USER };
userRepo.findOne.mockResolvedValue(mockUser);
await expect(
service.changeUserRole('u1', 'INVALID_ROLE' as UserRole),
).rejects.toThrow('Invalid role');
});

it('should default actorId to system when not provided', async () => {
const mockUser = { id: 'u1', role: UserRole.USER };
userRepo.findOne.mockResolvedValue(mockUser);

await service.changeUserRole('u1', UserRole.ADMIN);

expect(adminAuditLogService.create).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'system',
}),
);
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'system',
}),
);
});
});
});
Loading