|
| 1 | +# Per-User Audit History — `GET /api/audit/user/:addr` |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Returns a paginated list of audit log entries for a single Stellar wallet address. This endpoint supports the GrantFox FWC26 campaign requirement for per-user audit history. |
| 6 | + |
| 7 | +## Authentication & Authorisation |
| 8 | + |
| 9 | +| Caller | Allowed addresses | |
| 10 | +|--------|------------------| |
| 11 | +| Authenticated user (any role) | Own `stellarAddress` only | |
| 12 | +| Admin (`role: "admin"`) | Any address | |
| 13 | + |
| 14 | +All requests require a valid `Authorization: Bearer <JWT>` header. Missing or invalid tokens receive **401**. A valid user querying a different user's address receives **403**. |
| 15 | + |
| 16 | +## Request |
| 17 | + |
| 18 | +``` |
| 19 | +GET /api/audit/user/:addr |
| 20 | +``` |
| 21 | + |
| 22 | +### Path parameter |
| 23 | + |
| 24 | +| Parameter | Type | Required | Description | |
| 25 | +|-----------|------|----------|-------------| |
| 26 | +| `addr` | string | ✓ | Stellar public key — must match `G[A-Z2-7]{55}` | |
| 27 | + |
| 28 | +Returns **400** if the address does not match the Stellar public-key format. |
| 29 | + |
| 30 | +### Query parameters |
| 31 | + |
| 32 | +| Parameter | Type | Default | Description | |
| 33 | +|-----------|------|---------|-------------| |
| 34 | +| `cursor` | string | — | Opaque pagination cursor from the previous page's `nextCursor`. Omit for the first page. | |
| 35 | +| `limit` | integer | `20` | Records per page. Clamped to 1–100. | |
| 36 | +| `action` | string | — | Exact-match filter on the `action` field (e.g. `"auth.login"`). | |
| 37 | +| `startDate` | ISO 8601 | — | Inclusive lower-bound on `created_at`. | |
| 38 | +| `endDate` | ISO 8601 | — | Inclusive upper-bound on `created_at`. | |
| 39 | + |
| 40 | +Unknown query parameters are rejected with **422**. |
| 41 | + |
| 42 | +## Response |
| 43 | + |
| 44 | +### 200 OK |
| 45 | + |
| 46 | +```json |
| 47 | +{ |
| 48 | + "data": [ |
| 49 | + { |
| 50 | + "id": "11111111-1111-1111-1111-111111111111", |
| 51 | + "action": "auth.login", |
| 52 | + "walletAddress": "GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF", |
| 53 | + "ip": "203.0.113.42", |
| 54 | + "correlationId": "abc-def-123", |
| 55 | + "rateLimitContext": null, |
| 56 | + "createdAt": "2026-07-01T12:00:00.000Z" |
| 57 | + } |
| 58 | + ], |
| 59 | + "nextCursor": null |
| 60 | +} |
| 61 | +``` |
| 62 | + |
| 63 | +Entries are ordered by `(created_at DESC, id DESC)`. The `nextCursor` field is `null` when there are no further pages. |
| 64 | + |
| 65 | +### Error responses |
| 66 | + |
| 67 | +| Status | `error.code` | Cause | |
| 68 | +|--------|-------------|-------| |
| 69 | +| 400 | `request_failed` | `:addr` is not a valid Stellar public key | |
| 70 | +| 401 | `unauthenticated` | Missing or invalid Bearer token | |
| 71 | +| 403 | `forbidden` | Caller is requesting another user's history without admin role | |
| 72 | +| 422 | `validation_error` | Invalid query parameter (bad `limit`, `startDate`, etc.) | |
| 73 | +| 429 | `rate_limit_exceeded` | More than 60 requests/minute from the same token | |
| 74 | +| 500 | `internal_error` | Unexpected server error | |
| 75 | + |
| 76 | +All error responses follow the standard envelope: |
| 77 | + |
| 78 | +```json |
| 79 | +{ |
| 80 | + "error": { |
| 81 | + "code": "forbidden", |
| 82 | + "message": "You are not authorised to view audit logs for this address", |
| 83 | + "correlationId": "abc-def-123" |
| 84 | + } |
| 85 | +} |
| 86 | +``` |
| 87 | + |
| 88 | +## Pagination |
| 89 | + |
| 90 | +Pagination uses the same opaque keyset cursor as `GET /api/admin/audit`. The cursor encodes `(created_at, id)` of the last row on the current page. Never construct a cursor manually — always use the `nextCursor` value returned by the API. |
| 91 | + |
| 92 | +``` |
| 93 | +GET /api/audit/user/GABC...?limit=2 |
| 94 | +→ { data: [{...}, {...}], nextCursor: "eyJ..." } |
| 95 | +
|
| 96 | +GET /api/audit/user/GABC...?limit=2&cursor=eyJ... |
| 97 | +→ { data: [{...}], nextCursor: null } |
| 98 | +``` |
| 99 | + |
| 100 | +See [audit-log-pagination.md](./audit-log-pagination.md) for the full pagination contract. |
| 101 | + |
| 102 | +## Rate limiting |
| 103 | + |
| 104 | +60 requests per minute per JWT token (falls back to IP when the `Authorization` header is absent). Exceeding the limit returns **429** with `{ "error": { "code": "rate_limit_exceeded" } }`. |
| 105 | + |
| 106 | +## Structured logging |
| 107 | + |
| 108 | +Every successful request emits a `user_audit_fetch` log line at `info` level: |
| 109 | + |
| 110 | +```json |
| 111 | +{ |
| 112 | + "correlationId": "...", |
| 113 | + "addr": "GABC...", |
| 114 | + "filters": { "action": null, "startDate": null, "endDate": null, "limit": 20, "hasCursor": false }, |
| 115 | + "callerAddress": "GABC...", |
| 116 | + "msg": "user_audit_fetch" |
| 117 | +} |
| 118 | +``` |
| 119 | + |
| 120 | +Forbidden attempts (non-admin querying another address) emit a `user_audit_forbidden` warning: |
| 121 | + |
| 122 | +```json |
| 123 | +{ |
| 124 | + "correlationId": "...", |
| 125 | + "callerAddress": "GABC...", |
| 126 | + "requestedAddress": "GXYZ...", |
| 127 | + "msg": "user_audit_forbidden" |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +## Relevant files |
| 132 | + |
| 133 | +| File | Purpose | |
| 134 | +|------|---------| |
| 135 | +| `src/routes/audit/user.ts` | Route handler | |
| 136 | +| `src/repositories/auditLogRepo.ts` | `getAuditLogsByUser()` — DB query | |
| 137 | +| `src/middleware/requireAuth.ts` | JWT authentication | |
| 138 | +| `src/utils/cursor.ts` | Cursor encode/decode | |
| 139 | +| `src/__tests__/routes/auditUser.test.ts` | Unit tests | |
| 140 | +| `openapi.yaml` | OpenAPI spec (`/api/audit/user/{addr}`) | |
0 commit comments