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:

  • AuditEvent entity, 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.tsx is already driven entirely by the server’s warnings array; removing the string server-side removes the banner with no frontend diff.
  • An event bus / @nestjs/event-emitter pattern. Modules call AuditService directly, 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) plus type / severity / organizationId / date-range filters and pagination.
  • getRecentForDashboard(limit: number, organizationId?: string): Promise<AuditEvent[]> — used by DashboardQueryService.

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.

  1. backend/src/auth/auth.service.ts login()

    • Success path: auth.login_success, actorId/actorEmail/organizationId from the authenticated user, severity info.
    • Each failure branch (!user, !isPasswordValid, suspended, inactive): auth.login_failed, actorId set when the user record was found (password/suspended/inactive branches) and null when it wasn’t (!user branch), actorEmail from the submitted loginDto.email, severity warning, metadata.reason distinguishing the branch.
  2. backend/src/auth/auth.service.ts logout()

    • auth.logout, actorId from the userId parameter, severity info. (Email/org are not currently loaded in this method and are not fetched solely for audit purposes — actorId is sufficient to trace the event.)
  3. backend/src/rbac/role.service.ts update()

    • rbac.role.permissions_updated, severity high (permission changes are sensitive). Requires threading currentUser: CurrentUserData into update(id, updateRoleDto, currentUser) from backend/src/roles/role.controller.ts, which does not currently inject @CurrentUser() — this is added as part of this change. metadata includes the role id/name and the new permission set.
  4. backend/src/organizations/organizations.service.ts create()

    • organization.created, severity info, organizationId set to the newly created organization’s id. Requires threading currentUser: CurrentUserData into create(createOrgDto, currentUser) from backend/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 hardcoded auditSecurityFeed: [] with a call to AuditService.getRecentForDashboard(...), mapped to the existing SuperAdminAuditSecurityFeedItemDto[] shape (id, type, message, createdAt). buildEmptySuperAdminSections keeps 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. The warnings array collapses to just the system-health warning (if any).
  • No frontend changes: frontend/src/pages/dashboard/super-admin/SuperAdminDashboard.tsx already renders warnings conditionally and sections.auditSecurityFeed directly 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.record called with expected payloads on login success, each login failure branch, and logout.
  • role.service.spec.ts (rbac): AuditService.record called on update() with expected metadata.
  • organizations.service.spec.ts: AuditService.record called on create().
  • super-admin-dashboard.service.spec.ts / dashboard-query.service.ts specs: updated to assert the warning is gone and auditSecurityFeed is populated from AuditService.
  • One migration (CreateAuditEventsTable) — no RBAC permission migration needed since audit:read:system/audit:read:organization already exist.

Risks / Notes

  • logout() doesn’t currently load the user record, so the audit entry for logout has actorEmail: null. Fetching the user solely to enrich an audit log entry was judged not worth the extra query; actorId is enough to trace the session.
  • Threading currentUser into RoleService.update() and OrganizationsService.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.