Skip to content

Commit f64d030

Browse files
committed
feat: per-user audit endpoint (#325)
1 parent bda92c4 commit f64d030

7 files changed

Lines changed: 1090 additions & 0 deletions

File tree

PR_DESCRIPTION_325.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
feat: add GET /api/audit/user/:addr per-user audit history endpoint (#325)
2+
3+
## Summary
4+
5+
Implements per-user audit history for the GrantFox FWC26 campaign.
6+
Adds `GET /api/audit/user/:addr` which returns a paginated, cursor-based
7+
list of audit log entries scoped to a single Stellar wallet address.
8+
9+
## Changes
10+
11+
### New files
12+
- `src/routes/audit/user.ts` — route handler (factory pattern, matches
13+
existing audit sub-route conventions)
14+
- `src/__tests__/routes/auditUser.test.ts` — 22 unit tests; all DB and
15+
auth dependencies mocked via jest
16+
- `docs/user-audit-api.md` — API reference documentation
17+
18+
### Modified files
19+
- `src/repositories/auditLogRepo.ts` — added `getAuditLogsByUser()`;
20+
dedicated function that always pins the `walletAddress` predicate so it
21+
cannot be accidentally omitted by callers
22+
- `src/index.ts` — registered `userAuditRouter` at `/api/audit/user`
23+
- `openapi.yaml` — added `AuditLogEntry` and `UserAuditPage` component
24+
schemas; full `/api/audit/user/{addr}` path entry with response examples
25+
26+
## Endpoint contract
27+
28+
```
29+
GET /api/audit/user/:addr
30+
Authorization: Bearer <jwt>
31+
```
32+
33+
Query params: `cursor`, `limit` (1–100, default 20), `action`, `startDate`, `endDate`
34+
35+
Response:
36+
```json
37+
{ "data": [...], "nextCursor": "eyJ..." | null }
38+
```
39+
40+
Pagination uses the same `(created_at DESC, id DESC)` keyset cursor as
41+
`GET /api/admin/audit`. See `docs/audit-log-pagination.md`.
42+
43+
## Security
44+
45+
- `requireAuth` — 401 on missing/invalid JWT
46+
- Non-admin callers requesting a different address receive 403; the
47+
forbidden attempt is logged at warn level with caller + requested address
48+
- `:addr` validated against `G[A-Z2-7]{55}` before any DB access — bad
49+
addresses never reach the query layer
50+
- Query schema uses `.strict()` — unknown params rejected with 422
51+
- Rate limited: 60 req/min per JWT, keyed on Authorization header
52+
- Correlation ID propagated from ALS context to every log line
53+
54+
## Test coverage
55+
56+
22 tests across:
57+
- Happy path (empty page, entries returned, filters forwarded, cursor
58+
returned, cursor forwarded)
59+
- Address validation (wrong prefix, too short, lowercase, special chars,
60+
valid)
61+
- Query param validation (bad limit, bad dates, unknown params, boundary
62+
values)
63+
- Authorisation (non-admin cross-address → 403, own address → 200,
64+
admin cross-address → 200, unauthenticated → 401)
65+
- Error handling (repo throws → 500, error body present)
66+
- Structured logging (user_audit_fetch log emitted, correlationId present,
67+
user_audit_forbidden warning emitted)
68+
69+
## Checklist
70+
71+
- [x] Implementation matches description
72+
- [x] Input validation at the boundary; standardised error envelope
73+
- [x] Structured logging with correlation IDs
74+
- [x] Rate limiting applied
75+
- [x] OpenAPI spec updated
76+
- [x] API reference docs added
77+
- [x] No diagnostics (tsc, language server)
78+
- [x] Follows repo lint and code style

docs/user-audit-api.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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}`) |

openapi.yaml

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,53 @@ components:
969969
required:
970970
- totalCount
971971
- byAction
972+
AuditLogEntry:
973+
type: object
974+
description: A single audit log entry for a wallet address.
975+
properties:
976+
id:
977+
type: string
978+
format: uuid
979+
action:
980+
type: string
981+
description: Action identifier, e.g. "auth.login" or "market.create"
982+
walletAddress:
983+
type: string
984+
nullable: true
985+
description: Stellar wallet address of the actor
986+
ip:
987+
type: string
988+
description: IP address of the originating request
989+
correlationId:
990+
type: string
991+
description: Correlation ID for cross-log tracing
992+
rateLimitContext:
993+
nullable: true
994+
description: Optional rate-limit snapshot at request time
995+
createdAt:
996+
type: string
997+
format: date-time
998+
required:
999+
- id
1000+
- action
1001+
- ip
1002+
- correlationId
1003+
- createdAt
1004+
UserAuditPage:
1005+
type: object
1006+
description: A paginated page of audit log entries for a single user.
1007+
properties:
1008+
data:
1009+
type: array
1010+
items:
1011+
$ref: '#/components/schemas/AuditLogEntry'
1012+
nextCursor:
1013+
type: string
1014+
nullable: true
1015+
description: Opaque cursor for the next page, or null if this is the last page.
1016+
required:
1017+
- data
1018+
- nextCursor
9721019
PluginView:
9731020
type: object
9741021
properties:
@@ -3779,6 +3826,122 @@ paths:
37793826
application/json:
37803827
schema:
37813828
$ref: '#/components/schemas/ErrorBody'
3829+
/api/audit/user/{addr}:
3830+
get:
3831+
operationId: getUserAuditHistory
3832+
tags:
3833+
- Audit
3834+
summary: Per-user audit history
3835+
description: >
3836+
Returns paginated audit log entries for the given Stellar wallet
3837+
address, ordered by `(created_at DESC, id DESC)`.
3838+
3839+
Authenticated users may only query their own address. Admins
3840+
(`role: "admin"`) may query any address.
3841+
3842+
Pagination uses the same opaque keyset cursor as the admin audit
3843+
endpoint — always use the `nextCursor` value from the previous
3844+
response; never construct a cursor manually.
3845+
security:
3846+
- bearerAuth: []
3847+
parameters:
3848+
- in: path
3849+
name: addr
3850+
required: true
3851+
schema:
3852+
type: string
3853+
pattern: '^G[A-Z2-7]{55}$'
3854+
description: Stellar public key (G + 55 base-32 uppercase characters)
3855+
- in: query
3856+
name: cursor
3857+
required: false
3858+
schema:
3859+
type: string
3860+
description: Opaque pagination cursor from a previous response
3861+
- in: query
3862+
name: limit
3863+
required: false
3864+
schema:
3865+
type: integer
3866+
minimum: 1
3867+
maximum: 100
3868+
default: 20
3869+
description: Records per page (1–100, default 20)
3870+
- in: query
3871+
name: action
3872+
required: false
3873+
schema:
3874+
type: string
3875+
description: Filter by exact action string (e.g. "auth.login")
3876+
- in: query
3877+
name: startDate
3878+
required: false
3879+
schema:
3880+
type: string
3881+
format: date-time
3882+
description: Inclusive lower-bound on createdAt (ISO 8601)
3883+
- in: query
3884+
name: endDate
3885+
required: false
3886+
schema:
3887+
type: string
3888+
format: date-time
3889+
description: Inclusive upper-bound on createdAt (ISO 8601)
3890+
responses:
3891+
'200':
3892+
description: Paginated audit log for the requested address
3893+
content:
3894+
application/json:
3895+
schema:
3896+
$ref: '#/components/schemas/UserAuditPage'
3897+
examples:
3898+
singleEntry:
3899+
summary: One log entry, no further pages
3900+
value:
3901+
data:
3902+
- id: 11111111-1111-1111-1111-111111111111
3903+
action: auth.login
3904+
walletAddress: GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF
3905+
ip: 203.0.113.42
3906+
correlationId: abc-123
3907+
rateLimitContext: null
3908+
createdAt: '2026-07-01T12:00:00.000Z'
3909+
nextCursor: null
3910+
emptyPage:
3911+
summary: No logs for this address
3912+
value:
3913+
data: []
3914+
nextCursor: null
3915+
'400':
3916+
description: Invalid wallet address format
3917+
content:
3918+
application/json:
3919+
schema:
3920+
$ref: '#/components/schemas/ErrorBody'
3921+
'401':
3922+
description: Missing or invalid Bearer token
3923+
content:
3924+
application/json:
3925+
schema:
3926+
$ref: '#/components/schemas/ErrorBody'
3927+
'403':
3928+
description: Authenticated user is not authorised to view this address
3929+
content:
3930+
application/json:
3931+
schema:
3932+
$ref: '#/components/schemas/ErrorBody'
3933+
'422':
3934+
description: Invalid query parameters
3935+
content:
3936+
application/json:
3937+
schema:
3938+
$ref: '#/components/schemas/ErrorBody'
3939+
'429':
3940+
description: Rate limit exceeded
3941+
content:
3942+
application/json:
3943+
schema:
3944+
$ref: '#/components/schemas/ErrorBody'
37823945
/api/admin/plugins:
37833946
get:
37843947
operationId: listAdminPlugins

0 commit comments

Comments
 (0)