Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions PR_DESCRIPTION_325.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
feat: add GET /api/audit/user/:addr per-user audit history endpoint (#325)

## Summary

Implements per-user audit history for the GrantFox FWC26 campaign.
Adds `GET /api/audit/user/:addr` which returns a paginated, cursor-based
list of audit log entries scoped to a single Stellar wallet address.

## Changes

### New files
- `src/routes/audit/user.ts` — route handler (factory pattern, matches
existing audit sub-route conventions)
- `src/__tests__/routes/auditUser.test.ts` — 22 unit tests; all DB and
auth dependencies mocked via jest
- `docs/user-audit-api.md` — API reference documentation

### Modified files
- `src/repositories/auditLogRepo.ts` — added `getAuditLogsByUser()`;
dedicated function that always pins the `walletAddress` predicate so it
cannot be accidentally omitted by callers
- `src/index.ts` — registered `userAuditRouter` at `/api/audit/user`
- `openapi.yaml` — added `AuditLogEntry` and `UserAuditPage` component
schemas; full `/api/audit/user/{addr}` path entry with response examples

## Endpoint contract

```
GET /api/audit/user/:addr
Authorization: Bearer <jwt>
```

Query params: `cursor`, `limit` (1–100, default 20), `action`, `startDate`, `endDate`

Response:
```json
{ "data": [...], "nextCursor": "eyJ..." | null }
```

Pagination uses the same `(created_at DESC, id DESC)` keyset cursor as
`GET /api/admin/audit`. See `docs/audit-log-pagination.md`.

## Security

- `requireAuth` — 401 on missing/invalid JWT
- Non-admin callers requesting a different address receive 403; the
forbidden attempt is logged at warn level with caller + requested address
- `:addr` validated against `G[A-Z2-7]{55}` before any DB access — bad
addresses never reach the query layer
- Query schema uses `.strict()` — unknown params rejected with 422
- Rate limited: 60 req/min per JWT, keyed on Authorization header
- Correlation ID propagated from ALS context to every log line

## Test coverage

22 tests across:
- Happy path (empty page, entries returned, filters forwarded, cursor
returned, cursor forwarded)
- Address validation (wrong prefix, too short, lowercase, special chars,
valid)
- Query param validation (bad limit, bad dates, unknown params, boundary
values)
- Authorisation (non-admin cross-address → 403, own address → 200,
admin cross-address → 200, unauthenticated → 401)
- Error handling (repo throws → 500, error body present)
- Structured logging (user_audit_fetch log emitted, correlationId present,
user_audit_forbidden warning emitted)

## Checklist

- [x] Implementation matches description
- [x] Input validation at the boundary; standardised error envelope
- [x] Structured logging with correlation IDs
- [x] Rate limiting applied
- [x] OpenAPI spec updated
- [x] API reference docs added
- [x] No diagnostics (tsc, language server)
- [x] Follows repo lint and code style
140 changes: 140 additions & 0 deletions docs/user-audit-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Per-User Audit History — `GET /api/audit/user/:addr`

## Overview

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.

## Authentication & Authorisation

| Caller | Allowed addresses |
|--------|------------------|
| Authenticated user (any role) | Own `stellarAddress` only |
| Admin (`role: "admin"`) | Any address |

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**.

## Request

```
GET /api/audit/user/:addr
```

### Path parameter

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `addr` | string | ✓ | Stellar public key — must match `G[A-Z2-7]{55}` |

Returns **400** if the address does not match the Stellar public-key format.

### Query parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `cursor` | string | — | Opaque pagination cursor from the previous page's `nextCursor`. Omit for the first page. |
| `limit` | integer | `20` | Records per page. Clamped to 1–100. |
| `action` | string | — | Exact-match filter on the `action` field (e.g. `"auth.login"`). |
| `startDate` | ISO 8601 | — | Inclusive lower-bound on `created_at`. |
| `endDate` | ISO 8601 | — | Inclusive upper-bound on `created_at`. |

Unknown query parameters are rejected with **422**.

## Response

### 200 OK

```json
{
"data": [
{
"id": "11111111-1111-1111-1111-111111111111",
"action": "auth.login",
"walletAddress": "GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF",
"ip": "203.0.113.42",
"correlationId": "abc-def-123",
"rateLimitContext": null,
"createdAt": "2026-07-01T12:00:00.000Z"
}
],
"nextCursor": null
}
```

Entries are ordered by `(created_at DESC, id DESC)`. The `nextCursor` field is `null` when there are no further pages.

### Error responses

| Status | `error.code` | Cause |
|--------|-------------|-------|
| 400 | `request_failed` | `:addr` is not a valid Stellar public key |
| 401 | `unauthenticated` | Missing or invalid Bearer token |
| 403 | `forbidden` | Caller is requesting another user's history without admin role |
| 422 | `validation_error` | Invalid query parameter (bad `limit`, `startDate`, etc.) |
| 429 | `rate_limit_exceeded` | More than 60 requests/minute from the same token |
| 500 | `internal_error` | Unexpected server error |

All error responses follow the standard envelope:

```json
{
"error": {
"code": "forbidden",
"message": "You are not authorised to view audit logs for this address",
"correlationId": "abc-def-123"
}
}
```

## Pagination

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.

```
GET /api/audit/user/GABC...?limit=2
→ { data: [{...}, {...}], nextCursor: "eyJ..." }

GET /api/audit/user/GABC...?limit=2&cursor=eyJ...
→ { data: [{...}], nextCursor: null }
```

See [audit-log-pagination.md](./audit-log-pagination.md) for the full pagination contract.

## Rate limiting

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" } }`.

## Structured logging

Every successful request emits a `user_audit_fetch` log line at `info` level:

```json
{
"correlationId": "...",
"addr": "GABC...",
"filters": { "action": null, "startDate": null, "endDate": null, "limit": 20, "hasCursor": false },
"callerAddress": "GABC...",
"msg": "user_audit_fetch"
}
```

Forbidden attempts (non-admin querying another address) emit a `user_audit_forbidden` warning:

```json
{
"correlationId": "...",
"callerAddress": "GABC...",
"requestedAddress": "GXYZ...",
"msg": "user_audit_forbidden"
}
```

## Relevant files

| File | Purpose |
|------|---------|
| `src/routes/audit/user.ts` | Route handler |
| `src/repositories/auditLogRepo.ts` | `getAuditLogsByUser()` — DB query |
| `src/middleware/requireAuth.ts` | JWT authentication |
| `src/utils/cursor.ts` | Cursor encode/decode |
| `src/__tests__/routes/auditUser.test.ts` | Unit tests |
| `openapi.yaml` | OpenAPI spec (`/api/audit/user/{addr}`) |
163 changes: 163 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,53 @@ components:
required:
- totalCount
- byAction
AuditLogEntry:
type: object
description: A single audit log entry for a wallet address.
properties:
id:
type: string
format: uuid
action:
type: string
description: Action identifier, e.g. "auth.login" or "market.create"
walletAddress:
type: string
nullable: true
description: Stellar wallet address of the actor
ip:
type: string
description: IP address of the originating request
correlationId:
type: string
description: Correlation ID for cross-log tracing
rateLimitContext:
nullable: true
description: Optional rate-limit snapshot at request time
createdAt:
type: string
format: date-time
required:
- id
- action
- ip
- correlationId
- createdAt
UserAuditPage:
type: object
description: A paginated page of audit log entries for a single user.
properties:
data:
type: array
items:
$ref: '#/components/schemas/AuditLogEntry'
nextCursor:
type: string
nullable: true
description: Opaque cursor for the next page, or null if this is the last page.
required:
- data
- nextCursor
PluginView:
type: object
properties:
Expand Down Expand Up @@ -3779,6 +3826,122 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
/api/audit/user/{addr}:
get:
operationId: getUserAuditHistory
tags:
- Audit
summary: Per-user audit history
description: >
Returns paginated audit log entries for the given Stellar wallet
address, ordered by `(created_at DESC, id DESC)`.

Authenticated users may only query their own address. Admins
(`role: "admin"`) may query any address.

Pagination uses the same opaque keyset cursor as the admin audit
endpoint — always use the `nextCursor` value from the previous
response; never construct a cursor manually.
security:
- bearerAuth: []
parameters:
- in: path
name: addr
required: true
schema:
type: string
pattern: '^G[A-Z2-7]{55}$'
description: Stellar public key (G + 55 base-32 uppercase characters)
- in: query
name: cursor
required: false
schema:
type: string
description: Opaque pagination cursor from a previous response
- in: query
name: limit
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: Records per page (1–100, default 20)
- in: query
name: action
required: false
schema:
type: string
description: Filter by exact action string (e.g. "auth.login")
- in: query
name: startDate
required: false
schema:
type: string
format: date-time
description: Inclusive lower-bound on createdAt (ISO 8601)
- in: query
name: endDate
required: false
schema:
type: string
format: date-time
description: Inclusive upper-bound on createdAt (ISO 8601)
responses:
'200':
description: Paginated audit log for the requested address
content:
application/json:
schema:
$ref: '#/components/schemas/UserAuditPage'
examples:
singleEntry:
summary: One log entry, no further pages
value:
data:
- id: 11111111-1111-1111-1111-111111111111
action: auth.login
walletAddress: GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF
ip: 203.0.113.42
correlationId: abc-123
rateLimitContext: null
createdAt: '2026-07-01T12:00:00.000Z'
nextCursor: null
emptyPage:
summary: No logs for this address
value:
data: []
nextCursor: null
'400':
description: Invalid wallet address format
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
'401':
description: Missing or invalid Bearer token
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
'403':
description: Authenticated user is not authorised to view this address
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
'422':
description: Invalid query parameters
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
'429':
description: Rate limit exceeded
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
/api/admin/plugins:
get:
operationId: listAdminPlugins
Expand Down
Loading