autotel 3.2.0 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auto.cjs +2 -2
- package/dist/auto.js +1 -1
- package/dist/{chunk-RZI5XXAD.js → chunk-2GWM2CIT.js} +3 -3
- package/dist/{chunk-RZI5XXAD.js.map → chunk-2GWM2CIT.js.map} +1 -1
- package/dist/{chunk-IS2QJ44P.js → chunk-4EXFRREO.js} +3 -3
- package/dist/{chunk-IS2QJ44P.js.map → chunk-4EXFRREO.js.map} +1 -1
- package/dist/{chunk-FVA2YDEQ.js → chunk-55ALGIAR.js} +4 -4
- package/dist/{chunk-FVA2YDEQ.js.map → chunk-55ALGIAR.js.map} +1 -1
- package/dist/{chunk-ZKKJQS6R.js → chunk-B4CGFDZQ.js} +29 -7
- package/dist/chunk-B4CGFDZQ.js.map +1 -0
- package/dist/{chunk-5RZ3NZ2M.cjs → chunk-C4JCSBFO.cjs} +5 -5
- package/dist/{chunk-5RZ3NZ2M.cjs.map → chunk-C4JCSBFO.cjs.map} +1 -1
- package/dist/{chunk-NN2GODP4.cjs → chunk-DK6VFPVK.cjs} +7 -7
- package/dist/{chunk-NN2GODP4.cjs.map → chunk-DK6VFPVK.cjs.map} +1 -1
- package/dist/{chunk-EEQHQKPP.cjs → chunk-FMTNB27Z.cjs} +32 -32
- package/dist/{chunk-EEQHQKPP.cjs.map → chunk-FMTNB27Z.cjs.map} +1 -1
- package/dist/{chunk-UV64CWMA.cjs → chunk-JAX4LFGG.cjs} +13 -13
- package/dist/{chunk-UV64CWMA.cjs.map → chunk-JAX4LFGG.cjs.map} +1 -1
- package/dist/{chunk-7EVW3Z37.js → chunk-LCXOOJIP.js} +3 -3
- package/dist/{chunk-7EVW3Z37.js.map → chunk-LCXOOJIP.js.map} +1 -1
- package/dist/{chunk-QVLMGNQF.js → chunk-LKASEUWE.js} +4 -4
- package/dist/{chunk-QVLMGNQF.js.map → chunk-LKASEUWE.js.map} +1 -1
- package/dist/{chunk-4UUEGERM.cjs → chunk-PWOECUNT.cjs} +17 -17
- package/dist/{chunk-4UUEGERM.cjs.map → chunk-PWOECUNT.cjs.map} +1 -1
- package/dist/{chunk-KKIYPZOP.cjs → chunk-RYVFCHSO.cjs} +29 -7
- package/dist/chunk-RYVFCHSO.cjs.map +1 -0
- package/dist/{chunk-NIDUQZIN.js → chunk-VFU663OM.js} +3 -3
- package/dist/{chunk-NIDUQZIN.js.map → chunk-VFU663OM.js.map} +1 -1
- package/dist/{chunk-RRTFFAG3.cjs → chunk-VUYLXWCB.cjs} +5 -5
- package/dist/{chunk-RRTFFAG3.cjs.map → chunk-VUYLXWCB.cjs.map} +1 -1
- package/dist/correlation-id.cjs +10 -10
- package/dist/correlation-id.js +2 -2
- package/dist/decorators.cjs +4 -4
- package/dist/decorators.js +3 -3
- package/dist/event.cjs +6 -6
- package/dist/event.js +3 -3
- package/dist/functional.cjs +11 -11
- package/dist/functional.js +3 -3
- package/dist/http.cjs +3 -3
- package/dist/http.js +2 -2
- package/dist/index.cjs +215 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +185 -2
- package/dist/index.d.ts +185 -2
- package/dist/index.js +149 -12
- package/dist/index.js.map +1 -1
- package/dist/{init-BSyIyDs5.d.ts → init-DyE43paw.d.ts} +7 -2
- package/dist/{init-D9Bxx39e.d.cts → init-gyesUMwz.d.cts} +7 -2
- package/dist/instrumentation.cjs +8 -8
- package/dist/instrumentation.js +1 -1
- package/dist/messaging.cjs +7 -7
- package/dist/messaging.js +4 -4
- package/dist/semantic-helpers.cjs +8 -8
- package/dist/semantic-helpers.js +4 -4
- package/dist/webhook.cjs +5 -5
- package/dist/webhook.js +3 -3
- package/dist/workflow-distributed.cjs +5 -5
- package/dist/workflow-distributed.js +3 -3
- package/dist/workflow.cjs +8 -8
- package/dist/workflow.js +4 -4
- package/dist/yaml-config.d.cts +1 -1
- package/dist/yaml-config.d.ts +1 -1
- package/package.json +9 -9
- package/skills/build-audit-trails/SKILL.md +163 -5
- package/skills/build-audit-trails/references/audit-queries.md +73 -0
- package/skills/build-audit-trails/references/framework-wiring.md +196 -0
- package/skills/review-otel-patterns/SKILL.md +44 -0
- package/src/error-catalog.test.ts +133 -0
- package/src/error-catalog.ts +262 -0
- package/src/gen-ai-cost.test.ts +81 -0
- package/src/gen-ai-cost.ts +145 -0
- package/src/index.ts +29 -0
- package/src/init-auto-redactor.test.ts +53 -0
- package/src/init.ts +50 -7
- package/dist/chunk-KKIYPZOP.cjs.map +0 -1
- package/dist/chunk-ZKKJQS6R.js.map +0 -1
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
var chunk4P6ZOARG_cjs = require('./chunk-4P6ZOARG.cjs');
|
|
4
4
|
var chunkINJD3G4K_cjs = require('./chunk-INJD3G4K.cjs');
|
|
5
|
-
var
|
|
5
|
+
var chunkFMTNB27Z_cjs = require('./chunk-FMTNB27Z.cjs');
|
|
6
6
|
require('./chunk-2GIBANLB.cjs');
|
|
7
7
|
require('./chunk-VQTCQKHQ.cjs');
|
|
8
|
-
require('./chunk-
|
|
9
|
-
require('./chunk-
|
|
8
|
+
require('./chunk-JAX4LFGG.cjs');
|
|
9
|
+
require('./chunk-RYVFCHSO.cjs');
|
|
10
10
|
require('./chunk-FEEVB2GV.cjs');
|
|
11
11
|
require('./chunk-CEAQK2QY.cjs');
|
|
12
12
|
require('./chunk-ZNMBW67B.cjs');
|
|
@@ -58,7 +58,7 @@ var WorkflowBaggage = chunkINJD3G4K_cjs.createSafeBaggageSchema(workflowBaggageF
|
|
|
58
58
|
function traceDistributedWorkflow(config) {
|
|
59
59
|
const spanName = `workflow.${config.name}`;
|
|
60
60
|
return (fnFactory) => {
|
|
61
|
-
return
|
|
61
|
+
return chunkFMTNB27Z_cjs.trace(
|
|
62
62
|
{ name: spanName, spanKind: api.SpanKind.INTERNAL },
|
|
63
63
|
(baseCtx) => {
|
|
64
64
|
return async (...args) => {
|
|
@@ -157,7 +157,7 @@ function traceDistributedWorkflow(config) {
|
|
|
157
157
|
function traceDistributedStep(config) {
|
|
158
158
|
const spanName = `workflow.step.${config.name}`;
|
|
159
159
|
return (fnFactory) => {
|
|
160
|
-
return
|
|
160
|
+
return chunkFMTNB27Z_cjs.trace(
|
|
161
161
|
{ name: spanName, spanKind: api.SpanKind.INTERNAL },
|
|
162
162
|
(baseCtx) => {
|
|
163
163
|
return async (...args) => {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { emitCorrelatedEvent } from './chunk-KIL5CUN6.js';
|
|
2
2
|
import { createSafeBaggageSchema } from './chunk-4IFSYQVX.js';
|
|
3
|
-
import { trace } from './chunk-
|
|
3
|
+
import { trace } from './chunk-55ALGIAR.js';
|
|
4
4
|
import './chunk-HLZ7H3VZ.js';
|
|
5
5
|
import './chunk-SEO6NAQT.js';
|
|
6
|
-
import './chunk-
|
|
7
|
-
import './chunk-
|
|
6
|
+
import './chunk-LCXOOJIP.js';
|
|
7
|
+
import './chunk-B4CGFDZQ.js';
|
|
8
8
|
import './chunk-643PQG3Y.js';
|
|
9
9
|
import './chunk-A4E5AQFK.js';
|
|
10
10
|
import './chunk-WGWSHJ2N.js';
|
package/dist/workflow.cjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var chunkC4JCSBFO_cjs = require('./chunk-C4JCSBFO.cjs');
|
|
4
4
|
require('./chunk-4P6ZOARG.cjs');
|
|
5
|
-
require('./chunk-
|
|
5
|
+
require('./chunk-FMTNB27Z.cjs');
|
|
6
6
|
require('./chunk-2GIBANLB.cjs');
|
|
7
7
|
require('./chunk-VQTCQKHQ.cjs');
|
|
8
|
-
require('./chunk-
|
|
9
|
-
require('./chunk-
|
|
8
|
+
require('./chunk-JAX4LFGG.cjs');
|
|
9
|
+
require('./chunk-RYVFCHSO.cjs');
|
|
10
10
|
require('./chunk-FEEVB2GV.cjs');
|
|
11
11
|
require('./chunk-CEAQK2QY.cjs');
|
|
12
12
|
require('./chunk-ZNMBW67B.cjs');
|
|
@@ -24,19 +24,19 @@ require('./chunk-YREV3LGG.cjs');
|
|
|
24
24
|
|
|
25
25
|
Object.defineProperty(exports, "getCurrentWorkflowContext", {
|
|
26
26
|
enumerable: true,
|
|
27
|
-
get: function () { return
|
|
27
|
+
get: function () { return chunkC4JCSBFO_cjs.getCurrentWorkflowContext; }
|
|
28
28
|
});
|
|
29
29
|
Object.defineProperty(exports, "isInWorkflow", {
|
|
30
30
|
enumerable: true,
|
|
31
|
-
get: function () { return
|
|
31
|
+
get: function () { return chunkC4JCSBFO_cjs.isInWorkflow; }
|
|
32
32
|
});
|
|
33
33
|
Object.defineProperty(exports, "traceStep", {
|
|
34
34
|
enumerable: true,
|
|
35
|
-
get: function () { return
|
|
35
|
+
get: function () { return chunkC4JCSBFO_cjs.traceStep; }
|
|
36
36
|
});
|
|
37
37
|
Object.defineProperty(exports, "traceWorkflow", {
|
|
38
38
|
enumerable: true,
|
|
39
|
-
get: function () { return
|
|
39
|
+
get: function () { return chunkC4JCSBFO_cjs.traceWorkflow; }
|
|
40
40
|
});
|
|
41
41
|
//# sourceMappingURL=workflow.cjs.map
|
|
42
42
|
//# sourceMappingURL=workflow.cjs.map
|
package/dist/workflow.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export { getCurrentWorkflowContext, isInWorkflow, traceStep, traceWorkflow } from './chunk-
|
|
1
|
+
export { getCurrentWorkflowContext, isInWorkflow, traceStep, traceWorkflow } from './chunk-4EXFRREO.js';
|
|
2
2
|
import './chunk-KIL5CUN6.js';
|
|
3
|
-
import './chunk-
|
|
3
|
+
import './chunk-55ALGIAR.js';
|
|
4
4
|
import './chunk-HLZ7H3VZ.js';
|
|
5
5
|
import './chunk-SEO6NAQT.js';
|
|
6
|
-
import './chunk-
|
|
7
|
-
import './chunk-
|
|
6
|
+
import './chunk-LCXOOJIP.js';
|
|
7
|
+
import './chunk-B4CGFDZQ.js';
|
|
8
8
|
import './chunk-643PQG3Y.js';
|
|
9
9
|
import './chunk-A4E5AQFK.js';
|
|
10
10
|
import './chunk-WGWSHJ2N.js';
|
package/dist/yaml-config.d.cts
CHANGED
package/dist/yaml-config.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autotel",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.1",
|
|
4
4
|
"description": "Write Once, Observe Anywhere",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -317,7 +317,7 @@
|
|
|
317
317
|
}
|
|
318
318
|
},
|
|
319
319
|
"devDependencies": {
|
|
320
|
-
"@arethetypeswrong/cli": "^0.18.
|
|
320
|
+
"@arethetypeswrong/cli": "^0.18.3",
|
|
321
321
|
"@edge-runtime/vm": "^5.0.0",
|
|
322
322
|
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
|
|
323
323
|
"@opentelemetry/context-async-hooks": "^2.7.1",
|
|
@@ -328,25 +328,25 @@
|
|
|
328
328
|
"@opentelemetry/resource-detector-container": "^0.8.9",
|
|
329
329
|
"@opentelemetry/resource-detector-gcp": "^0.53.0",
|
|
330
330
|
"@opentelemetry/sdk-trace-node": "^2.7.1",
|
|
331
|
-
"@swc/core": "^1.15.
|
|
331
|
+
"@swc/core": "^1.15.40",
|
|
332
332
|
"@total-typescript/ts-reset": "^0.6.1",
|
|
333
333
|
"@total-typescript/tsconfig": "^1.0.4",
|
|
334
334
|
"@types/eslint-config-prettier": "^6.11.3",
|
|
335
|
-
"@types/node": "^25.
|
|
336
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
337
|
-
"@typescript-eslint/parser": "^8.
|
|
335
|
+
"@types/node": "^25.9.1",
|
|
336
|
+
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
|
337
|
+
"@typescript-eslint/parser": "^8.60.0",
|
|
338
338
|
"eslint-config-prettier": "^10.1.8",
|
|
339
339
|
"eslint-plugin-unicorn": "^64.0.0",
|
|
340
340
|
"pino": "^10.3.1",
|
|
341
341
|
"prettier": "^3.8.3",
|
|
342
342
|
"rimraf": "^6.1.3",
|
|
343
343
|
"tsup": "^8.5.1",
|
|
344
|
-
"tsx": "^4.
|
|
344
|
+
"tsx": "^4.22.3",
|
|
345
345
|
"typescript": "^6.0.3",
|
|
346
|
-
"typescript-eslint": "^8.
|
|
346
|
+
"typescript-eslint": "^8.60.0",
|
|
347
347
|
"unplugin-swc": "^1.5.9",
|
|
348
348
|
"vite-tsconfig-paths": "^6.1.1",
|
|
349
|
-
"vitest": "^4.1.
|
|
349
|
+
"vitest": "^4.1.7",
|
|
350
350
|
"vitest-mock-extended": "^4.0.0",
|
|
351
351
|
"winston": "^3.19.0",
|
|
352
352
|
"yaml": "^2.9.0"
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: build-audit-trails
|
|
3
3
|
description: >
|
|
4
|
-
|
|
5
|
-
autotel
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
Build or review tamper-aware audit trails on top of OpenTelemetry spans
|
|
5
|
+
using autotel and the autotel-audit package (`withAudit`,
|
|
6
|
+
`setAuditAttributes`, `forceKeepAuditEvent`). Covers what counts as
|
|
7
|
+
auditable, the audit-only span discipline, sampling bypass, HMAC and
|
|
8
|
+
hash-chain signing with tamper detection, denial logging, redaction,
|
|
9
|
+
retention, GDPR right-to-erasure via crypto-shredding, separation from
|
|
10
|
+
operational telemetry, testing audit spans, backend-agnostic audit queries,
|
|
11
|
+
a production-readiness checklist, and framework wiring (Next.js, Nuxt,
|
|
12
|
+
Nitro, NestJS, Express, Fastify, Hono, Cloudflare Workers, AWS Lambda,
|
|
13
|
+
standalone). Use it to design new audit trails or review existing ones for
|
|
14
|
+
GDPR, HIPAA, SOC 2, PCI-DSS, ISO 27001, SOX, and GxP compliance.
|
|
9
15
|
license: MIT
|
|
10
16
|
---
|
|
11
17
|
|
|
@@ -21,6 +27,55 @@ autotel lets you express both with the same primitive — a span — but you sho
|
|
|
21
27
|
- Adding "who did what" trails for admin actions, access reviews, payments
|
|
22
28
|
- Recording authorization decisions (allow + deny)
|
|
23
29
|
- Building immutable evidence for incident response
|
|
30
|
+
- Reviewing an existing audit trail for compliance gaps (see "Review an existing audit trail")
|
|
31
|
+
|
|
32
|
+
## Quick reference
|
|
33
|
+
|
|
34
|
+
| Situation | Use |
|
|
35
|
+
| --------------------------------------------------- | -------------------------------------------------------- |
|
|
36
|
+
| Wrap an action so success/failure is audited + kept | `withAudit(metadata, fn)` from `autotel-audit` |
|
|
37
|
+
| Tag the active span with `audit.*` attributes only | `setAuditAttributes(metadata)` |
|
|
38
|
+
| Make sure an audit span survives tail sampling | `forceKeepAuditEvent()` |
|
|
39
|
+
| Record an authorization denial | `audit({ …, outcome: 'deny', reason })` (Step 1 helper) |
|
|
40
|
+
| Full control / framework-agnostic span helper | hand-rolled `audit()` (Step 1) |
|
|
41
|
+
| Keep audit data off ops dashboards | `FilteringSpanProcessor` split (Step 3) |
|
|
42
|
+
| Prove a record was not altered | HMAC or hash-chain signature (Step 4) |
|
|
43
|
+
| Honor a GDPR erasure request on an append-only log | crypto-shredding (Step 6.5) |
|
|
44
|
+
| Assert the trail in tests | `createTraceCollector()` from `autotel/testing` (Step 8) |
|
|
45
|
+
|
|
46
|
+
## The shortest path: the `autotel-audit` package
|
|
47
|
+
|
|
48
|
+
`autotel-audit` ships this discipline as helpers. Reach for these before hand-rolling:
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import {
|
|
52
|
+
withAudit,
|
|
53
|
+
setAuditAttributes,
|
|
54
|
+
forceKeepAuditEvent,
|
|
55
|
+
} from 'autotel-audit';
|
|
56
|
+
|
|
57
|
+
// Wrap the action: sets audit.* attributes, force-keeps past tail sampling,
|
|
58
|
+
// and tags outcome 'success' / 'failure' automatically.
|
|
59
|
+
await withAudit(
|
|
60
|
+
{
|
|
61
|
+
action: 'user.delete',
|
|
62
|
+
resource: 'user',
|
|
63
|
+
actorId: 'usr_42',
|
|
64
|
+
category: 'admin',
|
|
65
|
+
},
|
|
66
|
+
async () => db.user.delete({ where: { id } }),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Or tag the current span yourself and opt out of sampling:
|
|
70
|
+
setAuditAttributes({
|
|
71
|
+
action: 'secret.read',
|
|
72
|
+
resource: 'sec_abc',
|
|
73
|
+
actorId: 'usr_42',
|
|
74
|
+
});
|
|
75
|
+
forceKeepAuditEvent();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`withAudit` marks the span with `autotel.audit = true` and sets the tail-sampling keep flags via `forceKeepAuditEvent`, so audit events are never dropped. The rest of this guide builds the same model from raw spans when you need full control over the schema, signing, and the pipeline split. If you use the package, filter on `autotel.audit` (not `audit`) in Step 3.
|
|
24
79
|
|
|
25
80
|
## The audit span discipline
|
|
26
81
|
|
|
@@ -122,6 +177,17 @@ Critical: route audit spans to a **different processor and backend** so:
|
|
|
122
177
|
- They are not subject to development-mode debug exporters.
|
|
123
178
|
- They go to a write-once / append-only store (S3 Object Lock, immutable bucket, dedicated audit DB).
|
|
124
179
|
|
|
180
|
+
How a span flows once you split the pipeline:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
audit(payload)
|
|
184
|
+
└─ span.attributes['audit'] = true (or autotel.audit via the package)
|
|
185
|
+
│
|
|
186
|
+
├─► FilteringSpanProcessor include: audit === true ─► auditExporter ─► append-only store
|
|
187
|
+
│
|
|
188
|
+
└─► FilteringSpanProcessor exclude: audit === true ─► opsExporter (PII-redacted dashboards)
|
|
189
|
+
```
|
|
190
|
+
|
|
125
191
|
```typescript
|
|
126
192
|
import {
|
|
127
193
|
composeSpanProcessors,
|
|
@@ -221,8 +287,31 @@ Audit retention is set by regulation, not engineering taste. Common minimums:
|
|
|
221
287
|
|
|
222
288
|
Express retention as a backend lifecycle policy (S3 Object Lock COMPLIANCE mode, BigQuery `--time_partitioning_expiration`), not application code.
|
|
223
289
|
|
|
290
|
+
## Step 6.5: GDPR and the right to erasure
|
|
291
|
+
|
|
292
|
+
An append-only audit log and GDPR Article 17 ("right to be forgotten") look like a contradiction: you must keep the audit record, but the subject can demand their personal data be erased. Resolve it with **crypto-shredding** rather than deleting rows.
|
|
293
|
+
|
|
294
|
+
- Store the immutable facts in the clear: `audit.action`, `audit.outcome`, timestamps, `audit.resource.id`.
|
|
295
|
+
- Encrypt any personal data (names, emails, free-form context) with a **per-subject key**, and store only the ciphertext on the span (or a reference to it).
|
|
296
|
+
- Keep per-subject keys in a separate key store. To honor an erasure request, delete that subject's key. The audit record stays intact and tamper-evident; the personal fields become permanently unrecoverable.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
// Never put raw PII on the span. Store a reference and shred the key on request.
|
|
300
|
+
setAuditAttributes({
|
|
301
|
+
action: 'profile.update',
|
|
302
|
+
resource: 'user',
|
|
303
|
+
actorId: 'usr_42',
|
|
304
|
+
'subject.id': 'usr_42', // internal id, safe to retain
|
|
305
|
+
'pii.ref': 'kms://subjects/usr_42/v3', // ciphertext lives off-span, keyed per subject
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
This keeps the chain of custody and signatures valid (you never mutate a signed record) while making erasure a key-management operation. Confirm the approach with your DPO; some regulators accept crypto-shredding as erasure, others want documented justification.
|
|
310
|
+
|
|
224
311
|
## Step 7: Framework wiring
|
|
225
312
|
|
|
313
|
+
The handlers below are the common cases. For Nuxt, Nitro, NestJS, Fastify, AWS Lambda, and standalone jobs, see [references/framework-wiring.md](references/framework-wiring.md).
|
|
314
|
+
|
|
226
315
|
### Next.js
|
|
227
316
|
|
|
228
317
|
```typescript
|
|
@@ -286,6 +375,66 @@ export default defineWorkerFetch(
|
|
|
286
375
|
);
|
|
287
376
|
```
|
|
288
377
|
|
|
378
|
+
## Step 8: Test the trail
|
|
379
|
+
|
|
380
|
+
An audit trail you have not tested is a compliance risk. The two tests that matter most: the denial path is recorded, and the audit attributes are present and correct. Use `createTraceCollector()` from `autotel/testing` to assert on emitted spans.
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
384
|
+
import { createTraceCollector, type TraceCollector } from 'autotel/testing';
|
|
385
|
+
|
|
386
|
+
describe('audit trail', () => {
|
|
387
|
+
let collector: TraceCollector;
|
|
388
|
+
beforeEach(() => {
|
|
389
|
+
collector = createTraceCollector();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('records a denial with reason and actor', async () => {
|
|
393
|
+
await expect(deleteUser(forbiddenReq)).rejects.toMatchObject({
|
|
394
|
+
status: 403,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const [span] = collector.getSpansByAttributes({ 'audit.outcome': 'deny' });
|
|
398
|
+
expect(span).toBeDefined();
|
|
399
|
+
expect(span.attributes['audit.action']).toBe('user.delete');
|
|
400
|
+
expect(span.attributes['audit.reason']).toBeTruthy();
|
|
401
|
+
expect(span.attributes['enduser.id']).toBe('usr_42');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('records the success path on allow', async () => {
|
|
405
|
+
await deleteUser(allowedReq);
|
|
406
|
+
expect(
|
|
407
|
+
collector.getSpansByAttributes({ 'audit.outcome': 'allow' }),
|
|
408
|
+
).toHaveLength(1);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Test the signature too: build an audit span, recompute the HMAC over the sorted attributes, and assert it matches; then mutate one attribute and assert it no longer does.
|
|
414
|
+
|
|
415
|
+
## Review an existing audit trail
|
|
416
|
+
|
|
417
|
+
When the task is to audit existing code rather than build new, work these four passes in order and report findings as you go.
|
|
418
|
+
|
|
419
|
+
1. **Pipeline.** Grep for where audit spans are produced (`audit(`, `withAudit`, `setAuditAttributes`). Confirm a `FilteringSpanProcessor` routes them to a separate, append-only backend, and that ops never receives them. Flag any audit span subject to sampling.
|
|
420
|
+
2. **Coverage.** List every `audit(`/`withAudit` call site. Are denials logged, not just successes? Are mutating admin actions, access reviews, payments, and data exports all covered? Flag audit-on-every-read noise.
|
|
421
|
+
3. **Integrity + redaction.** Is there a signature (HMAC or hash-chain) where storage is shared with the producer? Are raw payloads, secrets, or emails on the span instead of references and internal ids? Is the ops branch redacted?
|
|
422
|
+
4. **Tests + retention.** Is there a denial-path test and a signature-verification test? Is retention enforced at the storage layer (lifecycle policy), not in code?
|
|
423
|
+
|
|
424
|
+
## Production checklist
|
|
425
|
+
|
|
426
|
+
- [ ] Audit spans marked (`audit` / `autotel.audit`) and force-kept past sampling
|
|
427
|
+
- [ ] Separate processor and backend from ops; ops branch redacted
|
|
428
|
+
- [ ] Both allow and deny outcomes recorded
|
|
429
|
+
- [ ] No secrets, tokens, raw payloads, or emails on spans (references and internal ids only)
|
|
430
|
+
- [ ] Signature (HMAC or hash-chain) where storage is shared with the producer; keys rotated
|
|
431
|
+
- [ ] Retention set as a storage lifecycle policy per regulation
|
|
432
|
+
- [ ] GDPR erasure plan (crypto-shredding) for personal fields
|
|
433
|
+
- [ ] Multi-tenant isolation on the audit store
|
|
434
|
+
- [ ] Denial-path and signature-verification tests in CI
|
|
435
|
+
|
|
436
|
+
To query the trail (denials, per-actor history, tamper detection) across Honeycomb, Grafana Tempo, or Datadog, see [references/audit-queries.md](references/audit-queries.md).
|
|
437
|
+
|
|
289
438
|
## Anti-patterns
|
|
290
439
|
|
|
291
440
|
| Anti-pattern | Fix |
|
|
@@ -300,3 +449,12 @@ export default defineWorkerFetch(
|
|
|
300
449
|
| Audit on every read of harmless data | Audit _meaningful_ events; not every list call |
|
|
301
450
|
| Audit row tied to a specific framework | The `audit()` function is framework-agnostic |
|
|
302
451
|
| `enduser.id` = email | Use the internal id; emails go in a separate identity table |
|
|
452
|
+
|
|
453
|
+
## Glossary
|
|
454
|
+
|
|
455
|
+
- **Audit span** — an OpenTelemetry span that records who did what to which resource, marked `audit` / `autotel.audit` so it is routed and retained separately from ops telemetry.
|
|
456
|
+
- **Force-keep** — opting a span out of tail sampling so it is always exported; `forceKeepAuditEvent()` sets the autotel tail-keep flags.
|
|
457
|
+
- **Denial** — an authorization decision that blocked an action; recorded with `audit.outcome = 'deny'` and a reason. Logging denials is as important as logging successes.
|
|
458
|
+
- **Hash-chain** — linking each audit record to the hash of the previous one so removing or reordering records is detectable.
|
|
459
|
+
- **Crypto-shredding** — satisfying an erasure request by destroying the per-subject encryption key rather than deleting the immutable record.
|
|
460
|
+
- **Append-only store** — a backend that rejects updates and deletes (S3 Object Lock, immutable bucket, WORM storage), the destination for audit spans.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Querying the audit trail
|
|
2
|
+
|
|
3
|
+
Because audit events are OpenTelemetry spans, you query them with the same tools as the rest of your telemetry, no separate audit UI required. The attribute names below match the skill: `audit.action`, `audit.outcome`, `audit.resource.id`, `enduser.id`, `autotel.audit`, and `audit.signature.value`.
|
|
4
|
+
|
|
5
|
+
Run these against the **audit backend** (the append-only one), not your ops backend.
|
|
6
|
+
|
|
7
|
+
## Find all denials in a window
|
|
8
|
+
|
|
9
|
+
A spike in denials is a security signal (credential stuffing, privilege probing).
|
|
10
|
+
|
|
11
|
+
- **Honeycomb** — filter `autotel.audit = true` AND `audit.outcome = deny`, group by `audit.action` and `enduser.id`, visualize `COUNT`.
|
|
12
|
+
- **Grafana Tempo (TraceQL)**:
|
|
13
|
+
```
|
|
14
|
+
{ span.autotel.audit = true && span.audit.outcome = "deny" }
|
|
15
|
+
```
|
|
16
|
+
- **Datadog (spans search)**:
|
|
17
|
+
```
|
|
18
|
+
@autotel.audit:true @audit.outcome:deny
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Trace one actor across every resource
|
|
22
|
+
|
|
23
|
+
Answer "everything user X did" for an access review or incident.
|
|
24
|
+
|
|
25
|
+
- **Honeycomb** — filter `enduser.id = "usr_42"`, group by `audit.action`, `audit.resource.type`, order by timestamp.
|
|
26
|
+
- **TraceQL**:
|
|
27
|
+
```
|
|
28
|
+
{ span.enduser.id = "usr_42" && span.autotel.audit = true }
|
|
29
|
+
```
|
|
30
|
+
- **Datadog**:
|
|
31
|
+
```
|
|
32
|
+
@enduser.id:usr_42 @autotel.audit:true
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Who touched one resource
|
|
36
|
+
|
|
37
|
+
Answer "everyone who accessed secret `sec_abc`".
|
|
38
|
+
|
|
39
|
+
- **TraceQL**:
|
|
40
|
+
```
|
|
41
|
+
{ span.audit.resource.id = "sec_abc" && span.autotel.audit = true }
|
|
42
|
+
```
|
|
43
|
+
- **Datadog**:
|
|
44
|
+
```
|
|
45
|
+
@audit.resource.id:sec_abc @autotel.audit:true
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Spot coverage gaps
|
|
49
|
+
|
|
50
|
+
Confirm sensitive actions are actually being recorded. Group audit spans by `audit.action` and compare against your list of auditable actions. An action that never appears is either never exercised or never audited; both deserve a look.
|
|
51
|
+
|
|
52
|
+
- **Honeycomb** — group by `audit.action`, `COUNT`, over 30 days.
|
|
53
|
+
|
|
54
|
+
## Detect tampering or missing signatures
|
|
55
|
+
|
|
56
|
+
In a shared-storage setup every audit span should carry `audit.signature.value`. Spans without one, or whose recomputed HMAC does not match, are suspect.
|
|
57
|
+
|
|
58
|
+
- **Find unsigned audit spans (TraceQL)**:
|
|
59
|
+
```
|
|
60
|
+
{ span.autotel.audit = true && span.audit.signature.value = nil }
|
|
61
|
+
```
|
|
62
|
+
- **Datadog**:
|
|
63
|
+
```
|
|
64
|
+
@autotel.audit:true -@audit.signature.value:*
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Verification itself happens in code: export the spans, recompute the HMAC over the sorted attribute set (excluding `audit.signature.value`), and flag mismatches. A scheduled job that does this and writes its own audit span (`action: 'audit.integrity.check'`) gives you meta-auditing.
|
|
68
|
+
|
|
69
|
+
## Tips
|
|
70
|
+
|
|
71
|
+
- Pin the time range explicitly; audit queries often span months, well past hot-storage windows.
|
|
72
|
+
- Export results to CSV/NDJSON for auditors who need evidence outside the observability tool.
|
|
73
|
+
- Keep these queries in version control next to the audit code so reviewers can reproduce them.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Framework wiring for audit trails
|
|
2
|
+
|
|
3
|
+
Each handler below records an authorization decision (allow and deny) and emits one audit span. They assume the `audit()` / `withAuthz()` helpers from Step 1–2 of the skill, or the `withAudit` helper from `autotel-audit`. Keep the audit call inside the traced request so it inherits the trace context.
|
|
4
|
+
|
|
5
|
+
The rule across every framework is the same: wrap the authorization decision so both branches reach the audit pipeline, and never put raw payloads on the span.
|
|
6
|
+
|
|
7
|
+
## Next.js (App Router)
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// app/admin/users/[id]/route.ts
|
|
11
|
+
import { withAuthz } from '@/lib/audit';
|
|
12
|
+
|
|
13
|
+
export async function DELETE(
|
|
14
|
+
req: Request,
|
|
15
|
+
{ params }: { params: { id: string } },
|
|
16
|
+
) {
|
|
17
|
+
return withAuthz(
|
|
18
|
+
{
|
|
19
|
+
action: 'user.delete',
|
|
20
|
+
resource: { type: 'user', id: params.id },
|
|
21
|
+
actor: { id: req.headers.get('x-user-id')!, role: 'admin' },
|
|
22
|
+
},
|
|
23
|
+
async () => ({ allow: await canDelete(req, params.id) }),
|
|
24
|
+
async () => {
|
|
25
|
+
await db.user.delete({ where: { id: params.id } });
|
|
26
|
+
return Response.json({ ok: true });
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Nuxt / Nitro
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// server/api/secrets/[id].delete.ts
|
|
36
|
+
import { withAuthz } from '~/server/utils/audit';
|
|
37
|
+
|
|
38
|
+
export default defineEventHandler((event) =>
|
|
39
|
+
withAuthz(
|
|
40
|
+
{
|
|
41
|
+
action: 'secret.delete',
|
|
42
|
+
resource: { type: 'secret', id: getRouterParam(event, 'id')! },
|
|
43
|
+
actor: { id: event.context.user.id, role: event.context.user.role },
|
|
44
|
+
},
|
|
45
|
+
async () => ({ allow: await canManageSecret(event) }),
|
|
46
|
+
async () => {
|
|
47
|
+
await secrets.delete(getRouterParam(event, 'id')!);
|
|
48
|
+
return { ok: true };
|
|
49
|
+
},
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## NestJS
|
|
55
|
+
|
|
56
|
+
Wrap the audited work inside the service or an interceptor. The decision lives next to the business logic:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
@Injectable()
|
|
60
|
+
export class UsersService {
|
|
61
|
+
async remove(id: string, actor: Actor) {
|
|
62
|
+
return withAuthz(
|
|
63
|
+
{ action: 'user.delete', resource: { type: 'user', id }, actor },
|
|
64
|
+
async () => ({ allow: actor.role === 'admin' }),
|
|
65
|
+
async () => this.repo.delete(id),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Express
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { withAuthz } from './audit';
|
|
75
|
+
|
|
76
|
+
app.delete('/admin/users/:id', async (req, res, next) => {
|
|
77
|
+
try {
|
|
78
|
+
await withAuthz(
|
|
79
|
+
{
|
|
80
|
+
action: 'user.delete',
|
|
81
|
+
resource: { type: 'user', id: req.params.id },
|
|
82
|
+
actor: { id: req.user.id, role: req.user.role },
|
|
83
|
+
},
|
|
84
|
+
async () => ({ allow: req.user.role === 'admin' }),
|
|
85
|
+
async () => db.user.delete({ where: { id: req.params.id } }),
|
|
86
|
+
);
|
|
87
|
+
res.json({ ok: true });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
next(err);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Fastify
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
import { withAuthz } from './audit';
|
|
98
|
+
|
|
99
|
+
fastify.delete('/admin/users/:id', async (request, reply) => {
|
|
100
|
+
await withAuthz(
|
|
101
|
+
{
|
|
102
|
+
action: 'user.delete',
|
|
103
|
+
resource: { type: 'user', id: (request.params as { id: string }).id },
|
|
104
|
+
actor: { id: request.user.id, role: request.user.role },
|
|
105
|
+
},
|
|
106
|
+
async () => ({ allow: request.user.role === 'admin' }),
|
|
107
|
+
async () =>
|
|
108
|
+
db.user.delete({ where: { id: (request.params as { id: string }).id } }),
|
|
109
|
+
);
|
|
110
|
+
return { ok: true };
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Hono
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
import { withAuthz } from './audit';
|
|
118
|
+
|
|
119
|
+
app.post('/secrets/:id/read', (c) =>
|
|
120
|
+
withAuthz(
|
|
121
|
+
{
|
|
122
|
+
action: 'secret.read',
|
|
123
|
+
resource: { type: 'secret', id: c.req.param('id') },
|
|
124
|
+
actor: { id: c.var.user.id, role: c.var.user.role },
|
|
125
|
+
},
|
|
126
|
+
() => requireScope(c, 'secrets:read'),
|
|
127
|
+
async () => c.json({ value: await secrets.read(c.req.param('id')) }),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Cloudflare Workers
|
|
133
|
+
|
|
134
|
+
Audit from inside `defineWorkerFetch` so `ctx.waitUntil` exports the audit span before the response returns:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { defineWorkerFetch } from 'autotel-cloudflare';
|
|
138
|
+
import { withAuthz } from './audit';
|
|
139
|
+
|
|
140
|
+
export default defineWorkerFetch(
|
|
141
|
+
{ service: { name: 'admin-api' } },
|
|
142
|
+
async (request, env, ctx, log) =>
|
|
143
|
+
withAuthz(
|
|
144
|
+
{
|
|
145
|
+
action: 'data.export',
|
|
146
|
+
resource: { type: 'project', id: 'p_123' },
|
|
147
|
+
actor: { id: 'usr_42' },
|
|
148
|
+
},
|
|
149
|
+
async () => ({ allow: true }),
|
|
150
|
+
async () => Response.json({ ok: true }),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## AWS Lambda
|
|
156
|
+
|
|
157
|
+
Wrap the traced handler; the audit span is flushed when the handler settles:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { withAuthz } from './audit';
|
|
161
|
+
|
|
162
|
+
export const handler = async (event: APIGatewayProxyEventV2) =>
|
|
163
|
+
withAuthz(
|
|
164
|
+
{
|
|
165
|
+
action: 'invoice.void',
|
|
166
|
+
resource: { type: 'invoice', id: event.pathParameters!.id! },
|
|
167
|
+
actor: { id: event.requestContext.authorizer!.userId },
|
|
168
|
+
},
|
|
169
|
+
async () => ({ allow: await canVoid(event) }),
|
|
170
|
+
async () => {
|
|
171
|
+
await invoices.void(event.pathParameters!.id!);
|
|
172
|
+
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Standalone scripts and cron jobs
|
|
178
|
+
|
|
179
|
+
Outside a request there is no ambient trace context. Wrap the job in `trace()` first so the audit span has a parent, and set the actor to the system principal:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { trace } from 'autotel';
|
|
183
|
+
import { withAudit } from 'autotel-audit';
|
|
184
|
+
|
|
185
|
+
export const nightlyPurge = trace(async function nightlyPurge() {
|
|
186
|
+
await withAudit(
|
|
187
|
+
{
|
|
188
|
+
action: 'data.purge',
|
|
189
|
+
resource: 'expired-sessions',
|
|
190
|
+
actorId: 'system:cron',
|
|
191
|
+
category: 'maintenance',
|
|
192
|
+
},
|
|
193
|
+
async () => sessions.purgeExpired(),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
```
|