Audit Module Design
Date: 2026-07-04
Status: Draft for review
Issue: #120 — [Backend] Implement Audit Module to replace temporary audit/security feed
Goal
Replace the placeholder auditSecurityFeed data and hardcoded warning banner in the Super Admin Dashboard with a real, persisted, queryable audit trail, and give the rest of the backend a simple way to record audit-worthy events going forward.
Context
backend/src/dashboard/services/super-admin-dashboard.service.ts currently emits a hardcoded warning (“Audit/security feed đang dùng trạng thái tạm thời cho đến khi audit module hoàn tất.”) and DashboardQueryService.buildSuperAdminSections / buildEmptySuperAdminSections always return auditSecurityFeed: []. There is no AuditEvent entity, service, or endpoint anywhere in the codebase today.
Notably, the RBAC permission strings audit:read:system and audit:read:organization already exist in the database — seeded by migration 1745800000000-AddDashboardPermissions.ts for the Super Admin and Org Admin roles respectively, anticipating this module. No new RBAC migration is required to grant read access.
Scope
In scope:
AuditEvententity, migration, repository-backed service, and a paginated/filterable read API.AuditService.record(...)as the write path other modules call directly (no event bus).- Wiring the Super Admin Dashboard to real audit data and removing the hardcoded warning.
- Emitting events from a core, high-value set of flows: auth login success/failure, logout, RBAC role-permission updates, organization creation.
- Tests for the new module and for each emission call site.
Out of scope (left for follow-up issues, per the module’s extensible design):
- Instrumenting every domain listed in the issue body (device provisioning, config changes, ticket lifecycle, user/permission changes beyond role updates, etc.). The module is designed so adding a new emission call site elsewhere is a one-line
auditService.record(...)call plus a new entry in the shared event-type const — no schema or infra changes needed. - Any frontend code changes. The warning banner in
frontend/src/pages/dashboard/super-admin/SuperAdminDashboard.tsxis already driven entirely by the server’swarningsarray; removing the string server-side removes the banner with no frontend diff. - An event bus /
@nestjs/event-emitterpattern. Modules callAuditServicedirectly, consistent with how other cross-cutting services (e.g. notifications) are consumed elsewhere in this codebase.
Data Model
New table audit_events, entity at backend/src/audit/entities/audit-event.entity.ts:
| Column | Type | Notes |
|---|---|---|
id |
uuid PK |
|
type |
varchar |
Free-form string, not a DB enum. Validated at the application layer against a shared TS const so new event types can be added later without a migration. |
actor_id |
uuid, nullable |
Null when the actor is unknown (e.g. login attempt against a non-existent email). |
actor_email |
varchar, nullable |
|
organization_id |
uuid, nullable |
The organization the event pertains to. Null for system-wide events. |
message |
text |
Human-readable summary. |
severity |
enum (info, warning, high, critical) |
Native Postgres enum — this set is small and stable, unlike type. |
metadata |
jsonb, nullable |
Structured extra detail (e.g. { reason: 'invalid_password' }). |
created_at |
timestamptz |
Indexes: organization_id, type, severity, created_at — these back the required filters and the dashboard’s “recent events” query.
Shared event-type constants
backend/src/audit/audit-event-types.ts exports an AuditEventType string union covering the initial core set:
auth.login_success
auth.login_failed
auth.logout
rbac.role.permissions_updated
organization.created
Future modules extend this union as they adopt AuditService.record(...).
Module Structure
New module backend/src/audit/, following the shape of backend/src/tickets/:
audit/
entities/audit-event.entity.ts
audit-event-types.ts
dto/list-audit-events-query.dto.ts
audit.service.ts
audit.controller.ts
audit.module.ts
audit.service.spec.ts
audit.controller.spec.ts
AuditModule registers TypeOrmModule.forFeature([AuditEvent]), is imported by AppModule, AuthModule, RbacModule, OrganizationsModule, and DashboardModule (each of which needs AuditService injected), and exports AuditService.
AuditService
record(input: RecordAuditEventInput): Promise<void>— persists an event. Catches and logs any write failure internally; never throws. This is deliberate: a broken audit insert must not break login, role updates, or org creation.findAll(query: ListAuditEventsQueryDto, currentUser: CurrentUserData): Promise<{ data: AuditEvent[]; total: number; page: number; limit: number }>— applies RBAC scoping (see below) plustype/severity/organizationId/ date-range filters and pagination.getRecentForDashboard(limit: number, organizationId?: string): Promise<AuditEvent[]>— used byDashboardQueryService.
AuditController
GET /api/v1/audit-events, guarded by JwtAuthGuard + PermissionsGuard, decorated @RequirePermissions('audit:read:system', 'audit:read:organization') (OR semantics, matching the existing PermissionsGuard behavior).
Scoping logic in AuditService.findAll: if the caller only holds audit:read:organization, the query is force-filtered to currentUser.organizationId regardless of any organizationId query param (a caller cannot use the query param to read another tenant’s events). If the caller holds audit:read:system, cross-org queries are allowed and organizationId is an optional filter.
Emission Integration (Core Set)
All four call sites inject AuditService directly and call record(...) after the outcome is known. None of these are allowed to let an audit-write failure propagate.
backend/src/auth/auth.service.tslogin()- Success path:
auth.login_success,actorId/actorEmail/organizationIdfrom the authenticated user, severityinfo. - Each failure branch (
!user,!isPasswordValid, suspended, inactive):auth.login_failed,actorIdset when the user record was found (password/suspended/inactive branches) andnullwhen it wasn’t (!userbranch),actorEmailfrom the submittedloginDto.email, severitywarning,metadata.reasondistinguishing the branch.
- Success path:
backend/src/auth/auth.service.tslogout()auth.logout,actorIdfrom theuserIdparameter, severityinfo. (Email/org are not currently loaded in this method and are not fetched solely for audit purposes —actorIdis sufficient to trace the event.)
backend/src/rbac/role.service.tsupdate()rbac.role.permissions_updated, severityhigh(permission changes are sensitive). Requires threadingcurrentUser: CurrentUserDataintoupdate(id, updateRoleDto, currentUser)frombackend/src/roles/role.controller.ts, which does not currently inject@CurrentUser()— this is added as part of this change.metadataincludes the role id/name and the new permission set.
backend/src/organizations/organizations.service.tscreate()organization.created, severityinfo,organizationIdset to the newly created organization’s id. Requires threadingcurrentUser: CurrentUserDataintocreate(createOrgDto, currentUser)frombackend/src/organizations/organizations.controller.ts, which does not currently inject@CurrentUser()on this endpoint — added as part of this change.
Dashboard Integration
DashboardQueryService.buildSuperAdminSections(backend/src/dashboard/dashboard-query.service.ts): replace the hardcodedauditSecurityFeed: []with a call toAuditService.getRecentForDashboard(...), mapped to the existingSuperAdminAuditSecurityFeedItemDto[]shape (id,type,message,createdAt).buildEmptySuperAdminSectionskeeps returning[](used when there’s no organization/data context).SuperAdminDashboardService.getDashboard()(backend/src/dashboard/services/super-admin-dashboard.service.ts): delete the hardcoded warning string at line 36. Thewarningsarray collapses to just the system-health warning (if any).- No frontend changes:
frontend/src/pages/dashboard/super-admin/SuperAdminDashboard.tsxalready renderswarningsconditionally andsections.auditSecurityFeeddirectly from the API response.
Testing Plan
audit.service.spec.ts:record()swallows and logs write failures without throwing;findAll()scoping (system vs. organization-scoped callers), filter combinations, pagination.audit.controller.spec.ts: guard/permission wiring, query param pass-through.auth.service.spec.ts:AuditService.recordcalled with expected payloads on login success, each login failure branch, and logout.role.service.spec.ts(rbac):AuditService.recordcalled onupdate()with expected metadata.organizations.service.spec.ts:AuditService.recordcalled oncreate().super-admin-dashboard.service.spec.ts/dashboard-query.service.tsspecs: updated to assert the warning is gone andauditSecurityFeedis populated fromAuditService.- One migration (
CreateAuditEventsTable) — no RBAC permission migration needed sinceaudit:read:system/audit:read:organizationalready exist.
Risks / Notes
logout()doesn’t currently load the user record, so the audit entry for logout hasactorEmail: null. Fetching the user solely to enrich an audit log entry was judged not worth the extra query;actorIdis enough to trace the session.- Threading
currentUserintoRoleService.update()andOrganizationsService.create()is a small, additive signature change (new trailing parameter) — no existing callers break since both parameters are new arguments appended to existing methods, and current call sites within controllers are the only callers being updated in this same change.