# Tenant Audit Log

**Date:** 2026-05-31  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `system-communications-notifications`, `foundation-monorepo`, `operational-audit-trail`  
**Referenced by:** `settings-module`, `bulk-operations`, `data-export-gdpr`  
**Consolidates:** spec 104 (`audit-log-viewer`) — retired; `/reports/audit` viewer folded in here

---

## Overview

Tenants (OWNER/ADMIN) can view a chronological, immutable log of all write-level actions taken within their workspace. The log is the authoritative record for compliance, debugging, and accountability. Entries are written asynchronously via Cloudflare Queue to avoid adding latency to mutation handlers.

---

## What Gets Logged

### User Management
- `user.invited` — member invited (actor, invitee email, role assigned)
- `user.role_changed` — member role changed (old role → new role)
- `user.removed` — member removed from workspace
- `user.2fa_enforced` — tenant-level 2FA enforcement toggled
- `user.frozen` / `user.unfrozen`

### Data Changes
- `customer.created` / `customer.updated` / `customer.deleted`
- `invoice.created` / `invoice.updated` / `invoice.status_changed` (old → new status)
- `invoice.sent` / `invoice.paid` / `invoice.voided`
- `project.created` / `project.updated` / `project.deleted`
- `expense.created` / `expense.updated` / `expense.deleted`
- `contract.signed` / `contract.voided`

### Settings Changes
- `settings.updated` — any settings field changed; metadata: `{ field, old_value, new_value }`. Sensitive fields (passwords, tokens) are masked as `[redacted]`.

### Auth Events
- `auth.login_success` / `auth.login_failed` (with IP)
- `auth.logout`
- `auth.password_reset_requested` / `auth.password_reset_completed`
- `auth.2fa_enabled` / `auth.2fa_disabled`
- `auth.2fa_backup_code_used`

### Integration Events
- `webhook.delivered` / `webhook.failed` (endpoint, HTTP status)
- `payment_gateway.configured` / `payment_gateway.removed`
- `smtp.configured` / `smtp.verified`

### Bulk Operations
- `import.completed` — rows imported, rows failed, source file name
- `bulk.status_changed` — entity type, count of records changed, old → new status

### API Key Events
- `api_key.created` / `api_key.revoked`
- `api_key.used_from_new_ip` — IP logged, key ID (not secret)

---

## What Is NOT Logged

- GET / read operations — write events only
- Time entries — too high volume; these have their own `updated_at` history
- Individual task comments — use the task activity feed instead
- Internal system jobs (cron, queue processing) — these emit structured Worker logs, not audit events

---

## Data Model

```sql
CREATE TABLE tenant_audit_log (
  id          UUID    PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID    NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id     UUID    REFERENCES users(id) ON DELETE SET NULL, -- NULL for system/webhook events
  actor_name  TEXT,   -- denormalized display name at time of event
  actor_email TEXT,   -- denormalized
  event_type  TEXT    NOT NULL, -- e.g. 'customer.created', 'invoice.status_changed'
  entity_type TEXT,   -- 'customer', 'invoice', 'user', 'settings', etc.
  entity_id   UUID,   -- NULL for non-row events (e.g. 'settings')
  entity_label TEXT,  -- denormalized display name at time of event (e.g. customer company name)
  metadata    JSONB,  -- { old_value, new_value, ip_address, user_agent, ... }
  ip_address  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_tal_tenant_time    ON tenant_audit_log(tenant_id, created_at DESC);
CREATE INDEX idx_tal_entity         ON tenant_audit_log(tenant_id, entity_type, entity_id);
CREATE INDEX idx_tal_user           ON tenant_audit_log(tenant_id, user_id);
CREATE INDEX idx_tal_event_type     ON tenant_audit_log(tenant_id, event_type);
```

> `metadata` is `JSONB` (Neon Postgres) — queryable and indexable at the DB layer; no application-side JSON string parsing needed.

---

## Features & Screens

### `/settings/audit-log`

**Access:** OWNER and ADMIN only. Redirect other roles to `/403`.

**Layout:**
- Page header: "Audit Log" with "Export CSV" button (top-right)
- Filter bar (collapsible on mobile):
  - Date range picker (from / to) — defaults to last 7 days
  - Actor dropdown (team members) — "All actors"
  - Event type dropdown (grouped: User, Data, Settings, Auth, Integration, Bulk, API Keys)
  - Entity type dropdown

**Table columns:** Date/Time | Actor | Event | Entity | Details

- **Date/Time:** formatted per tenant locale (DD/MM/YYYY HH:mm), sortable (default: newest first)
- **Actor:** avatar + name; "System" for automated events
- **Event:** human-readable label (e.g. "Invoice status changed", "Member invited")
- **Entity:** entity type + label (e.g. "Customer — Acme Ltd")
- **Details:** truncated summary, e.g. "Draft → Sent" or "Role: Member → Admin"

**Row expand:** click row → inline expansion showing:
- Full metadata as readable key/value pairs
- For `settings.updated`: diff view (old value / new value), sensitive fields masked
- IP address and User-Agent (collapsed under "Technical details")
- Raw JSON toggle for developers

**Pagination:** 50 rows per page, cursor-based (no offset). "Load more" button at bottom.

**Retention:**
- Business plan: 90 days
- Enterprise plan: 365 days
- Rows older than retention limit are purged by a nightly Cron Trigger Worker

---

## Permissions

