Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ PAGINATION_MAX_SCAN=10000
# or cursors minted by one will be rejected by another.
# PAGINATION_CURSOR_SECRET=

# Audit actor references (README: Audit integrity and attribution)
# Optional actor fingerprinting secret, read once when the module loads.
# Falls back to PAGINATION_CURSOR_SECRET, then a random secret per process.
# Set before startup; restart after changing it. A fixed secret stabilizes
# references across restarts/instances but does not persist or share the
# process-local audit history.
# AUDIT_ACTOR_SECRET=

# API Token authentication
# JSON map of token → array of scopes. If omitted, hardcoded demo tokens are used.
# Valid scopes: transfers:read, transfers:write, users:read, users:write, audit:read
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ When preparing a new release:

### Added

- Tamper-evident audit log: hash-chained `prevHash` / `entryHash` metadata,
`GET /api/audit/integrity` for authorized operators, and attribution fields
(`actor`, `scope`, `target`, `correlationId`, `outcome`, redacted `changes`).
Duplicate outcome events for the same privileged mutation (same action,
target, correlation id, and outcome) are suppressed. `GET /api/audit`
accepts `action`, `scope`, `outcome`, `correlationId`, and `actor` filters.
Secrets in changes are redacted at write time. Archive/unarchive now emit
outcome events. Optional `AUDIT_ACTOR_SECRET` (falls back to
`PAGINATION_CURSOR_SECRET`) fingerprints actors without storing raw tokens.

- Cursor pagination for `GET /api/transfers` and `GET /api/audit`. Pass
`?cursor=` (with optional `?order=asc|desc`) to page by an indexed position
instead of a row offset; responses carry a `pageInfo` block with
Expand Down Expand Up @@ -55,6 +65,11 @@ When preparing a new release:

### Fixed

- Privileged mutation audit actors now fingerprint raw credentials even when a
configured token starts with `actor:` or equals `system`. Transfer and user
services use the explicit `actorToken` audit input; legacy precomputed actor
references and token-scoped idempotent transfer replay remain supported.

