diff --git a/PR_DESCRIPTION_325.md b/PR_DESCRIPTION_325.md new file mode 100644 index 00000000..99cfad13 --- /dev/null +++ b/PR_DESCRIPTION_325.md @@ -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 +``` + +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 diff --git a/docs/user-audit-api.md b/docs/user-audit-api.md new file mode 100644 index 00000000..00421445 --- /dev/null +++ b/docs/user-audit-api.md @@ -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 ` 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}`) | diff --git a/openapi.yaml b/openapi.yaml index 45cecd0d..41f84e7f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: @@ -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 diff --git a/src/__tests__/routes/auditUser.test.ts b/src/__tests__/routes/auditUser.test.ts new file mode 100644 index 00000000..4e8956d7 --- /dev/null +++ b/src/__tests__/routes/auditUser.test.ts @@ -0,0 +1,385 @@ +/** + * Tests for GET /api/audit/user/:addr + * + * All DB and logger dependencies are mocked so no real Postgres connection + * is required. Auth is tested via a mock of requireAuth. + */ + +import express from "express"; +import request from "supertest"; +import { createUserAuditRouter } from "../../routes/audit/user"; +import { errorHandler } from "../../middleware/errorHandler"; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +jest.mock("../../config/logger", () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +// Mock the repo — each test can override the resolved value. +jest.mock("../../repositories/auditLogRepo"); +import { getAuditLogsByUser } from "../../repositories/auditLogRepo"; +const mockGetAuditLogsByUser = getAuditLogsByUser as jest.MockedFunction< + typeof getAuditLogsByUser +>; + +// Mock requireAuth so we can inject any user shape without real JWTs. +jest.mock("../../middleware/requireAuth"); +import { requireAuth } from "../../middleware/requireAuth"; +const mockRequireAuth = requireAuth as jest.MockedFunction; + +// Mock correlation middleware — keeps correlationId stable in tests. +jest.mock("../../middleware/correlation", () => ({ + ...jest.requireActual("../../middleware/correlation"), + getCorrelationId: () => "test-correlation-id", + correlationMiddleware: ( + _req: express.Request, + _res: express.Response, + next: express.NextFunction, + ) => next(), +})); + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +// A syntactically valid Stellar public key (G + 55 uppercase base-32 chars). +const OWNER_ADDR = "GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF"; +const OTHER_ADDR = "GBSM6GFMXVHPMPQCFXZXZXYKNB7VZAQM7GM5XJPQEXHVZXL7ZXVB7AAA"; + +const SAMPLE_LOG_ITEM = { + id: "11111111-1111-1111-1111-111111111111", + action: "auth.login", + walletAddress: OWNER_ADDR, + ip: "127.0.0.1", + correlationId: "test-correlation-id", + rateLimitContext: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), +}; + +const EMPTY_PAGE = { data: [], nextCursor: null }; +const ONE_ITEM_PAGE = { data: [SAMPLE_LOG_ITEM], nextCursor: null }; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** + * Builds a minimal Express app with the user audit router and error handler. + * `user` is injected into req.user by the requireAuth mock. + */ +function buildApp(user: { + id: string; + stellarAddress: string; + role?: string; +}) { + // requireAuth mock: just populate req.user and call next() + mockRequireAuth.mockImplementation((req, _res, next) => { + (req as express.Request & { user: unknown }).user = user; + next(); + }); + + const app = express(); + app.use("/api/audit/user", createUserAuditRouter({ rateLimitPerMinute: 100 })); + app.use(errorHandler); + return app; +} + +// ── Test suites ──────────────────────────────────────────────────────────────── + +describe("GET /api/audit/user/:addr", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default: return an empty page + mockGetAuditLogsByUser.mockResolvedValue(EMPTY_PAGE); + }); + + // ── Happy path ─────────────────────────────────────────────────────────────── + + describe("happy path", () => { + it("returns 200 with an empty page when no logs exist", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get(`/api/audit/user/${OWNER_ADDR}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ data: [], nextCursor: null }); + }); + + it("returns log entries belonging to the user", async () => { + mockGetAuditLogsByUser.mockResolvedValue(ONE_ITEM_PAGE); + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get(`/api/audit/user/${OWNER_ADDR}`); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].action).toBe("auth.login"); + }); + + it("forwards limit, cursor, action, startDate, endDate to the repo", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + await request(app).get( + `/api/audit/user/${OWNER_ADDR}?limit=5&action=auth.login&startDate=2026-01-01T00:00:00.000Z&endDate=2026-12-31T23:59:59.000Z`, + ); + + expect(mockGetAuditLogsByUser).toHaveBeenCalledWith( + OWNER_ADDR, + expect.objectContaining({ + limit: 5, + action: "auth.login", + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-12-31T23:59:59.000Z"), + }), + ); + }); + + it("returns the nextCursor from the repo in the response body", async () => { + mockGetAuditLogsByUser.mockResolvedValue({ + data: [SAMPLE_LOG_ITEM], + nextCursor: "opaque-cursor-xyz", + }); + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get(`/api/audit/user/${OWNER_ADDR}`); + + expect(res.body.nextCursor).toBe("opaque-cursor-xyz"); + }); + + it("forwards a cursor parameter to the repo", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + await request(app).get( + `/api/audit/user/${OWNER_ADDR}?cursor=some-opaque-cursor`, + ); + + expect(mockGetAuditLogsByUser).toHaveBeenCalledWith( + OWNER_ADDR, + expect.objectContaining({ cursor: "some-opaque-cursor" }), + ); + }); + }); + + // ── Address validation ──────────────────────────────────────────────────────── + + describe("address validation", () => { + it("returns 400 for an address that doesn't start with G", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get( + "/api/audit/user/XAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF", + ); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it("returns 400 for an address that is too short", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get("/api/audit/user/GSHORT"); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it("returns 400 for an address containing lowercase letters", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const lowercase = OWNER_ADDR.toLowerCase(); + const res = await request(app).get(`/api/audit/user/${lowercase}`); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it("returns 400 for an address containing special characters", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get( + "/api/audit/user/G!HK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF", + ); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it("accepts a valid 56-character Stellar address", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get(`/api/audit/user/${OWNER_ADDR}`); + + // Passes address validation (auth already passed too) → 200 + expect(res.status).toBe(200); + }); + }); + + // ── Query parameter validation ──────────────────────────────────────────────── + + describe("query parameter validation", () => { + it("returns 422 for a non-numeric limit", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get( + `/api/audit/user/${OWNER_ADDR}?limit=abc`, + ); + + expect(res.status).toBe(422); + }); + + it("returns 422 for an invalid startDate", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get( + `/api/audit/user/${OWNER_ADDR}?startDate=not-a-date`, + ); + + expect(res.status).toBe(422); + }); + + it("returns 422 for an invalid endDate", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get( + `/api/audit/user/${OWNER_ADDR}?endDate=not-a-date`, + ); + + expect(res.status).toBe(422); + }); + + it("returns 422 for unknown query parameters (strict schema)", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get( + `/api/audit/user/${OWNER_ADDR}?unknownParam=value`, + ); + + expect(res.status).toBe(422); + }); + + it("accepts limit=1", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get( + `/api/audit/user/${OWNER_ADDR}?limit=1`, + ); + + expect(res.status).toBe(200); + }); + + it("accepts limit=100 (max)", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get( + `/api/audit/user/${OWNER_ADDR}?limit=100`, + ); + + expect(res.status).toBe(200); + }); + }); + + // ── Authorisation ───────────────────────────────────────────────────────────── + + describe("authorisation", () => { + it("returns 403 when a non-admin requests another user's address", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get(`/api/audit/user/${OTHER_ADDR}`); + + expect(res.status).toBe(403); + expect(res.body.error).toBeDefined(); + expect(mockGetAuditLogsByUser).not.toHaveBeenCalled(); + }); + + it("returns 200 when a user requests their own address", async () => { + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get(`/api/audit/user/${OWNER_ADDR}`); + + expect(res.status).toBe(200); + }); + + it("returns 200 when an admin requests another user's address", async () => { + const app = buildApp({ + id: "admin1", + stellarAddress: OWNER_ADDR, + role: "admin", + }); + const res = await request(app).get(`/api/audit/user/${OTHER_ADDR}`); + + expect(res.status).toBe(200); + expect(mockGetAuditLogsByUser).toHaveBeenCalledWith( + OTHER_ADDR, + expect.any(Object), + ); + }); + + it("returns 401 when requireAuth rejects the request", async () => { + mockRequireAuth.mockImplementation((_req, res, _next) => { + res.status(401).json({ error: { code: "unauthenticated" } }); + }); + + const app = express(); + app.use( + "/api/audit/user", + createUserAuditRouter({ rateLimitPerMinute: 100 }), + ); + app.use(errorHandler); + + const res = await request(app).get(`/api/audit/user/${OWNER_ADDR}`); + + expect(res.status).toBe(401); + expect(mockGetAuditLogsByUser).not.toHaveBeenCalled(); + }); + }); + + // ── Error handling ──────────────────────────────────────────────────────────── + + describe("error handling", () => { + it("returns 500 when the repository throws an unexpected error", async () => { + mockGetAuditLogsByUser.mockRejectedValue(new Error("DB connection lost")); + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get(`/api/audit/user/${OWNER_ADDR}`); + + expect(res.status).toBe(500); + }); + + it("includes an error body for 500 responses", async () => { + mockGetAuditLogsByUser.mockRejectedValue(new Error("DB connection lost")); + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + const res = await request(app).get(`/api/audit/user/${OWNER_ADDR}`); + + expect(res.body.error).toBeDefined(); + }); + }); + + // ── Structured logging ──────────────────────────────────────────────────────── + + describe("structured logging", () => { + it("logs a user_audit_fetch event on a successful request", async () => { + const { logger } = jest.requireMock("../../config/logger") as { + logger: { info: jest.Mock }; + }; + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + await request(app).get(`/api/audit/user/${OWNER_ADDR}`); + + const logCall = logger.info.mock.calls.find( + (args: unknown[]) => + typeof args[1] === "string" && args[1] === "user_audit_fetch", + ); + expect(logCall).toBeDefined(); + }); + + it("includes correlationId in the fetch log", async () => { + const { logger } = jest.requireMock("../../config/logger") as { + logger: { info: jest.Mock }; + }; + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + await request(app).get(`/api/audit/user/${OWNER_ADDR}`); + + const logCall = logger.info.mock.calls.find( + (args: unknown[]) => + typeof args[1] === "string" && args[1] === "user_audit_fetch", + ); + expect(logCall?.[0]).toHaveProperty("correlationId"); + }); + + it("logs a user_audit_forbidden warning when a non-admin requests another address", async () => { + const { logger } = jest.requireMock("../../config/logger") as { + logger: { warn: jest.Mock }; + }; + const app = buildApp({ id: "u1", stellarAddress: OWNER_ADDR }); + await request(app).get(`/api/audit/user/${OTHER_ADDR}`); + + const warnCall = logger.warn.mock.calls.find( + (args: unknown[]) => + typeof args[1] === "string" && args[1] === "user_audit_forbidden", + ); + expect(warnCall).toBeDefined(); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index e6b87982..d76634c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ import { adminAuditRouter } from "./routes/admin/audit"; import { adminAuditExportRouter } from "./routes/admin/audit/export"; import { auditCountsRouter } from "./routes/audit/counts"; import { auditHealthRouter } from "./routes/audit/health"; +import { userAuditRouter } from "./routes/audit/user"; import { adminHealthRouter } from "./routes/admin/health"; import { adminMarketsRouter } from "./routes/admin/markets"; import { adminSchemaVersionsRouter } from "./routes/admin/schema-versions"; @@ -203,6 +204,7 @@ export function createApp(): express.Express { app.use("/api/admin/audit", adminAuditExportRouter); app.use("/api/audit/health", auditHealthRouter); app.use("/api/audit/counts", auditCountsRouter); + app.use("/api/audit/user", userAuditRouter); app.use("/api/admin/health", adminHealthRouter); app.use("/api/admin/users", adminUsersRouter); app.use("/api/admin/users", adminNotesRouter); diff --git a/src/repositories/auditLogRepo.ts b/src/repositories/auditLogRepo.ts index 30c836af..5fb7a838 100644 --- a/src/repositories/auditLogRepo.ts +++ b/src/repositories/auditLogRepo.ts @@ -122,6 +122,63 @@ export async function* getAuditLogsStream( } } +/** + * Retrieve a paginated list of audit logs for a specific wallet address. + * Uses the same keyset pagination as `getAuditLogs`, but always pins the + * `walletAddress` filter so callers cannot omit it. + */ +export async function getAuditLogsByUser( + walletAddress: string, + filters: Omit, +): Promise> { + const take = clampLimit(filters.limit); + const key = decodeCursor(filters.cursor); + + const cursorPredicate = key + ? or( + lt(auditLogs.createdAt, new Date(key.sortValue)), + and( + eq(auditLogs.createdAt, new Date(key.sortValue)), + lt(auditLogs.id, key.id), + ), + ) + : undefined; + + const conditions = [eq(auditLogs.walletAddress, walletAddress)]; + + if (filters.action) { + conditions.push(eq(auditLogs.action, filters.action)); + } + if (filters.startDate) { + conditions.push(gte(auditLogs.createdAt, filters.startDate)); + } + if (filters.endDate) { + conditions.push(lte(auditLogs.createdAt, filters.endDate)); + } + if (cursorPredicate) { + conditions.push(cursorPredicate); + } + + const rows = await db + .select() + .from(auditLogs) + .where(and(...conditions)) + .orderBy(desc(auditLogs.createdAt), desc(auditLogs.id)) + .limit(take + 1); + + const hasMore = rows.length > take; + const data = hasMore ? rows.slice(0, take) : rows; + const last = data[data.length - 1]; + + return { + data: data as AuditLogItem[], + nextCursor: + hasMore && last + ? encodeCursor({ sortValue: last.createdAt.toISOString(), id: last.id }) + : null, + }; +} + export interface AuditActionCount { action: string; count: number; diff --git a/src/routes/audit/user.ts b/src/routes/audit/user.ts new file mode 100644 index 00000000..0c2fe276 --- /dev/null +++ b/src/routes/audit/user.ts @@ -0,0 +1,265 @@ +/** + * @module routes/audit/user + * + * GET /api/audit/user/:addr + * + * Returns paginated audit log entries for a single wallet address. + * Only the authenticated user may query their own address, or an admin + * may query any address. + * + * Security + * -------- + * - Requires a valid Bearer JWT (`requireAuth`). + * - Non-admin callers receive 403 if `:addr` does not match their own + * `req.user.stellarAddress`. + * - `:addr` is validated against the Stellar public-key format + * (G + 55 base-32 uppercase characters) before any DB access. + * + * Pagination + * ---------- + * Uses the same opaque keyset cursor as the admin audit endpoint + * (`(created_at DESC, id DESC)`). See docs/audit-log-pagination.md. + * + * Rate limiting + * ------------- + * 60 requests / minute per JWT (falls back to IP when the header is absent). + */ + +import { Router } from "express"; +import { rateLimit } from "express-rate-limit"; +import { z } from "zod"; +import { requireAuth } from "../../middleware/requireAuth"; +import { getAuditLogsByUser } from "../../repositories/auditLogRepo"; +import { getCorrelationId } from "../../middleware/correlation"; +import { logger } from "../../config/logger"; +import { RouteErrorFactory } from "../../errors"; + +// ── Stellar public-key pattern: G followed by exactly 55 base-32 uppercase chars ── +const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; + +/** Zod schema for the `:addr` path parameter. */ +const addrParamSchema = z.object({ + addr: z + .string() + .regex(STELLAR_ADDRESS_RE, { + message: "addr must be a valid Stellar public key (G + 55 base-32 characters)", + }), +}); + +/** Zod schema for query-string parameters. */ +const userAuditQuerySchema = z + .object({ + cursor: z.string().optional(), + limit: z + .string() + .regex(/^\d+$/, { message: "limit must be a positive integer" }) + .transform((val) => parseInt(val, 10)) + .optional(), + action: z.string().optional(), + startDate: z + .string() + .datetime({ message: "startDate must be a valid ISO 8601 datetime string" }) + .transform((val) => new Date(val)) + .optional(), + endDate: z + .string() + .datetime({ message: "endDate must be a valid ISO 8601 datetime string" }) + .transform((val) => new Date(val)) + .optional(), + }) + .strict(); + +export interface UserAuditRouterOptions { + /** Requests per minute per token/IP. Defaults to 60. */ + rateLimitPerMinute?: number; +} + +/** + * Factory — creates the user audit router with injectable options. + * The factory pattern mirrors the rest of the audit sub-routes and + * allows tests to configure rate-limit overrides without touching globals. + */ +export function createUserAuditRouter( + opts: UserAuditRouterOptions = {}, +): Router { + const router = Router({ mergeParams: true }); + + // ── Rate limiting ────────────────────────────────────────────────────────── + router.use( + rateLimit({ + windowMs: 60_000, + limit: opts.rateLimitPerMinute ?? 60, + keyGenerator: (req) => + (req.headers.authorization as string | undefined) ?? req.ip ?? "unknown", + standardHeaders: "draft-6", + legacyHeaders: false, + message: { error: { code: "rate_limit_exceeded" } }, + }), + ); + + // ── Authentication ───────────────────────────────────────────────────────── + router.use(requireAuth); + + /** + * @openapi + * /api/audit/user/{addr}: + * get: + * summary: Per-user audit history + * description: > + * Returns paginated audit log entries for the given Stellar wallet + * address. Authenticated users may only query their own address; + * admins (role=admin) may query any address. + * operationId: getUserAuditHistory + * tags: + * - Audit + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: addr + * required: true + * schema: + * type: string + * description: Stellar public key (G + 55 base-32 uppercase chars) + * - in: query + * name: cursor + * schema: + * type: string + * description: Opaque pagination cursor from a previous response + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 20 + * description: Number of records per page (1–100, default 20) + * - in: query + * name: action + * schema: + * type: string + * description: Filter by exact action string (e.g. "auth.login") + * - in: query + * name: startDate + * schema: + * type: string + * format: date-time + * description: Inclusive lower-bound on createdAt (ISO 8601) + * - in: query + * name: endDate + * 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' + * 400: + * description: Invalid path or query parameter + * 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: Unprocessable query parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorBody' + * 429: + * description: Rate limit exceeded + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorBody' + */ + router.get("/:addr", async (req, res, next) => { + const correlationId = getCorrelationId() ?? "unknown"; + + try { + // ── Validate path parameter ────────────────────────────────────────── + const paramResult = addrParamSchema.safeParse(req.params); + if (!paramResult.success) { + throw RouteErrorFactory.badRequest( + paramResult.error.issues[0]?.message ?? + "Invalid wallet address format", + ); + } + const { addr } = paramResult.data; + + // ── Authorisation: user may only read their own history ────────────── + // req.user is guaranteed by requireAuth above. + const caller = req.user!; + const isAdmin = + (caller as unknown as { role?: string }).role === "admin"; + + if (!isAdmin && caller.stellarAddress !== addr) { + logger.warn( + { + correlationId, + callerAddress: caller.stellarAddress, + requestedAddress: addr, + }, + "user_audit_forbidden", + ); + throw RouteErrorFactory.forbidden( + "You are not authorised to view audit logs for this address", + ); + } + + // ── Validate query string ──────────────────────────────────────────── + const queryResult = userAuditQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw RouteErrorFactory.validation( + queryResult.error.issues[0]?.message ?? + "Invalid query parameters", + ); + } + const filters = queryResult.data; + + logger.info( + { + correlationId, + addr, + filters: { + action: filters.action, + startDate: filters.startDate, + endDate: filters.endDate, + limit: filters.limit, + hasCursor: Boolean(filters.cursor), + }, + callerAddress: caller.stellarAddress, + }, + "user_audit_fetch", + ); + + // ── Query ──────────────────────────────────────────────────────────── + const page = await getAuditLogsByUser(addr, filters); + + res.json({ data: page.data, nextCursor: page.nextCursor }); + } catch (err) { + next(err); + } + }); + + return router; +} + +/** Production router instance wired into src/index.ts. */ +export const userAuditRouter = createUserAuditRouter();