Remote Eaze
Features

Activity Logs

Centralized audit logging system with tiered guarantees, PII sanitization, and compliance-ready data model.

The Activity Log system is a centralized, compliance-ready auditing engine. It captures the "Who, What, Where, When, and Why" of every significant action.

Core Philosophy

  1. Immutable History: Once written, logs are never altered.
  2. Loose Coupling: Logs do not use Foreign Keys. If a User or Tenant is deleted, the audit trail remains intact (orphaned logs are a feature, not a bug).
  3. Security First: Sensitive data is sanitized before it leaves application memory. Secrets are redacted; PII is either redacted or encrypted depending on sensitivity level.
  4. Performance Tiers: Critical actions (money movement) are logged synchronously; non-critical actions (views) are fire-and-forget.

Architecture

The Service (modules/audit-logs/service.ts)

  • AuditService: The central dispatcher. Determines whether to write synchronously, queue, or fire-and-forget based on tier.
  • Sanitizer: A recursive engine that processes data based on a 3-level sensitivity system.
  • Diff Engine: Uses microdiff to calculate deep JSON differences between changeBefore and changeAfter, stored in dot-notation format.
  • Crypto: AES-256-GCM implementation for encrypting PII at HIGH sensitivity.

The Plugin (plugins/audit.ts)

  • Fastify Decorator: Adds request.audit.log() to every request via the onRequest hook.
  • Context Auto-fill: Automatically captures ipAddress, userAgent, requestId, traceId, sessionId, httpMethod, path, service, environment, and duration.
  • Actor Auto-population: Calls populateUser(req) to extract user context. Defaults actorType to "HUMAN" if user is authenticated, "SYSTEM" otherwise. Defaults actorId to user.id or "ANONYMOUS".
  • Runtime Validation: Drops the log with a warning if action, module, tier, or status are missing.
  • Safety: If a SYNC log fails, the error is re-thrown to abort the transaction. If a QUEUE/ASYNC log fails, it logs the error but allows the request to proceed.

The Helper (utils/audit-helper.ts)

Provides logSuccess() and logFailure() convenience functions that extract actor fields from request.user and combine them with route-specific config. Routes still provide all domain context (tags, metadata, tier, sensitivity) — the helpers only eliminate repetitive actor extraction.

await logSuccess(request, {
  action: "license.create",
  module: "LICENSES",
  entityType: "license",
  entityId: license.id,
  changeAfter: license,
  tier: "SYNC",
  sensitivity: "HIGH",
  tags: ["provisioning", "billing", "contract"],
  metadata: { companyName: license.companyName, statusCode: 201 },
  tenantId: "SYSTEM", // Override for system-level operations
});