- Offset pagination over transfer and audit history repeated or skipped rows
when records were written while a client was paging, because the window was
defined by a row count rather than a position. Cursor pagination anchors to
Expand Down
72 changes: 71 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ The application is configured using environment variables (typically defined in
| `PAGINATION_MAX_LIMIT` | Largest accepted `limit`; bigger requests are rejected | `200` |
| `PAGINATION_MAX_SCAN` | Max records a single history query may examine | `10000` |
| `PAGINATION_CURSOR_SECRET` | HMAC key used to sign pagination cursors | *(random per process)* |
| `AUDIT_ACTOR_SECRET` | Secret used to fingerprint audit actors; read once at startup | `PAGINATION_CURSOR_SECRET`, then random per process |
| `API_TOKENS` | JSON object mapping API tokens to their allowed scopes (see [Authentication](#authentication)) | *(demo tokens)* |


Expand All @@ -69,7 +70,7 @@ Authorization: Bearer <token>
| `transfers:write` | `POST /api/transfers`, `POST /api/transfers/:id/claim`, `POST /api/transfers/:id/cancel`, `POST /api/transfers/:id/archive`, `POST /api/transfers/:id/unarchive` |
| `users:read` | `GET /api/users`, `GET /api/users/:id` |
| `users:write` | `POST /api/users` |
| `audit:read` | `GET /api/audit` |
| `audit:read` | `GET /api/audit`, `GET /api/audit/integrity` |

### Public endpoints (no token required)

Expand Down Expand Up @@ -294,6 +295,75 @@ exists.
- `GET /api/users/:id` — fetch one user.
- `POST /api/users` — create a user. Body: `{ name, email, country? }`

### Audit integrity and attribution

`GET /api/audit` and `GET /api/audit/integrity` both require a Bearer API token
with the `audit:read` scope. Set `TOKEN` to one of your configured tokens with
that scope, then inspect the current chain:

```bash
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:3000/api/audit/integrity"
```

The integrity endpoint returns HTTP `200` for both intact and invalid chains.
Inspect the JSON `valid` field; a successful HTTP request alone does not mean
that verification passed.

| Field | Meaning |
|-------|---------|
| `valid` | Whether the current chain and compatibility aliases pass verification. |
| `checked` | Number of entries verified before the report was returned. |
| `tipHash` | The process's stored hash for the end of its chain. |
| `brokenAt` | Zero-based chain position of a reported problem; `null` when valid. |
| `reason` | Description of the reported problem; `null` when valid. |

Verification walks the whole current process-local chain. Listing filters
do not narrow the integrity check.

Use `GET /api/audit` to investigate individual entries. It supports the
[pagination parameters](#pagination) and these exact-match filters, combined
when more than one is supplied. Empty filter strings are treated as absent.

| Filter | Match against a returned entry |
|--------|-------------------------------|
| `resourceId` | Resource ID (the compatibility alias for `target`). |
| `action` | Action such as `transfer.created` or `user.created`. |
| `scope` | Event scope such as `transfers` or `users`. |
| `outcome` | Recorded outcome such as `success`. |
| `correlationId` | Correlation ID (also returned as `requestId`). |
| `actor` | The exact opaque actor reference returned in `actor`. |

First list entries, then copy a returned actor value into `ACTOR_REF`:

```bash
curl -H "Authorization: Bearer $TOKEN" "http://localhost:3000/api/audit?limit=50"
# Set ACTOR_REF to an entry's returned actor value.
curl --get -H "Authorization: Bearer $TOKEN" \
--data-urlencode "actor=$ACTOR_REF" \
"http://localhost:3000/api/audit"
```

The actor filter does not fingerprint its query value. Keep the raw API token
in the Authorization header and use the returned reference in the query.

#### Actor secret and storage lifetime

[Actor fingerprinting](src/utils/auditCrypto.js) reads its secret once when
the module loads: nonempty `AUDIT_ACTOR_SECRET` takes precedence, then nonempty
`PAGINATION_CURSOR_SECRET`, then a random secret generated for that process.
Set the chosen value in the environment or `.env` before starting the server;
restart the server after changing it. A fixed secret keeps token-derived actor
references stable across restarts and instances; rotating it changes those
references.

The [audit service](src/services/auditService.js) keeps entries, duplicate-event
identities and the chain tip in memory. Restarting or resetting the store
clears that history. Each server process has its own chain: sharing the actor
secret does not share or persist records. The integrity report checks this
in-memory chain's internal consistency; it does not establish an externally
anchored history or provide a persistence guarantee.

## Transfer lifecycle

```
Expand Down
211 changes: 211 additions & 0 deletions benchmark/auditCrypto-results.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
{
"node": "v24.19.0",
"cpu": "AMD EPYC 9V74 80-Core Processor",
"baselineSourceSha256": "2f4ed3632d507b155840ab31be3f6a220b0141a0beb7e9657fe70ba6eb178c3a",
"currentSourceSha256": "ffbcbc5f4815d423879d3b16d30c5315c65c7891bfcd9e96381369356a4c2966",
"samples": 9,
"checksum": 4785642,
"results": [
{
"workload": "transfer",
"operation": "canonicalize",
"iterations": 25622,
"samples": 9,
"baselineOpsPerSecond": 231872,
"currentOpsPerSecond": 254396,
"throughputGainPercent": 9.7,
"milliseconds": {
"baseline": [
108.14750799999999,
110.50075000000004,
114.085827,
120.19784000000004,
433.98151299999995,
118.08017599999994,
108.58598699999993,
107.03499200000033,
104.13203199999998
],
"current": [
100.71716899999998,
96.60119300000002,
93.150758,
105.88667299999997,
110.9722999999999,
109.8460540000001,
96.07561699999997,
96.66931999999997,
116.32044300000007
]
}
},
{
"workload": "transfer",
"operation": "computeEntryHash",
"iterations": 20913,
"samples": 9,
"baselineOpsPerSecond": 154730,
"currentOpsPerSecond": 168089,
"throughputGainPercent": 8.6,
"milliseconds": {
"baseline": [
139.94221199999993,
128.48196199999984,
135.15780799999993,
163.80414900000005,
135.65932299999986,
127.02398100000028,
122.57160699999986,
121.9742380000007,
138.5978610000002
],
"current": [
112.19437000000016,
176.93305099999998,
146.36374899999964,
363.42496900000015,
124.41626499999984,
118.41780299999982,
138.92307100000016,
120.26536099999976,
112.68107099999997
]
}
},
{
"workload": "nested",
"operation": "canonicalize",
"iterations": 13635,
"samples": 9,
"baselineOpsPerSecond": 93662,
"currentOpsPerSecond": 109206,
"throughputGainPercent": 16.6,
"milliseconds": {
"baseline": [
161.79588400000011,
513.342294,
167.7878469999996,
136.8664800000006,
169.69064600000002,
129.1553560000002,
145.57622300000003,
141.5409579999996,
135.3788199999999
],
"current": [
140.92494100000022,
140.3922789999997,
130.87958700000036,
133.18306399999983,
124.85618700000032,
107.7370120000005,
83.3952220000001,
88.12642499999947,
87.29651599999943
]
}
},
{
"workload": "nested",
"operation": "computeEntryHash",
"iterations": 10425,
"samples": 9,
"baselineOpsPerSecond": 78433,
"currentOpsPerSecond": 91882,
"throughputGainPercent": 17.1,
"milliseconds": {
"baseline": [
140.76221500000065,
132.91666199999963,
177.81992100000025,
112.24446799999896,
151.19165799999973,
110.88364899999942,
110.13825799999904,
374.8971299999994,
130.50535200000013
],
"current": [
109.00271799999973,
90.57954900000004,
120.02028500000051,
138.31343799999922,
139.6425650000001,
113.46040299999913,
90.29416000000128,
105.93533899999966,
119.5507439999983
]
}
},
{
"workload": "bulk",
"operation": "canonicalize",
"iterations": 1018,
"samples": 9,
"baselineOpsPerSecond": 8012,
"currentOpsPerSecond": 16196,
"throughputGainPercent": 102.1,
"milliseconds": {
"baseline": [
119.26919499999894,
125.01002899999912,
121.21669199999997,
100.20210100000077,
334.13020099999994,
127.05269299999964,
164.37312499999825,
153.95353699999941,
157.6018699999986
],
"current": [
63.9687529999992,
61.7488110000013,
62.85602699999981,
60.481588000000556,
62.75086899999951,
59.93035299999974,
98.35994399999981,
97.32818900000166,
92.10636800000066
]
}
},
{
"workload": "bulk",
"operation": "computeEntryHash",
"iterations": 1221,
"samples": 9,
"baselineOpsPerSecond": 9863,
"currentOpsPerSecond": 16103,
"throughputGainPercent": 63.3,
"milliseconds": {
"baseline": [
127.3327360000003,
112.31843000000117,
123.13689300000078,
129.09518000000025,
123.80094899999858,
133.62701300000117,
123.57619999999952,
123.3772399999998,
272.7938579999991
],
"current": [
69.38326500000039,
69.1379160000015,
79.74982400000044,
79.03531900000053,
75.65462900000057,
77.22689999999966,
75.8228909999998,
74.59852799999862,
134.89016499999707
]
}
}
],
"baseline_repository_blob": "5423fc9652af26952821d872bcd034582c522968",
"baseline_repository_sha256": "063c5ddcbace868618de6b211212521d1fabff3ba524976fd2af2f00461aeb41",
"baseline_materialization": "Pinned source plus one trailing newline; executable source is unchanged."
}
Loading