Paginate the audit log for org admins
Java · Spring Boot · intermediate · modification
Adds the audit log endpoint behind the admin console's new Audit tab. It pages with a keyset cursor instead of `OFFSET`: each page asks for the rows after the last one it returned, ordered by `(createdAt, id)` descending, so page 500 costs about what page 1 does, and fetching one extra row tells us whether there is a next page without a count query. The cursor is the last row's timestamp in microseconds and its id, base64url-encoded, and anything that does not decode is a 400. Rows come back as a DTO built by a constructor expression with a left join to the actor, so there are no entities, no lazy loading and no query per row. Migration V27 adds the `(org_id, created_at DESC, id DESC)` index the query walks. Tests cover a walk across events that share a timestamp, system events, org isolation and the rejected limits and cursors.
Workspace SaaS backend on Spring Boot 3.3 (Java 21), Spring Data JPA 3.3 and Hibernate 6.5 on PostgreSQL 16; Jackson writes `Instant` as ISO-8601 (Spring Boot's default). Existing and unchanged: `AuditEvent` (`audit_events`: `id` bigint identity, `org_id`, nullable `actor_id`, `action`, `target_type`, `target_id`, `created_at` `timestamptz`; constructor `AuditEvent(Long orgId, Member actor, String action, String targetType, String targetId, Instant createdAt)`) is written only by the existing `AuditLogger`, which sets `createdAt` from the server clock when the event happens; events are append-only, never updated or deleted (retention is out of scope), and an actor always belongs to the event's org. `Member` (`members`, constructor `Member(Long orgId, String displayName)`) is deactivated, never deleted. `org_id` columns are plain bigints without a foreign key (orgs live in the accounts service). `audit_events` has only its primary key index today. `SecurityConfig` requires authentication on `/api/**`, enables `@EnableMethodSecurity` and puts a `MemberPrincipal(long memberId, long orgId)` in the context of every authenticated request; org admins carry `ROLE_ADMIN`. A `ResponseStatusException` answers with Spring Boot's default error body. Flyway runs at startup; the newest migration is V26, and the service runs as a single instance whose deploys stop the old version before the new one starts (a short downtime is accepted). Tests run against a Testcontainers PostgreSQL 16 with the Flyway migrations applied (`src/test/resources/application.properties`, unchanged). The admin console's Audit tab ships in a separate frontend PR; nothing else calls this endpoint.
Requirements
- `GET /api/audit-events` (new) returns the audit events of the signed-in member's own org, newest first: by `createdAt` descending, ties broken by `id` descending. Only org admins (authority `ROLE_ADMIN`) may call it, and the org always comes from the session, never from a parameter. The body is `{"items": [{"id", "createdAt", "action", "targetType", "targetId", "actor"}], "nextCursor"}`, where `actor` is `{"id", "displayName"}`, or `null` for an event the system wrote.
- Paging: an absent or empty `limit` defaults to 50, and otherwise it must be an integer from 1 to 200; an absent `cursor` means the first page, and otherwise clients pass back the `nextCursor` of an earlier response unchanged. A `limit` outside that range or not an integer answers 400, and so does a `cursor` that is not a base64url-encoded `<micros>:<id>` (two runs of digits, at most 16 and 18 long); a well-formed cursor a client edited only moves the position inside the caller's own org. `nextCursor` is `null` on the last page. Following `nextCursor` until it is `null` returns every event of the org that existed when the walk started exactly once; events written during the walk may or may not show up.
- No totals: the admin console shows "Load more", so the response carries no count and computing one is out of scope.
- The largest org has about 600,000 events (the table holds about 3 million and grows by about 10,000 a day). Every page, the deepest included, must be served by one SQL statement that reads only about a page's worth of rows through an index, with no extra query per event for its actor. The index ships in this PR as a Flyway migration.
Files touched
- src/main/java/com/acme/workspace/audit/AuditEventRepository.java
- src/main/java/com/acme/workspace/audit/AuditEventView.java
- src/main/java/com/acme/workspace/audit/AuditPage.java
- src/main/java/com/acme/workspace/audit/AuditCursor.java
- src/main/java/com/acme/workspace/audit/InvalidPageRequestException.java
- src/main/java/com/acme/workspace/audit/AuditEventService.java
- src/main/java/com/acme/workspace/audit/AuditEventController.java
- src/main/resources/db/migration/V27__audit_events_org_created_idx.sql
- src/test/java/com/acme/workspace/audit/AuditEventServiceTest.java
--- src/main/java/com/acme/workspace/audit/AuditEventRepository.java
package com.acme.workspace.audit;
+import java.time.Instant;
+import java.util.List;
+import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface AuditEventRepository extends JpaRepository<AuditEvent, Long> {
+
+ @Query("""
+ select new com.acme.workspace.audit.AuditEventView(
+ e.id, e.createdAt, e.action, e.targetType, e.targetId, a.id, a.displayName)
+ from AuditEvent e left join e.actor a
+ where e.orgId = :orgId
+ order by e.createdAt desc, e.id desc
+ """)
+ List<AuditEventView> firstPage(@Param("orgId") long orgId, Limit limit);
+
+ // A row comparison rather than the equivalent OR, so PostgreSQL can use it as
+ // the index condition and start the scan right after the cursor.
+ @Query("""
+ select new com.acme.workspace.audit.AuditEventView(
+ e.id, e.createdAt, e.action, e.targetType, e.targetId, a.id, a.displayName)
+ from AuditEvent e left join e.actor a
+ where e.orgId = :orgId
+ and (e.createdAt, e.id) < (:createdAt, :id)
+ order by e.createdAt desc, e.id desc