| Role | Can View | Can Export | Can Delete |
|------|----------|------------|------------|
| OWNER | Yes | Yes | No (immutable) |
| ADMIN | Yes | Yes | No |
| MEMBER | No | No | No |
| CONTRACTOR | No | No | No |
| CLIENT_PORTAL | No | No | No |

Audit log entries are immutable — no DELETE or UPDATE operations are permitted on `tenant_audit_log` rows from application code.

---

## API Endpoints

### `GET /api/audit-log`

Query parameters:
- `from` — ISO date string or Unix timestamp
- `to` — ISO date string or Unix timestamp
- `user_id` — filter by actor user ID
- `event_type` — filter by event type (e.g. `customer.created`)
- `entity_type` — filter by entity type (e.g. `invoice`)
- `cursor` — pagination cursor (opaque string, base64-encoded `{id, created_at}`)
- `limit` — default 50, max 200

Response:
```json
{
  "items": [
    {
      "id": "abc123",
      "event_type": "invoice.status_changed",
      "entity_type": "invoice",
      "entity_id": "inv_xyz",
      "entity_label": "INV-0042",
      "actor_name": "Dana Cohen",
      "actor_email": "dana@example.co.il",
      "metadata": { "old_value": "draft", "new_value": "sent" },
      "ip_address": "1.2.3.4",
      "created_at": 1748649600
    }
  ],
  "next_cursor": "eyJpZCI6ImFiYzEyMyIsImNyZWF0ZWRfYXQiOjE3NDg2NDk2MDB9",
  "has_more": true
}
```

### `GET /api/audit-log/export`

Returns CSV file (streamed, `Content-Disposition: attachment`). Accepts same query params as list endpoint. Max export: 10,000 rows. For large exports use the data-export-gdpr async job flow.

---

---

## Global Viewer: `/reports/audit`

Second mounting point for the same `AuditLogTable` component, accessible from the Reports nav section.

**Access:** OWNER or ADMIN. MEMBER sees own-actions only (`WHERE user_id = currentUserId`).

```
┌──────────────────────────────────────────────────────────────┐
│  Audit Log                                          [Export ▾]│
│                                                              │
│  Date: [Last 30 days ▾]  Actor: [All ▾]  Type: [All ▾]      │
│  Entity: [All ▾]  Search: [__________________________]       │
│                                                              │
│  Time               Actor          Event                      │
│  ─────────────────────────────────────────────────────────── │
│  2026-05-31 14:22   Alex Cohen     invoice.status_changed     │
│                     INV-0042       SENT → APPROVED            │
│  2026-05-31 13:05   Dana Levi      customer.updated           │
│                     Acme Corp      email changed              │
│  [< Prev]  Page 1 of 47  [Next >]                            │
└──────────────────────────────────────────────────────────────┘
```

Additional filters over `/settings/audit-log`:
- **Entity text search** — free-text on `entity_label`
- **Full-text search** — across `actor_name`, `actor_email`, `entity_label`, `event_type`
- Default date range: Last 30 days (vs. last 7 days in settings view)

**Event Detail Panel** (click any row → side panel):

Shows full `before_state` / `after_state` diff (from spec 50 `operational-audit-trail`). If `before_state` is NULL (pre-spec-50 events or creates), Before column shows "—". Includes `[View entity]` link to `/invoices/:id`, `/customers/:id`, etc. based on `entity_type + entity_id`.

**CSV Export:** same `GET /api/audit-log/export` endpoint (Business+).

**Per-Entity History Tab:** Spec 50 adds a "History" tab to detail pages. That tab uses the same list component filtered by `entity_type + entity_id` with date filter removed (all time for that entity).

---

## Architecture Decisions

### Fire-and-forget via Cloudflare Queue

Mutation handlers call `logAuditEvent(ctx, event)` which enqueues a message to the `audit-log-queue` Cloudflare Queue. The mutation returns immediately; a separate Queue Consumer Worker inserts the row into Postgres (Neon via Hyperdrive). This means:
- Audit writes never block API responses
- If the DB write fails, the Queue retries (up to 3 times with exponential backoff)
- Rare queue failures result in missing audit entries (acceptable trade-off vs. blocking mutations)

```typescript
// Signature
async function logAuditEvent(ctx: AppContext, event: AuditEvent): Promise<void>

interface AuditEvent {
  tenantId: string
  userId?: string
  actorName?: string
  actorEmail?: string
  eventType: string
  entityType?: string
  entityId?: string
  entityLabel?: string
  metadata?: Record<string, unknown>
  ipAddress?: string
}
```

### Denormalization

`actor_name`, `actor_email`, and `entity_label` are stored at write time. This ensures the audit log remains historically accurate even if users are later renamed or deleted, or entities are deleted.

### Sensitive field masking

When logging `settings.updated` events, the `logAuditEvent` helper checks the field name against a blocklist (`smtp_password_encrypted`, `api_key_secret`, `webhook_secret`, `oauth_token`, `dkim_private_key`) and replaces values with `[redacted]` before inserting metadata.

### Retention enforcement

A Cron Trigger fires nightly at 03:00 UTC:
```sql
DELETE FROM tenant_audit_log
WHERE tenant_id IN (
  SELECT id FROM tenants WHERE plan = 'business'
) AND created_at < now() - INTERVAL '90 days';

DELETE FROM tenant_audit_log
WHERE tenant_id IN (
  SELECT id FROM tenants WHERE plan = 'enterprise'
) AND created_at < now() - INTERVAL '365 days';
```
