Skip to content

feat: add paginated audit-log retrieval for group administrators - #134

Merged
Cjay-Cyber-2 merged 3 commits into
mergepay:mainfrom
AdaBebe0:feat/audit-log-retrieval
Jul 31, 2026
Merged

feat: add paginated audit-log retrieval for group administrators#134
Cjay-Cyber-2 merged 3 commits into
mergepay:mainfrom
AdaBebe0:feat/audit-log-retrieval

Conversation

@AdaBebe0

Copy link
Copy Markdown
Contributor

Summary

Closes #113

Adds GET /groups/:id/audit-log, an admin-only endpoint that surfaces the audit trail (AuditLog rows) already being written for membership, expense, treasury, and settlement changes, so group administrators have an auditable history without loading an unbounded collection.

  • Authorization: uses the existing requireAdmin helper. Non-admin members get the repository's standard 403 FORBIDDEN; non-members/unknown groups still resolve through requireMembership's existing 403/404 behavior. No new authorization pattern introduced.
  • Scoping: AuditLog previously had no direct link to a group (only entityType/entityId, which vary per action and aren't uniformly joinable back to a group). Added a nullable group_id column via migration, backed by an index on (group_id, created_at), and threaded groupId through the existing audit() call sites in the group, expense, settlement, and treasury routes plus the settlement worker and reconciliation loop. Historical rows written before this change remain ungrouped (group_id IS NULL) and won't appear in this endpoint — there's no reliable way to backfill them without guessing at group membership from entityType/entityId, so filtering, not backfill, was the pragmatic choice here.
  • Pagination: stable cursor pagination ordered by (createdAt desc, id desc), bounded page size (limit, 1-100, default 50, validated via Zod), returning an opaque nextCursor (the last row's id) or null when there's no more data. Query/pagination logic lives in src/services/audit-log.ts, not the route, per the "keep Horizon/DB access in services" convention, and queries only ever load one page (take: limit + 1) rather than the full collection.
  • Filters: optional action (exact match), actorUserId, and from/to (ISO-8601 date-time) are validated server-side and applied in the Prisma where clause; an invalid range (from after to) returns 400.
  • Redaction: the serializer strips a denylist of known-sensitive metadata keys (signedXdr, transactionXdr, xdr, token, jwt, secret, password, privateKey, secretKey, authorization) before the response is built. No current write path stores these in AuditLog.metadata, but this makes the guarantee explicit rather than implicit.
  • Response shape: { events: [{ id, createdAt, actorUserId, actorDisplayName, action, entityType, entityId, metadata }], nextCursor }. action/entityType/entityId carry the existing action-taxonomy convention (e.g. settlement.confirmed vs settlement.failed) which already encodes outcome — no separate outcome column was added since the schema doesn't need one to represent it.

mergepay-web/src/lib/types.ts isn't in this repository, so the contract above isn't mirrored there in this PR — flagging so the corresponding web-side type can be added when this ships.

Testing

  • tests/audit-log.test.ts (new): non-admin 403, non-member 403, successful admin listing with metadata redaction, cursor/nextCursor behavior (including the take: limit + 1 lookahead), filter parameters reaching the query, invalid date range, over-max limit, and an empty-page response.
  • Existing route/audit-log-writer tests remain green (audit writes are now called with an extra groupId field but retain their try/catch-swallowed, best-effort semantics).
npx prisma generate   # regenerates client for the new AuditLog.groupId field
npm test               # 84 passed
npm run build           # clean
npm run lint             # 1 pre-existing failure in src/services/money.ts (prefer-const), unrelated to this change and present on main before this branch

npm run test:integration requires a live server + database/testnet connection not available in this sandbox, so it wasn't run end-to-end here.

Add GET /groups/:id/audit-log, restricted to a group's admins via the
existing requireAdmin check. Non-members and non-admins get the
repository's standard 403 FORBIDDEN response; unknown groups still
404 through requireMembership.

Results are ordered newest first and paginated with an opaque
cursor bounded to a validated page size (1-100, default 50), with
optional server-validated filters for action, actor, and a
created-at date range. Query and pagination logic lives in
src/services/audit-log.ts rather than the route so it stays testable
independent of Fastify, and the query is scoped by an indexed
group_id column added to audit_logs via migration (previously audit
events had no direct group association).

Response metadata is redacted of known-sensitive keys (signed XDR,
tokens, secrets) before serialization as a defensive measure, even
though no current write path stores them there.

The audit_logs.group_id column is populated going forward by
threading groupId through the existing audit() call sites in group,
expense, settlement, and treasury routes and the settlement worker;
past events remain ungrouped until backfilled.

Closes mergepay#113
@drips-wave

drips-wave Bot commented Jul 29, 2026

Copy link
Copy Markdown

@AdaBebe0 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Cjay-Cyber-2
Cjay-Cyber-2 merged commit a4ad5f2 into mergepay:main Jul 31, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add paginated and scoped audit-log retrieval for group administrators

2 participants