logFailure() uses getFriendlyErrorMessage() from modules/audit-logs/utils.ts to extract safe error messages (matching the global error handler's logic) and automatically captures the HTTP status code from AppError instances.

Tiered Logging Strategy

3-tier strategy balancing data integrity with system performance:

TierUsageBehaviorFailure Mode
SYNCMoney movement, auth changes, security alertsBlocking. Awaits DB insert.Throws error. Request/transaction aborts.
QUEUEProfile updates, entity creation, settings changesNon-blocking. Pushes to BullMQ.Logs error. Request succeeds.
ASYNCDashboard views, search, report exportsFire & forget. No await.Logs error. Request succeeds.

QUEUE Tier Fallback

The QUEUE tier currently falls back to SYNC behavior until BullMQ worker infrastructure is deployed. QUEUE-tier logs are blocking in the current implementation.

If no tier is provided, the service determines it from the action name using a built-in mapping (AUDIT_TIERS in service.ts). Routes should always set tier explicitly.

Sensitivity & Sanitization

The sensitivity field controls how the sanitizer handles data. It is applied to changeBefore, changeAfter, and metadata before anything is written to the database.

Sensitivity Levels

LevelSecretsPIIUse Case
LOW[REDACTED][PII_REDACTED]Read-only / GET logs
MEDIUM (default)[REDACTED][PII_REDACTED]Standard operations
HIGH[REDACTED]Encrypted (AES-256-GCM, recoverable)Critical transactions with compliance needs

At HIGH sensitivity, PII is encrypted and recoverable by authorized admins. At MEDIUM and LOW, PII is irreversibly redacted with [PII_REDACTED].

The isSensitive boolean on the database row is set to true when sensitivity === "HIGH", flagging that the row contains encrypted (rather than redacted) PII.

Field Classification

Always Redacted ([REDACTED]) — secrets that must never appear in logs:

password, passwordConfirmation, oldPassword, newPassword, currentPassword, confirmPassword, token, accessToken, refreshToken, verificationToken, pin, clientSecret, apiKey, otp

Any key containing "password" is also redacted, unless it matches the Password Policy Whitelist (config fields like passwordMinLength, passwordExpiryDays, passwordHistory, etc.).

PII Fields — redacted at MEDIUM/LOW, encrypted at HIGH:

ssn, socialSecurityNumber, nationalId, pan, cardNumber, cvv, cvc, email, phone, phoneNumber, mobile, address, street, dob, dateOfBirth, iban, accountNumber

Truncated — large binary/file data capped at 20 characters to prevent storage bloat:

base64, image, file, buffer, pdf

Encryption

  • Algorithm: AES-256-GCM (authenticated encryption — verifies integrity).
  • Key Derivation: scrypt using ENCRYPTION_KEY + ENCRYPTION_SALT from environment variables.
  • Output Format: ENC:v1:<IV hex>:<AuthTag hex>:<Ciphertext hex>
  • Failure Handling: If encryption fails, the value is replaced with [ENCRYPTION_FAILED] rather than crashing the logging pipeline.

Data Model

Table: remoteEaze_activity_logs

Core Fields

FieldTypeDescription
idStringTime-sorted ID: YYYYMMDD-HHMMSSms-RAND (4-digit random suffix)
tenantIdStringTenant isolation key (no FK — loose reference)
timestampDateTime (Timestamptz)When the action actually occurred (UTC)
createdAtDateTime (Timestamptz)When the log was written to DB (differs for queued logs)

Actor Context (Who)

FieldTypeDescription
actorIdString?User ID or "ANONYMOUS" (no FK)
actorTypeActorTypeHUMAN, SYSTEM, SERVICE, CRON, IMPERSONATION
actorNameString?Snapshot of name at time of action
actorBranchString?Branch ID snapshot (from user.branchId)
actorRoleString?Role ID snapshot (from user.roleId)

Event Details (What)

FieldTypeDescription
actionStringAction identifier (e.g., license.create, auth.signin)
entityTypeStringType of entity affected (e.g., license, role)
entityIdString?ID of the specific entity
moduleString?Business module (e.g., AUTH, LICENSES, ACCESS_CONTROL)

Data Changes

FieldTypeDescription
changeBeforeJson?State before mutation (sanitized)
changeAfterJson?State after mutation (sanitized)
diffJson?Calculated delta via microdiff{ "field.path": { from, to } }
recordStatusBeforeString?Workflow state before (e.g., pending_auth_2)
recordStatusAfterString?Workflow state after (e.g., authorized)

Context (Where and How)

FieldTypeDescription
ipAddressString?Source IP address
userAgentString?Browser/client user agent
sessionIdString?Better Auth session ID (from cookie or x-session-id header)
requestIdString?Fastify request ID for correlation
traceIdString?From x-trace-id header (OpenTelemetry)
httpMethodString?HTTP method (GET, POST, etc.)
pathString?API endpoint path
serviceString?Defaults to "api-core"
environmentString?From NODE_ENV

Additional Context

FieldTypeDescription
tagsString[]Filterable tags (e.g., ["financial", "provisioning"])
metadataJson?Arbitrary extra data (also sanitized at the event's sensitivity level)
customFieldsJson?Extensibility for organization-specific needs

Status and Performance

FieldTypeDescription
statusLogStatusSUCCESS or FAILURE (PENDING is coerced to SUCCESS at write time)
errorString?Error message if status is FAILURE
durationInt?Milliseconds from request start to audit log call

Security and Compliance

FieldTypeDescription
isSensitiveBooleantrue when sensitivity === "HIGH" — row contains encrypted PII
retentionPolicyStringRetention rule. Defaults to "90_days". Options: 90_days, 1_year, 2_years, 7_years

Indexes

@@index([tenantId, timestamp])           // Tenant-scoped date range queries
@@index([tenantId, action, timestamp])   // Filter by action type
@@index([entityType, entityId])          // Entity audit history
@@index([actorId, timestamp])            // User activity timeline
@@index([traceId])                       // Distributed tracing
@@index([status])                        // Failure analysis
@@index([tags], type: Gin)               // Tag filtering (PostgreSQL GIN)
@@index([customFields], type: Gin)       // Custom field queries (PostgreSQL GIN)

Enums

ActorType: HUMAN | SYSTEM | SERVICE | CRON | IMPERSONATION

LogStatus: SUCCESS | FAILURE | PENDING

Usage Guide

Use request.audit.log() in route handlers. The RequestAuditEvent type requires action, module, tier, and status — the plugin will drop the log if any are missing.

Create (SYNC — Critical)

await request.audit.log({
  action: "transaction.create",
  module: "TRANSACTIONS",
  entityType: "transaction",
  entityId: transaction.id,
  changeAfter: transaction,
  tier: "SYNC",
  sensitivity: "HIGH",
  status: "SUCCESS",
  tags: ["financial", "high-value"],
});

Update (With Diff)

await request.audit.log({
  action: "user.update",
  module: "USERS",
  entityType: "user",
  entityId: userId,
  changeBefore: oldUser,
  changeAfter: newUser,
  tier: "QUEUE",
  sensitivity: "MEDIUM",
  status: "SUCCESS",
  tags: ["profile"],
});

View (ASYNC — Fire & Forget)

// No await — fire and forget
request.audit.log({
  action: "dashboard.view",
  module: "DASHBOARD",
  entityType: "dashboard",
  tier: "ASYNC",
  sensitivity: "LOW",
  status: "SUCCESS",
});
import { logSuccess, logFailure } from "../../utils/audit-helper";

try {
  const result = await service.create(data);
  await logSuccess(request, {
    action: "role.create",
    module: "ACCESS_CONTROL",
    entityType: "role",
    entityId: result.id,
    changeAfter: result,
    tier: "SYNC",
    sensitivity: "MEDIUM",
    tags: ["access-control", "rbac"],
    metadata: { roleName: result.name, statusCode: 201 },
  });
} catch (error) {
  await logFailure(request, {
    action: "role.create",
    module: "ACCESS_CONTROL",
    entityType: "role",
    tier: "SYNC",
    sensitivity: "LOW",
    tags: ["access-control", "rbac"],
    error,
    additionalMetadata: { attemptedName: data.name },
  });
  throw error; // Let global error handler send the response
}

On this page