-
Notifications
You must be signed in to change notification settings - Fork 288
Expand file tree
/
Copy pathprotectedEndpointMiddleware.ts
More file actions
194 lines (179 loc) · 6.93 KB
/
Copy pathprotectedEndpointMiddleware.ts
File metadata and controls
194 lines (179 loc) · 6.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
/**
* @module audit/protectedEndpointMiddleware
* @description Express middleware that automatically emits a structured audit
* entry for every request handled by an auth-protected route.
*
* ## How it works
*
* The middleware registers a `res.on('finish')` listener before calling
* `next()`. This guarantees that the audit entry is written **after** the
* full middleware chain (including authentication) has run, so the final
* HTTP status code and the resolved `req.user` identity are both available.
*
* Mount this middleware **before** `authenticateMiddleware` / `requireAuth`
* on any router or route group that requires authentication.
*
* ## Action mapping
*
* | Condition | AuditAction | Severity |
* |-------------------------------|----------------------|-----------|
* | Status 401 (unauthenticated) | `AUTH_FAILED` | `WARNING` |
* | Status 403 (unauthorised) | `AUTH_FAILED` | `WARNING` |
* | GET / HEAD + 2xx/3xx | `ENDPOINT_ACCESS` | `INFO` |
* | POST / PUT / PATCH / DELETE | `ENDPOINT_MUTATION` | `INFO` |
* | Any method + 4xx/5xx (other) | method-derived above | `WARNING` |
*
* ## Redaction
*
* All request headers and body fields are passed through the deterministic
* redaction rules defined in `./redact` before being written to the store.
* The `Authorization` header value is **never** persisted.
*
* ## Traceability
*
* The `requestId` set by `requestIdMiddleware` (stored in
* `res.locals.requestId`) is used as the `correlationId` on every entry,
* enabling end-to-end request tracing across logs.
*
* @security
* - Audit failures are silently swallowed (with a console.error) so that a
* logging fault never breaks the primary request path.
* - No raw bearer tokens, passwords, or PII reach the audit store.
*
* @example
* ```ts
* import { protectedEndpointAuditMiddleware } from './audit/protectedEndpointMiddleware';
* import { authenticateMiddleware } from './auth/authenticate';
*
* router.use(protectedEndpointAuditMiddleware);
* router.use(authenticateMiddleware);
* router.get('/contracts', handler);
* ```
*/
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import type { AuditAction, AuditSeverity } from './types';
import type { AuthenticatedRequest } from '../auth/authenticate';
import { buildAuditMetadata } from './redact';
import { auditService, AuditService } from './service';
import { validateEnv } from '../config/env.schema';
import {
AUDIT_ACTIONS,
AUDIT_SEVERITIES,
AUDIT_RESOURCES,
AUDIT_DEFAULTS,
} from '../constants/audit';
// ─── Internal helpers ─────────────────────────────────────────────────────────
/**
* Map HTTP method + final status code to an AuditAction.
* Auth failures take priority over the HTTP verb.
*/
function deriveAction(method: string, statusCode: number): AuditAction {
if (statusCode === 401 || statusCode === 403) {
return AUDIT_ACTIONS.AUTH_FAILED;
}
const verb = method.toUpperCase();
return verb === 'GET' || verb === 'HEAD' ? AUDIT_ACTIONS.ENDPOINT_ACCESS : AUDIT_ACTIONS.ENDPOINT_MUTATION;
}
/**
* Choose the appropriate severity for an audit entry.
* Auth failures and unexpected errors are WARNING; routine access is INFO.
*/
function deriveSeverity(action: AuditAction, statusCode: number): AuditSeverity {
if (action === AUDIT_ACTIONS.AUTH_FAILED) return AUDIT_SEVERITIES.WARNING;
if (statusCode >= 400) return AUDIT_SEVERITIES.WARNING;
return AUDIT_SEVERITIES.INFO;
}
/**
* Extract the resource type from a URL path.
* Parses the first named segment after the versioned API prefix.
*
* @example
* '/api/v1/contracts/abc' → 'contracts'
* '/api/v1/reputation/u1' → 'reputation'
* '/other' → 'endpoint'
*/
function deriveResource(path: string): string {
const match = /^\/api\/v\d+\/([^/?#]+)/i.exec(path);
return match?.[1] ?? AUDIT_RESOURCES.ENDPOINT;
}
/**
* Extract the primary resource ID from a URL path.
* Returns the path segment immediately after the resource type, if present.
*
* @example
* '/api/v1/contracts/abc123/metadata' → 'abc123'
* '/api/v1/contracts' → ''
*/
function deriveResourceId(path: string): string {
const match = /^\/api\/v\d+\/[^/?#]+\/([^/?#]+)/i.exec(path);
return match?.[1] ?? '';
}
// ─── Middleware factory ───────────────────────────────────────────────────────
/**
* Factory that returns a `protectedEndpointAuditMiddleware` bound to the
* provided `AuditService` instance. Useful for injecting isolated services
* in tests without touching the module-level singleton.
*
* @param service - AuditService instance to write entries to (defaults to
* the application singleton).
*/
export function createProtectedEndpointAuditMiddleware(
service: AuditService = auditService,
): RequestHandler {
return function protectedEndpointAuditMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
const env = validateEnv();
if (!env.AUDIT_ENABLED) {
// Feature flag off — skip the finish listener entirely; no audit entries
// are written for protected-endpoint traffic.
next();
return;
}
res.on('finish', () => {
try {
// req.user is populated by authenticateMiddleware after this runs
const actor =
(req as AuthenticatedRequest).user?.userId ?? AUDIT_DEFAULTS.ANONYMOUS_ACTOR;
const action = deriveAction(req.method, res.statusCode);
const severity = deriveSeverity(action, res.statusCode);
const resource = deriveResource(req.path);
const resourceId = deriveResourceId(req.path);
const requestId = res.locals['requestId'] as string | undefined;
const ipAddress =
(req.ip ?? req.socket?.remoteAddress) as string | undefined;
const metadata = buildAuditMetadata(
req.method,
req.path,
req.headers as Record<string, string | string[] | undefined>,
req.body,
req.query as Record<string, unknown>,
res.statusCode,
requestId,
);
service.log({
action,
severity,
actor,
resource,
resourceId,
metadata,
ipAddress,
correlationId: requestId,
});
} catch (err) {
// Audit failures must never disrupt the request lifecycle.
console.error('[protectedEndpointAuditMiddleware] Failed to write audit entry:', err);
}
});
next();
};
}
/**
* Ready-to-use middleware instance backed by the application-level singleton
* `AuditService`. Import and mount this on any protected router.
*/
export const protectedEndpointAuditMiddleware =
createProtectedEndpointAuditMiddleware();