Root directory had accumulated audit/exploration markdown files cluttering the project root. Moved all audit-related files to docs/audits/ with a README.md index, and updated cross-references in K6_LOAD_TESTING_GUIDE.md and README_FRONTEND_DOCS.md. Co-Authored-By: Paperclip <noreply@paperclip.ing>
28 KiB
28 KiB
Audit Logging Architecture for GoodGo Admin Module
System Design Overview
┌─────────────────────────────────────────────────────────────────────┐
│ HTTP Request (Admin Action) │
│ POST /admin/users/ban │ POST /admin/moderation/approve │ etc. │
└────────────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────┐
│ Authentication & Validation
│ - JwtAuthGuard (@UseGuards)
│ - RolesGuard (@Roles('ADMIN'))
│ - ValidationPipe (DTO)
│ - @CurrentUser() extracts JWT
└──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Controller (Presentation Layer) │
│ │
│ @Post('users/ban') │
│ async banUser( │
│ @Body() dto: BanUserDto, │ ◄─── DTO validation
│ @CurrentUser() user: JwtPayload │ ◄─── Admin ID here!
│ ) { │
│ return this.commandBus.execute( │
│ new BanUserCommand( │
│ dto.userId, │
│ user.sub, ◄─────────────┐ │
│ dto.reason │ │
│ ) │ │
│ ); │ │
│ } │ │
└─────────────────┬───────────────┘ │
│ │
▼ (Command) │
┌──────────────────────────────────────┐
│ CQRS Bus - Routing │
│ (CommandBus.execute) │
└────────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Command Handler (Application Layer) │
│ │
│ @CommandHandler(BanUserCommand) │
│ export class BanUserHandler { │
│ async execute( │
│ command: BanUserCommand │
│ ): Promise<BanUserResult> { │
│ │
│ // 1. Business Logic │
│ const user = await │
│ this.userRepo.findById(...) │
│ user.deactivate() │
│ await this.userRepo.update(...) │
│ │
│ // 2. Publish Domain Event │
│ this.eventBus.publish( │
│ new UserBannedEvent( │
│ user.id, ◄─── Resource ID
│ command.adminId, ◄─── Admin ID
│ command.reason ◄─── Action context
│ ) │
│ ); │
│ │
│ return { ... }; │
│ } │
│ } │
└──────────────────┬────────────────────┘
│ (Event Published)
▼ ┌──────────────────────────────────┐
│ │ Event Emitted: 'user.banned' │
│ │ { │
│ │ eventName: 'user.banned', │
│ │ aggregateId: 'usr_xyz', │
│ │ adminId: 'adm_abc', ◄────┐ │
│ │ reason: '...', │ │
│ │ occurredAt: now() │ │
│ │ } │ │
└──────────────────────────────────┘ │
│ │
┌──────────────────┴──────────────────┐ │
│ │ │
▼ (Event Subscribe) ▼ │
┌──────────────────────────┐ ┌──────────────────┐ │
│ Existing Listeners │ │ Audit Listener │ │
│ │ │ │ │
│ UserBannedListener │ │ AuditLogging │ │
│ @OnEvent('user.banned') │ │ Listener │ │
│ - Deactivate listings │ │ @OnEvent(...) │ │
│ - Send notification │ │ - Extract info │ │
│ │ │ - Create record │◄─┘
└──────────────────────────┘ │ - Persist to DB │
└────────┬─────────┘
│
▼
┌──────────────────────────┐
│ AuditLog Repository │
│ (Infrastructure Layer) │
│ │
│ @Injectable() │
│ export class Prisma │
│ AuditLogRepository {} │
│ │
│ Methods: │
│ - create(auditLog) │
│ - findMany(filters) │
│ - findById(id) │
└──────────────┬───────────┘
│
▼
┌──────────────────────────┐
│ Prisma Client │
│ (Database Driver) │
└──────────────┬───────────┘
│
▼
┌──────────────────────────┐
│ PostgreSQL Database │
│ │
│ AuditLog Table │
│ ├─ id │
│ ├─ adminId │
│ ├─ action │
│ ├─ resourceType │
│ ├─ resourceId │
│ ├─ reason │
│ ├─ status │
│ └─ createdAt │
└──────────────────────────┘
Data Flow Sequence
Admin Action → Controller → Command → Event → AuditListener → Repository → Database
┌───┐ POST /admin/users/ban ┌──────────┐
│ ├──────────────────────────────────▶│ Controller
└───┘ {userId, reason, jwt} └──────┬───┘
│
(validate & extract admin ID)
│
┌──────▼────────┐
│ CommandBus │
└──────┬────────┘
│
┌──────────▼──────────┐
│ BanUserCommand │
│ {userId, adminId, │
│ reason, unban} │
└──────────┬──────────┘
│
┌──────────▼──────────────┐
│ BanUserHandler │
│ (execute business logic)│
└──────────┬──────────────┘
│
(publish event)
│
┌──────────▼────────────────┐
│ UserBannedEvent │
│ {aggregateId, adminId, │
│ reason, occurredAt} │
└──────────┬────────────────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ (existing listener) ▼ (NEW - audit listener) │
┌──────────────────────┐ ┌──────────────────────────────┐ │
│ UserBannedListener │ │ AuditLoggingListener │ │
│ - Deactivate listings│ │ - Extract event data │ │
│ - Send notification │ │ - Map to AuditLog record │ │
└──────────────────────┘ │ - Call repository.create() │ │
└──────────┬──────────────────┘ │
│ │
┌──────────▼─────────┐ │
│ PrismaAuditLog │ │
│ Repository │ │
│ .create({...}) │ │
└──────────┬─────────┘ │
│ │
┌──────────▼──────────┐ │
│ prisma.auditLog │ │
│ .create({ │ │
│ adminId, │ │
│ action, │ │
│ resourceType, │ │
│ resourceId, │ │
│ reason, │ │
│ status, │ │
│ createdAt │ │
│ }) │ │
└──────────┬──────────┘ │
│ │
┌──────────▼──────────┐ │
│ PostgreSQL │ │
│ INSERT INTO auditLog│ │
│ values(...) │ │
└─────────────────────┘ │
▼ (HTTP Response)
200 OK {
userId: 'usr_xyz',
isActive: false,
message: '...'
}
Prisma Schema Addition
// Add to prisma/schema.prisma
// ============================================================================
// ADMIN AUDIT LOG
// ============================================================================
enum AdminAction {
USER_BANNED
USER_UNBANNED
USER_DEACTIVATED
USER_STATUS_UPDATED
LISTING_APPROVED
LISTING_REJECTED
LISTING_BULK_MODERATED
KYC_APPROVED
KYC_REJECTED
SUBSCRIPTION_ADJUSTED
}
enum AuditResourceType {
USER
LISTING
KYC
SUBSCRIPTION
}
enum AuditStatus {
SUCCESS
FAILED
}
model AuditLog {
id String @id @default(cuid())
adminId String
admin User? @relation(fields: [adminId], references: [id])
action AdminAction
resourceType AuditResourceType
resourceId String
changes Json? // {before: {...}, after: {...}}
reason String? @db.Text
ipAddress String?
userAgent String?
status AuditStatus @default(SUCCESS)
statusCode Int?
errorMessage String?
duration Int? // milliseconds
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([adminId])
@@index([action])
@@index([resourceType])
@@index([resourceId])
@@index([createdAt])
@@index([adminId, createdAt(sort: Desc)])
@@index([action, createdAt(sort: Desc)])
@@index([resourceType, resourceId, createdAt(sort: Desc)])
}
// Add relationship to User model:
model User {
// ... existing fields ...
auditLogs AuditLog[] // New relation
}
Repository Layer
Domain Interface
// domain/repositories/audit-log.repository.ts
export interface IAuditLogRepository {
create(auditLog: CreateAuditLogDto): Promise<AuditLog>;
findMany(params: FindAuditLogsParams): Promise<PaginatedResult<AuditLog>>;
findById(id: string): Promise<AuditLog | null>;
findByAdminId(adminId: string, pagination: Pagination): Promise<PaginatedResult<AuditLog>>;
}
export interface CreateAuditLogDto {
adminId: string;
action: AdminAction;
resourceType: AuditResourceType;
resourceId: string;
changes?: Record<string, any>;
reason?: string;
ipAddress?: string;
userAgent?: string;
status: AuditStatus;
statusCode?: number;
errorMessage?: string;
duration?: number;
}
export interface FindAuditLogsParams {
page: number;
limit: number;
adminId?: string;
action?: AdminAction;
resourceType?: AuditResourceType;
resourceId?: string;
startDate?: Date;
endDate?: Date;
}
Infrastructure Implementation
// infrastructure/repositories/prisma-audit-log.repository.ts
@Injectable()
export class PrismaAuditLogRepository implements IAuditLogRepository {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateAuditLogDto): Promise<AuditLog> {
return this.prisma.auditLog.create({
data: {
adminId: dto.adminId,
action: dto.action,
resourceType: dto.resourceType,
resourceId: dto.resourceId,
changes: dto.changes,
reason: dto.reason,
ipAddress: dto.ipAddress,
userAgent: dto.userAgent,
status: dto.status,
statusCode: dto.statusCode,
errorMessage: dto.errorMessage,
duration: dto.duration,
},
});
}
async findMany(params: FindAuditLogsParams): Promise<PaginatedResult<AuditLog>> {
const skip = (params.page - 1) * params.limit;
const where: Prisma.AuditLogWhereInput = {
...(params.adminId && { adminId: params.adminId }),
...(params.action && { action: params.action }),
...(params.resourceType && { resourceType: params.resourceType }),
...(params.resourceId && { resourceId: params.resourceId }),
...(params.startDate || params.endDate) && {
createdAt: {
...(params.startDate && { gte: params.startDate }),
...(params.endDate && { lte: params.endDate }),
},
},
};
const [data, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
skip,
take: params.limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.auditLog.count({ where }),
]);
return {
data,
total,
page: params.page,
limit: params.limit,
totalPages: Math.ceil(total / params.limit),
};
}
// ... other methods
}
Event Listener Implementation
// application/listeners/audit-logging.listener.ts
@Injectable()
export class AuditLoggingListener {
constructor(
@Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository,
private readonly logger: LoggerService,
) {}
@OnEvent('user.banned', { async: true })
async handleUserBanned(event: UserBannedEvent): Promise<void> {
await this.createAuditLog({
adminId: event.adminId,
action: AdminAction.USER_BANNED,
resourceType: AuditResourceType.USER,
resourceId: event.aggregateId,
reason: event.reason,
status: AuditStatus.SUCCESS,
});
}
@OnEvent('user.unbanned', { async: true })
async handleUserUnbanned(event: UserUnbannedEvent): Promise<void> {
await this.createAuditLog({
adminId: event.adminId,
action: AdminAction.USER_UNBANNED,
resourceType: AuditResourceType.USER,
resourceId: event.aggregateId,
status: AuditStatus.SUCCESS,
});
}
@OnEvent('listing.approved', { async: true })
async handleListingApproved(event: ListingApprovedEvent): Promise<void> {
await this.createAuditLog({
adminId: event.adminId,
action: AdminAction.LISTING_APPROVED,
resourceType: AuditResourceType.LISTING,
resourceId: event.aggregateId,
reason: event.moderationNotes,
status: AuditStatus.SUCCESS,
});
}
@OnEvent('kyc.approved', { async: true })
async handleKycApproved(event: KycApprovedEvent): Promise<void> {
await this.createAuditLog({
adminId: event.adminId,
action: AdminAction.KYC_APPROVED,
resourceType: AuditResourceType.KYC,
resourceId: event.aggregateId,
reason: event.comments,
status: AuditStatus.SUCCESS,
});
}
// ... more handlers ...
private async createAuditLog(dto: CreateAuditLogDto): Promise<void> {
try {
await this.auditRepo.create(dto);
this.logger.log(
`Audit logged: ${dto.action} on ${dto.resourceType}/${dto.resourceId}`,
'AuditLoggingListener',
);
} catch (error) {
this.logger.error(
`Failed to create audit log: ${String(error)}`,
error instanceof Error ? error.stack : undefined,
'AuditLoggingListener',
);
// Don't re-throw to prevent interrupting the main operation
}
}
}
Query Handler for Retrieval
// application/queries/get-audit-logs/get-audit-logs.handler.ts
export class GetAuditLogsQuery {
constructor(
public readonly page: number,
public readonly limit: number,
public readonly adminId?: string,
public readonly action?: AdminAction,
public readonly resourceType?: AuditResourceType,
public readonly startDate?: Date,
public readonly endDate?: Date,
) {}
}
@QueryHandler(GetAuditLogsQuery)
export class GetAuditLogsHandler implements IQueryHandler<GetAuditLogsQuery> {
constructor(
@Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository,
) {}
async execute(query: GetAuditLogsQuery): Promise<PaginatedResult<AuditLog>> {
return this.auditRepo.findMany({
page: query.page,
limit: query.limit,
adminId: query.adminId,
action: query.action,
resourceType: query.resourceType,
startDate: query.startDate,
endDate: query.endDate,
});
}
}
Controller Endpoint
// presentation/controllers/admin.controller.ts (add to existing file)
@Get('audit-logs')
@ApiOperation({ summary: 'Get audit logs with filters' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'adminId', required: false, type: String })
@ApiQuery({ name: 'action', required: false, enum: AdminAction })
@ApiQuery({ name: 'resourceType', required: false, enum: AuditResourceType })
@ApiQuery({ name: 'startDate', required: false, type: String })
@ApiQuery({ name: 'endDate', required: false, type: String })
@ApiResponse({ status: 200, description: 'Audit logs retrieved' })
async getAuditLogs(
@Query() query: GetAuditLogsQueryDto,
): Promise<PaginatedResult<AuditLog>> {
return this.queryBus.execute(
new GetAuditLogsQuery(
query.page ?? 1,
query.limit ?? 20,
query.adminId,
query.action,
query.resourceType,
query.startDate ? new Date(query.startDate) : undefined,
query.endDate ? new Date(query.endDate) : undefined,
),
);
}
DI Registration
// admin.module.ts (update existing)
import { AUDIT_LOG_REPOSITORY, IAuditLogRepository } from '...';
import { PrismaAuditLogRepository } from '...';
import { AuditLoggingListener } from '...';
const QueryHandlers = [
// ... existing handlers ...
GetAuditLogsHandler, // NEW
];
const EventListeners = [
UserBannedListener,
UserDeactivatedListener,
AuditLoggingListener, // NEW
];
@Module({
imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule],
controllers: [AdminController, AdminModerationController],
providers: [
// Repositories
{ provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },
{ provide: AUDIT_LOG_REPOSITORY, useClass: PrismaAuditLogRepository }, // NEW
// CQRS
...CommandHandlers,
...QueryHandlers,
// Event Listeners
...EventListeners,
],
})
export class AdminModule {}
Testing Strategy
Unit Test (AuditLoggingListener)
describe('AuditLoggingListener', () => {
let listener: AuditLoggingListener;
let auditRepo: MockRepository<IAuditLogRepository>;
beforeEach(() => {
auditRepo = mockRepository();
listener = new AuditLoggingListener(auditRepo, mockLogger());
});
it('should create audit log on user.banned event', async () => {
const event = new UserBannedEvent('usr_123', 'admin_456', 'Violation');
await listener.handleUserBanned(event);
expect(auditRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
adminId: 'admin_456',
action: AdminAction.USER_BANNED,
resourceId: 'usr_123',
reason: 'Violation',
})
);
});
it('should handle repository errors gracefully', async () => {
auditRepo.create.mockRejectedValueOnce(new Error('DB error'));
const event = new UserBannedEvent('usr_123', 'admin_456', 'Violation');
// Should not throw
await expect(listener.handleUserBanned(event)).resolves.toBeUndefined();
});
});
Integration Test
describe('Audit Logging Integration', () => {
let app: INestApplication;
let prisma: PrismaService;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AdminModule, SharedModule],
}).compile();
app = module.createNestApplication();
prisma = module.get(PrismaService);
});
it('should log admin action to database', async () => {
const admin = await createTestAdmin(prisma);
const user = await createTestUser(prisma);
await app.get(CommandBus).execute(
new BanUserCommand(user.id, admin.id, 'Test ban')
);
const auditLog = await prisma.auditLog.findFirst({
where: { adminId: admin.id, resourceId: user.id },
});
expect(auditLog).toBeDefined();
expect(auditLog?.action).toBe(AdminAction.USER_BANNED);
expect(auditLog?.reason).toBe('Test ban');
});
});
Summary
This architecture ensures:
✅ Separation of Concerns - Audit logging as separate concern via event listener
✅ Non-Blocking - Audit logging happens async, doesn't block main operation
✅ Reusability - Single listener handles all admin actions
✅ Consistency - Follows existing DDD/CQRS patterns
✅ Queryability - Full audit trail with filtering capabilities
✅ Compliance - Complete record of who did what and when