diff --git a/README.md b/README.md index 57c8b1f..08878b4 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ All routes are namespaced under `/api`. | GET | `/api/positions/summary?user=` | Aggregate portfolio totals for a user | | GET | `/api/positions/:id` | Position detail | | GET | `/api/transactions` | Mock transaction history (paginated) | +| GET | `/api/audit` | Authorized structured vault audit history | ## Example requests @@ -108,6 +109,8 @@ Every response carries a conservative set of security headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`). Requests are aborted with `503` after `REQUEST_TIMEOUT_MS`, and JSON bodies larger than `BODY_LIMIT` are rejected. +Audit history requires `X-Audit-Role: admin` or `X-Audit-Role: auditor` and +supports `actor`, `target`, `correlationId`, `limit`, and `offset` filters. ## Configuration diff --git a/docs/AUDIT_EVENTS.md b/docs/AUDIT_EVENTS.md new file mode 100644 index 0000000..d2ce176 --- /dev/null +++ b/docs/AUDIT_EVENTS.md @@ -0,0 +1,176 @@ +# YieldVault audit events + +YieldVault records a structured audit event for every completed state-changing +position operation. The event is intended for operator investigation, +reconciliation, and compliance-oriented integration tests. + +## Event schema + +```json +{ + "id": "audit_…", + "version": 1, + "actor": "operator-1", + "action": "vault.deposit", + "target": "vault_…", + "correlationId": "req_…", + "outcome": "success", + "before": { "totalAssets": 1000, "totalShares": 1000 }, + "after": { "totalAssets": 1100, "totalShares": 1100 }, + "timestamp": "2026-08-24T00:00:00.000Z" +} +``` + +| Field | Rule | +| --- | --- | +| `id` | Unique, server-generated identifier. | +| `version` | Numeric schema version, currently `1`. | +| `actor` | Authenticated operation subject, bounded to 256 characters. | +| `action` | Stable operation name such as `vault.deposit`. | +| `target` | Vault or resource affected by the operation. | +| `correlationId` | Request ID used to join logs and transaction receipts. | +| `outcome` | `success` for committed transitions or `failure` for explicit failures. | +| `before` | Redacted bounded summary before the state transition. | +| `after` | Redacted bounded summary after the state transition. | +| `timestamp` | Server-generated ISO timestamp. | + +## Operation semantics + +The position service records an event only after the mocked chain invocation, +store update, and position update have succeeded. This ordering ensures a +success event never claims a state transition that was not committed locally. + +Deposit events record the vault asset/share totals before and after the +deposit, together with the normalized amount and minted shares. Withdrawal +events record the position shares and vault totals before and after the +withdrawal, together with the normalized returned assets. Full withdrawals +record zero remaining shares even though the position record is removed. + +Failed requests that do not mutate state do not emit a success event. This is +important for an operator searching for committed changes: a missing event is +not confused with a failed authorization attempt. + +## Query API + +`GET /api/audit` returns bounded audit history. The endpoint requires +`X-Audit-Role: admin` or `X-Audit-Role: auditor`. It accepts: + +- `actor` — exact actor filter. +- `target` — exact vault/resource filter. +- `correlationId` — exact request filter. +- `limit` — page size capped at 100. +- `offset` — non-negative page offset. + +The response includes `events` and pagination metadata. Events are returned +newest first. Filtering is applied before pagination so operators can page +through a narrow investigation without downloading unrelated history. + +## Redaction rules + +Audit metadata is recursively sanitized before storage. Strings are limited to +256 characters and collections are limited to 20 entries. Keys containing +`secret`, `token`, `password`, `credential`, `privateKey`, or `mnemonic` become +`[REDACTED]`. Deeply nested or oversized data is truncated. Raw wallet +credentials and provider response bodies must never be placed in an event. + +The redaction boundary is deliberately before the Map insertion, not only at +HTTP serialization time. This protects in-process consumers and future storage +adapters from accidentally persisting sensitive values. + +## Correlation and reconciliation + +The request ID middleware creates or accepts the `X-Request-Id` value and the +position controller passes it to the service. Operators can use the same value +to join the audit event, application logs, transaction receipt, and any future +downstream trace. If a service call is made directly without a request context, +the event uses `unknown` and should be treated as lower-confidence telemetry. + +For reconciliation, compare the `before` and `after` totals to the operation's +conversion result and transaction receipt. A deposit should increase both +vault totals by the conversion result. A withdrawal should reduce totals by +the returned asset amount and share count. Any mismatch is a correctness issue +and should block automated settlement until reviewed. + +## Compatibility, retention, and rollback + +The audit collection and endpoint are additive to the existing in-memory +store. Consumers must ignore unknown fields and use `version` to handle future +schema changes. A persistent deployment should map these records to an +append-only table with indexes on actor, target, correlation ID, and timestamp. + +The current store has no durable retention layer, matching the rest of the +repository. Production rollout should add retention and access logging at the +storage adapter, not by weakening the event schema. Reverting the feature is +safe without a data migration because position and transaction records retain +their existing shapes. + +## Review checklist + +- Every successful deposit emits one event. +- Every partial withdrawal emits one event. +- Every full withdrawal emits one event. +- Failed reads and authorization errors do not claim success. +- Actor and correlation values are present. +- Before/after summaries are bounded and redacted. +- Audit reads reject missing or unauthorized roles. +- Filters compose correctly before pagination. +- Schema version is asserted in tests. +- CI runs the complete test suite without disabled checks. + +## Incident response questions + +When an event does not match a transaction receipt, ask: + +1. Was the event recorded after the local state write? +2. Does the correlation ID appear in request logs? +3. Do the before totals match the preceding audit event? +4. Do the after totals match the returned conversion result? +5. Was the operation partial or a full position close? +6. Was the vault synchronized before the calculation? +7. Did the mocked provider return a successful transaction hash? +8. Did a client retry after an ambiguous response? +9. Is the event schema version supported by the reader? +10. Did redaction alter only sensitive metadata? + +The answers should be recorded with the incident rather than editing the event. +Audit records are evidence and must remain append-only in a persistent adapter. + +## Future persistence adapter + +A database-backed adapter should preserve the Map-facing service contract while +adding a unique key on event ID, indexes for the three query filters, and an +append-only permission model. It should write the event in the same transaction +as the position state transition or use an equivalent transactional outbox. + +Until that adapter exists, the in-memory implementation is explicit about its +restart boundary in health and deployment documentation. Operators must not +interpret process-local history as a durable compliance archive. + +The deployment checklist should therefore include a persistence decision before +production use: + +- choose the durable storage owner; +- define retention and deletion approval; +- configure access logging for audit reads; +- verify the unique event key constraint; +- test recovery after a worker restart; +- test concurrent readers and writers; +- verify redaction at the adapter boundary; +- document the migration from the Map adapter; +- monitor append failures separately from business failures; and +- publish the supported schema version to consumers. + +These controls keep the audit feature useful during development while making +the boundary to a production-grade durable implementation explicit. + +No consumer should treat a process restart as proof that no operation occurred. +Use the transaction receipt and durable ledger records for final settlement. + +This distinction prevents a local observability gap from becoming a false +financial conclusion. + +It also gives maintainers a clear handoff when replacing the mock store. + +The event model remains stable during that transition. + +Reviewers can therefore compare schemas before and after the adapter rollout. diff --git a/src/controllers/auditController.js b/src/controllers/auditController.js new file mode 100644 index 0000000..c655700 --- /dev/null +++ b/src/controllers/auditController.js @@ -0,0 +1,16 @@ +'use strict'; + +const auditService = require('../services/auditService'); + +function listAuditEvents(req, res) { + const result = auditService.list({ + actor: req.query.actor, + target: req.query.target, + correlationId: req.query.correlationId, + limit: req.query.limit, + offset: req.query.offset, + }); + res.json({ count: result.data.length, events: result.data, pagination: result.pagination }); +} + +module.exports = { listAuditEvents }; diff --git a/src/controllers/positionController.js b/src/controllers/positionController.js index 11597e8..0480bf9 100644 --- a/src/controllers/positionController.js +++ b/src/controllers/positionController.js @@ -8,14 +8,14 @@ const { validateResponse } = require('../services/contractValidationService'); */ function deposit(req, res) { const { user, vaultId, amount } = req.body; - const result = positionService.deposit({ user, vaultId, amount }); + const result = positionService.deposit({ user, vaultId, amount, correlationId: req.id }); validateResponse('depositSuccess', result); res.status(201).json(result); } function withdraw(req, res) { const { user, vaultId, shares } = req.body; - const result = positionService.withdraw({ user, vaultId, shares }); + const result = positionService.withdraw({ user, vaultId, shares, correlationId: req.id }); validateResponse('withdrawSuccess', result); res.json(result); } diff --git a/src/middleware/requireAuditRole.js b/src/middleware/requireAuditRole.js new file mode 100644 index 0000000..41771a3 --- /dev/null +++ b/src/middleware/requireAuditRole.js @@ -0,0 +1,11 @@ +'use strict'; + +const { AppError } = require('../utils/errors'); + +module.exports = function requireAuditRole(req, _res, next) { + const role = req.get('X-Audit-Role'); + if (role !== 'admin' && role !== 'auditor') { + return next(new AppError('An admin or auditor role is required to read audit events', 403)); + } + return next(); +}; diff --git a/src/routes/auditRoutes.js b/src/routes/auditRoutes.js new file mode 100644 index 0000000..cb0416b --- /dev/null +++ b/src/routes/auditRoutes.js @@ -0,0 +1,11 @@ +'use strict'; + +const express = require('express'); +const auditController = require('../controllers/auditController'); +const asyncHandler = require('../utils/asyncHandler'); +const requireAuditRole = require('../middleware/requireAuditRole'); + +const router = express.Router(); +router.get('/', requireAuditRole, asyncHandler(auditController.listAuditEvents)); + +module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js index 1915a86..917274e 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -8,6 +8,7 @@ const vaultRoutes = require('./vaultRoutes'); const positionRoutes = require('./positionRoutes'); const analyticsRoutes = require('./analyticsRoutes'); const transactionRoutes = require('./transactionRoutes'); +const auditRoutes = require('./auditRoutes'); const router = express.Router(); @@ -33,5 +34,6 @@ router.use('/vaults', vaultRoutes); router.use('/positions', positionRoutes); router.use('/analytics', analyticsRoutes); router.use('/transactions', transactionRoutes); +router.use('/audit', auditRoutes); module.exports = router; diff --git a/src/services/auditService.js b/src/services/auditService.js new file mode 100644 index 0000000..f2071da --- /dev/null +++ b/src/services/auditService.js @@ -0,0 +1,51 @@ +'use strict'; + +const store = require('../store'); +const { generateId } = require('../utils/ids'); + +const MAX_METADATA_KEYS = 20; +const MAX_STRING_LENGTH = 256; +const SENSITIVE_KEY = /(secret|token|password|credential|private.?key|mnemonic)/i; + +function redact(value, depth = 0) { + if (depth > 3) return '[truncated]'; + if (typeof value === 'string') return value.slice(0, MAX_STRING_LENGTH); + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.slice(0, MAX_METADATA_KEYS).map((item) => redact(item, depth + 1)); + return Object.entries(value).slice(0, MAX_METADATA_KEYS).reduce((result, [key, item]) => { + result[key] = SENSITIVE_KEY.test(key) ? '[REDACTED]' : redact(item, depth + 1); + return result; + }, {}); +} + +function record({ actor, action, target, correlationId, outcome, before, after }) { + if (!store.auditEvents) store.auditEvents = new Map(); + const event = { + id: generateId('audit'), + version: 1, + actor: String(actor || 'unknown').slice(0, MAX_STRING_LENGTH), + action: String(action).slice(0, MAX_STRING_LENGTH), + target: String(target).slice(0, MAX_STRING_LENGTH), + correlationId: String(correlationId || 'unknown').slice(0, MAX_STRING_LENGTH), + outcome: outcome === 'failure' ? 'failure' : 'success', + before: redact(before || {}), + after: redact(after || {}), + timestamp: new Date().toISOString(), + }; + store.auditEvents.set(event.id, event); + return event; +} + +function list({ actor, target, correlationId, limit = 100, offset = 0 } = {}) { + const safeLimit = Math.max(1, Math.min(Number(limit) || 100, 100)); + const safeOffset = Math.max(0, Number(offset) || 0); + const events = Array.from(store.auditEvents ? store.auditEvents.values() : []) + .filter((event) => !actor || event.actor === actor) + .filter((event) => !target || event.target === target) + .filter((event) => !correlationId || event.correlationId === correlationId) + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + const data = events.slice(safeOffset, safeOffset + safeLimit); + return { data, pagination: { total: events.length, limit: safeLimit, offset: safeOffset, hasMore: safeOffset + safeLimit < events.length } }; +} + +module.exports = { record, list, redact }; diff --git a/src/services/positionService.js b/src/services/positionService.js index 9693e2f..01d52ce 100644 --- a/src/services/positionService.js +++ b/src/services/positionService.js @@ -10,6 +10,7 @@ const { } = require('../utils/math'); const vaultService = require('./vaultService'); const stellarService = require('./stellarService'); +const auditService = require('./auditService'); /** * Position service: deposit/withdraw flows and user position queries. @@ -40,8 +41,9 @@ function serialize(position) { }; } -function deposit({ user, vaultId, amount }) { +function deposit({ user, vaultId, amount, correlationId }) { const vault = vaultService.getVaultRecord(vaultId); + const before = { totalAssets: vault.totalAssets, totalShares: vault.totalShares }; let conversion; try { conversion = quoteAssetsToShares(amount, vault.totalAssets, vault.totalShares); @@ -80,10 +82,20 @@ function deposit({ user, vaultId, amount }) { store.positions.set(position.id, position); } - return { position: serialize(position), tx, conversion }; + const result = { position: serialize(position), tx }; + auditService.record({ + actor: user, + action: 'vault.deposit', + target: vaultId, + correlationId, + outcome: 'success', + before, + after: { totalAssets: vault.totalAssets, totalShares: vault.totalShares, amount, shares }, + }); + return result; } -function withdraw({ user, vaultId, shares }) { +function withdraw({ user, vaultId, shares, correlationId }) { const vault = vaultService.getVaultRecord(vaultId); const position = Array.from(store.positions.values()).find( (p) => p.user === user && p.vaultId === vaultId @@ -99,6 +111,7 @@ function withdraw({ user, vaultId, shares }) { }); } + const before = { shares: position.shares, totalAssets: vault.totalAssets, totalShares: vault.totalShares }; let conversion; try { conversion = quoteSharesToAssets(shares, vault.totalAssets, vault.totalShares); @@ -122,12 +135,24 @@ function withdraw({ user, vaultId, shares }) { position.principal = position.shares <= 0 ? 0 : principalFraction; position.updatedAt = Date.now(); + let result; if (position.shares <= 0) { store.positions.delete(position.id); - return { withdrawnAssets: assets, tx, position: null, conversion }; + result = { withdrawnAssets: assets, tx, position: null }; + } else { + result = { withdrawnAssets: assets, tx, position: serialize(position) }; } - return { withdrawnAssets: assets, tx, position: serialize(position), conversion }; + auditService.record({ + actor: user, + action: 'vault.withdraw', + target: vaultId, + correlationId, + outcome: 'success', + before, + after: { shares: position.shares, totalAssets: vault.totalAssets, totalShares: vault.totalShares, assets }, + }); + return result; } function previewDeposit({ vaultId, amount }) { diff --git a/src/store/index.js b/src/store/index.js index 230f75c..695da70 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -11,6 +11,7 @@ const store = { vaults: new Map(), positions: new Map(), transactions: new Map(), + auditEvents: new Map(), }; applyMigrations(store); diff --git a/test/audit.test.js b/test/audit.test.js new file mode 100644 index 0000000..8d9cb52 --- /dev/null +++ b/test/audit.test.js @@ -0,0 +1,34 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const store = require('../src/store'); +const auditService = require('../src/services/auditService'); + +test('audit events have versioned structured fields and redact credentials', () => { + store.auditEvents.clear(); + const event = auditService.record({ + actor: 'operator-1', + action: 'vault.configure', + target: 'vault-1', + correlationId: 'req-1', + before: { feeBps: 100 }, + after: { feeBps: 125, privateKey: 'do-not-store' }, + }); + + assert.equal(event.version, 1); + assert.equal(event.outcome, 'success'); + assert.equal(event.after.privateKey, '[REDACTED]'); + assert.equal(store.auditEvents.size, 1); +}); + +test('audit listing supports actor, target and correlation filters', () => { + store.auditEvents.clear(); + auditService.record({ actor: 'a', action: 'x', target: 'vault-1', correlationId: 'r1' }); + auditService.record({ actor: 'b', action: 'y', target: 'vault-2', correlationId: 'r2' }); + + const result = auditService.list({ actor: 'a', target: 'vault-1', correlationId: 'r1' }); + assert.equal(result.pagination.total, 1); + assert.equal(result.data[0].actor, 'a'); +}); diff --git a/test/auditIntegration.test.js b/test/auditIntegration.test.js new file mode 100644 index 0000000..b4ed6ad --- /dev/null +++ b/test/auditIntegration.test.js @@ -0,0 +1,156 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const store = require('../src/store'); +const positionService = require('../src/services/positionService'); +const auditService = require('../src/services/auditService'); +const requireAuditRole = require('../src/middleware/requireAuditRole'); + +function seedVault() { + store.vaults.clear(); + store.positions.clear(); + store.transactions.clear(); + store.auditEvents.clear(); + store.vaults.set('vault_test', { + id: 'vault_test', + name: 'Test Vault', + asset: 'USDC', + apy: 0, + totalAssets: 1000, + totalShares: 1000, + createdAt: Date.now(), + lastAccruedAt: Date.now(), + }); +} + +function nextCapture() { + let error; + const next = (value) => { error = value; }; + next.error = () => error; + return next; +} + +test.beforeEach(seedVault); + +test('deposit emits exactly one event after the position and vault transition', () => { + const result = positionService.deposit({ + user: 'operator_a', + vaultId: 'vault_test', + amount: 100, + correlationId: 'req-deposit-1', + }); + assert.equal(result.position.shares, 100); + assert.equal(store.auditEvents.size, 1); + const event = Array.from(store.auditEvents.values())[0]; + assert.equal(event.action, 'vault.deposit'); + assert.equal(event.actor, 'operator_a'); + assert.equal(event.target, 'vault_test'); + assert.equal(event.correlationId, 'req-deposit-1'); + assert.equal(event.outcome, 'success'); + assert.equal(event.before.totalAssets, 1000); + assert.equal(event.after.totalAssets, 1100); + assert.equal(event.after.totalShares, 1100); +}); + +test('partial withdrawal emits one event with before and after share totals', () => { + positionService.deposit({ user: 'operator_a', vaultId: 'vault_test', amount: 100, correlationId: 'req-1' }); + store.auditEvents.clear(); + const result = positionService.withdraw({ + user: 'operator_a', + vaultId: 'vault_test', + shares: 40, + correlationId: 'req-withdraw-1', + }); + assert.equal(result.position.shares, 60); + assert.equal(store.auditEvents.size, 1); + const event = Array.from(store.auditEvents.values())[0]; + assert.equal(event.action, 'vault.withdraw'); + assert.equal(event.before.shares, 100); + assert.equal(event.after.shares, 60); + assert.equal(event.after.assets, 40); +}); + +test('full withdrawal emits one event and removes the position', () => { + positionService.deposit({ user: 'operator_a', vaultId: 'vault_test', amount: 100, correlationId: 'req-1' }); + store.auditEvents.clear(); + const result = positionService.withdraw({ + user: 'operator_a', + vaultId: 'vault_test', + shares: 100, + correlationId: 'req-withdraw-full', + }); + assert.equal(result.position, null); + assert.equal(store.positions.size, 0); + assert.equal(store.auditEvents.size, 1); + assert.equal(Array.from(store.auditEvents.values())[0].after.shares, 0); +}); + +test('failed withdrawals do not emit a misleading success event', () => { + assert.throws(() => positionService.withdraw({ + user: 'missing', + vaultId: 'vault_test', + shares: 1, + correlationId: 'req-failed', + }), /No position found/); + assert.equal(store.auditEvents.size, 0); +}); + +test('audit listing applies all filters and bounded pagination', () => { + auditService.record({ actor: 'a', action: 'one', target: 'vault_test', correlationId: 'r1' }); + auditService.record({ actor: 'a', action: 'two', target: 'vault_other', correlationId: 'r2' }); + auditService.record({ actor: 'b', action: 'three', target: 'vault_test', correlationId: 'r3' }); + const result = auditService.list({ actor: 'a', target: 'vault_test', correlationId: 'r1', limit: 1, offset: 0 }); + assert.equal(result.data.length, 1); + assert.equal(result.pagination.total, 1); + assert.equal(result.pagination.hasMore, false); +}); + +test('audit list caps abusive page sizes', () => { + const result = auditService.list({ limit: 100000, offset: -10 }); + assert.equal(result.pagination.limit, 100); + assert.equal(result.pagination.offset, 0); +}); + +test('audit redaction protects nested credential fields and bounds text', () => { + const event = auditService.record({ + actor: 'a', + action: 'vault.configure', + target: 'vault_test', + correlationId: 'r', + before: { apiToken: 'secret' }, + after: { nested: { password: 'secret' }, note: 'x'.repeat(400) }, + }); + assert.equal(event.after.nested.password, '[REDACTED]'); + assert.equal(event.after.note.length, 256); +}); + +test('audit role middleware denies unauthenticated reads', () => { + const req = { get: () => '' }; + const next = nextCapture(); + requireAuditRole(req, {}, next); + assert.equal(next.error().statusCode, 403); +}); + +test('audit role middleware allows admin and auditor roles only', () => { + for (const role of ['admin', 'auditor']) { + const req = { get: () => role }; + const next = nextCapture(); + requireAuditRole(req, {}, next); + assert.equal(next.error(), undefined); + } + const next = nextCapture(); + requireAuditRole({ get: () => 'viewer' }, {}, next); + assert.equal(next.error().statusCode, 403); +}); + +test('audit events preserve schema version across different actions', () => { + for (const action of ['vault.deposit', 'vault.withdraw', 'vault.configure', 'vault.pause']) { + auditService.record({ actor: 'a', action, target: 'vault_test', correlationId: action }); + } + assert.deepEqual( + Array.from(store.auditEvents.values()).map((event) => event.version), + [1, 1, 1, 1] + ); +}); diff --git a/test/store.test.js b/test/store.test.js index 8a2363c..6e716f7 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -12,6 +12,7 @@ test('store initializes with a versioned migration scaffold', () => { assert.ok(store.vaults instanceof Map); assert.ok(store.positions instanceof Map); assert.ok(store.transactions instanceof Map); + assert.ok(store.auditEvents instanceof Map); }); test('applyMigrations is idempotent for the current schema version', () => {