# Audit Log

## What Is Logged

- **All mutations** (CREATE, UPDATE, DELETE) on business entities: users, roles, invitations, sessions
- **Sensitive reads**: reading the user list, reading the audit log itself
- **Auth events**: login (session creation), logout (session deletion), invitation acceptance
- **Page views**: every page a signed-in employee opens (`page_view`, READ), sent
  by the browser through the `recordPageView` mutation — see below

## Audit Entry Fields

| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Unique audit entry ID |
| `actorUserId` | UUID \| null | The user who performed the action (null for system actions) |
| `operation` | String | `CREATE`, `READ`, `UPDATE`, or `DELETE` |
| `entityType` | String | The type of entity affected (e.g., `user`, `role`, `invitation`, `session`, `audit_log`) |
| `entityId` | String \| null | The ID of the specific entity |
| `correlationId` | UUID \| null | Links related audit entries from the same request |
| `ipAddress` | String \| null | Client IP address |
| `userAgent` | String \| null | Client user agent |
| `before` | JSON \| null | Entity state before mutation (for UPDATE/DELETE) |
| `after` | JSON \| null | Entity state after mutation (for CREATE/UPDATE) |
| `metadata` | JSON \| null | Additional context (e.g., `{ action: "invitation_accepted", provider: "google" }`) |
| `createdAt` | DateTime | When the event occurred |

## Querying Audit Logs

Permission required: `audit.read`

```graphql
query {
  auditLogs(input: {
    entityType: "user"
    operation: "UPDATE"
    from: "2026-01-01T00:00:00Z"
    to: "2026-03-10T00:00:00Z"
    limit: 50
    offset: 0
  }) {
    items {
      id
      actorUserId
      operation
      entityType
      entityId
      before
      after
      metadata
      createdAt
    }
    total
    limit
    offset
  }
}
```

## Page Views

`recordPageViews(input: [{ clientId, viewedAt, path, search, title, name }])`
writes one `page_view` READ row per page opened: `entity_id` is the pathname,
`correlation_id` the browser's `clientId`, `created_at` when it was opened
(`viewedAt`, bounded to the last 24 hours), and `metadata` the query string
(`search`, the tab or filter), the page's Danish name (`title` — Danish whatever
language the user reads Core in, because stored audit data is Danish) and what a
detail page showed (`name`). API tokens are refused: only a person in a browser
opens pages.

Delivery is as close to guaranteed as a browser allows. The browser decides what
counts as a visit (`apps/frontend/src/app/page-view-log.ts`: redirects under
300 ms are not visits, a detail page waits up to 3 s for its name, a query string
being typed into waits to hold still for 2 s) and writes each visit to an outbox
in `localStorage` (`lib/page-view-outbox.ts`), removing it only once the server
has *settled* it. Delivery retries on every visit, every 30 s, when the network
comes back, on sign-in, and with `keepalive` as the page goes away. A view
delivered twice is recorded once (`clientId`, under a per-user advisory lock).
Past 120 a minute per user a view is left unsettled, so a backlog drains at
that rate rather than being dropped.
`logout(pageViews)` and `revokeAllMySessions(pageViews)` take what is still
waiting and record it in the request that ends the session — sent separately it
would race the sign-out and be refused. Views are only ever sent on their own
user's session; older than a day, they are dropped.

Page views are a large share of the log and never what someone opens it to find, so
`auditLogs`, `auditLogGroups` and the assistant's action list leave them out
unless asked for — `includePageViews: true`, or `entityType: "page_view"`.
The audit page has a "Vis sidevisninger" checkbox under Avanceret.

## Paging

`auditLogs` and `auditLogGroups` return `snapshotAt`, the server's clock at the
query; sent back as `asOf`, later pages see the log as it stood then, so rows
written meanwhile (reading the log writes one) do not shift them. The audit page
does this for every page after the first.

## Sessions and Presence

Ending a session — `logout`, `revokeAllMySessions`, and an administrator's
`revokeUserSession`/`revokeAllUserSessions` — releases the presence rows those
sessions held (`releaseEntityPresence`), so a colleague stops seeing the user on
a product at once rather than after the five-minute expiry.

## The AI Assistant

With `audit.read` the assistant has two tools (`src/modules/ai/audit-log-tools.ts`)
and the `auditlog` skill that explains them:

- `search_audit_log` takes a person's name or e-mail (resolved to a user by
  `findAuditActor`; an ambiguous name returns the candidates), `entityTypes`,
  `entityId`, `operation` and a period. It answers with counts per actor ×
  entity type × operation for the whole period, then the newest actions
  grouped by correlation id, each with the entity's name, link and the fields
  that changed. Rows written through an API token are counted apart from the
  token's owner: the N8N automation runs on a person's token and has written
  millions of rows under their user id. Without an `entityId` a search covers
  at most 93 days. Each call writes an `audit_log` READ row, like the audit page.
  With an actor it also returns their `journey`: the pages they opened, in
  order.
- `page_visits` answers "who has been on this page" from the `page_view` rows,
  per user, for a page and the pages under it, with which pages and tabs each
  user was on. Visits from before page views were logged cannot be seen; the
  sidebar's `recent_pages` list is not used as a stand-in.

`query_data` can still read `audit_logs` for counts over time.

## Access Rules

- Reading the audit log requires the `audit.read` permission
- Reading the audit log is itself audited (creates a READ entry for `audit_log` entity type)
- Audit log entries are append-only — they cannot be modified or deleted via the API

## Storage Design

- Stored in PostgreSQL `audit_logs` table
- Indexed on: `created_at`, `actor_user_id`, `entity_type` + `entity_id`, `operation`, `correlation_id`
- `before`, `after`, and `metadata` fields use JSONB for flexible storage
- Designed for future partitioning by `created_at` (monthly) for retention management
- Minimum retention: 24 months
