@everystack/api 0.2.7 → 0.2.8
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/package.json +1 -1
- package/src/handler/audit.ts +128 -31
- package/src/handler/index.ts +73 -21
package/package.json
CHANGED
package/src/handler/audit.ts
CHANGED
|
@@ -7,66 +7,163 @@
|
|
|
7
7
|
* the only place the caller's identity, the action, and the params all meet, so the
|
|
8
8
|
* record is captured here and handed to an app-provided append-only sink.
|
|
9
9
|
*
|
|
10
|
+
* Captures the **query, not the response**: who, what (method + table or RPC), which
|
|
11
|
+
* (the filter and the column *names* a write touched — never values), and the outcome.
|
|
12
|
+
* No row contents, no before/after — that keeps the trail low-sensitivity and needs no
|
|
13
|
+
* DB triggers. Every **non-GET** request (writes + RPCs) is recorded when a sink is
|
|
14
|
+
* configured — not an opt-in list, so there's no forgot-to-declare gap. GET reads are
|
|
15
|
+
* delegated to RLS (a sound RLS read returns only self-data). See
|
|
16
|
+
* docs/plans/forensic-audit-spec.md.
|
|
17
|
+
*
|
|
10
18
|
* The sink should be append-only with integrity (e.g. S3 Object-Lock / WORM) — an
|
|
11
19
|
* audit log the app's own DB role can rewrite is not evidence. A sink failure is
|
|
12
20
|
* logged loudly (a dropped record is never silent) but does not fail the request:
|
|
13
21
|
* audit outage must not become read outage.
|
|
14
22
|
*/
|
|
15
23
|
|
|
16
|
-
/**
|
|
24
|
+
/** Whether the access was allowed, refused (authz/validation), or faulted (system error). */
|
|
25
|
+
export type AuditOutcome = 'allow' | 'deny' | 'error';
|
|
26
|
+
|
|
27
|
+
/** What kind of principal made the call. */
|
|
28
|
+
export type PrincipalKind = 'user' | 'client' | 'system' | 'anonymous';
|
|
29
|
+
|
|
30
|
+
/** One recorded access of the non-GET surface. */
|
|
17
31
|
export interface AuditRecord {
|
|
18
32
|
/** ISO-8601 timestamp of the access. */
|
|
19
33
|
ts: string;
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
/** Request correlation id — the trace-id spine. Sourced from the CloudFront
|
|
35
|
+
* request id (`x-amz-cf-id`), which matches `x-edge-request-id` in the access log. */
|
|
36
|
+
requestId?: string;
|
|
37
|
+
// WHO
|
|
38
|
+
/** The principal id: the user `sub`, the client id, or null when anonymous. */
|
|
23
39
|
caller: string | null;
|
|
24
|
-
/**
|
|
40
|
+
/** Whether the principal is a user, a client (machine), system, or anonymous. */
|
|
41
|
+
principalKind: PrincipalKind;
|
|
42
|
+
/** The principal's role, when known (from the JWT claims). */
|
|
43
|
+
role?: string;
|
|
44
|
+
// WHAT
|
|
45
|
+
/** The action — `rpc:<fn>` or `rest:<table>`. */
|
|
46
|
+
action: string;
|
|
47
|
+
/** For REST: the table and the operation performed. */
|
|
48
|
+
resource?: { table: string; operation: 'insert' | 'update' | 'delete' | 'select' };
|
|
49
|
+
/** For REST writes/deletes: the WHERE predicate (query string) — ids, not values. */
|
|
50
|
+
filter?: string;
|
|
51
|
+
/** For writes: the column NAMES the body touched. Never values — values can be PII. */
|
|
52
|
+
columns?: string[];
|
|
53
|
+
// WHOM
|
|
54
|
+
/** The subject accessed — the id from the configured RPC subject param, or null. */
|
|
25
55
|
subject: string | null;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
56
|
+
// OUTCOME
|
|
57
|
+
/** allow (succeeded), deny (refused — authz/validation), or error (system fault). */
|
|
58
|
+
result: AuditOutcome;
|
|
59
|
+
// provenance
|
|
60
|
+
/** Stamped from AuditConfig for multi-app/stage trails. */
|
|
61
|
+
app?: string;
|
|
62
|
+
stage?: string;
|
|
30
63
|
}
|
|
31
64
|
|
|
32
|
-
/** Audit configuration.
|
|
65
|
+
/** Audit configuration. When a `sink` is set, every non-GET request is recorded. */
|
|
33
66
|
export interface AuditConfig {
|
|
34
67
|
/** Append-only sink for audit records. The app provides a WORM-backed writer
|
|
35
68
|
* (e.g. S3 Object-Lock). Awaited per request, so a well-behaved sink gives
|
|
36
69
|
* durability before the response; a throwing sink is logged, never fatal. */
|
|
37
70
|
sink: (record: AuditRecord) => void | Promise<void>;
|
|
38
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
71
|
+
/** Optional subject-extraction hints per RPC — which body param identifies the
|
|
72
|
+
* subject. NOT a gate: with a sink set, every non-GET request is audited; this
|
|
73
|
+
* only enriches the `subject` field for the SECDEF PII-read functions.
|
|
41
74
|
* Example: { get_user: { subject: 'p_user_id' } } */
|
|
42
75
|
rpc?: Record<string, { subject?: string }>;
|
|
76
|
+
/** Stamped on every record (multi-app / multi-stage trails). */
|
|
77
|
+
app?: string;
|
|
78
|
+
stage?: string;
|
|
43
79
|
}
|
|
44
80
|
|
|
45
81
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
82
|
+
* The request-scoped context threaded to RPC handlers so app code (e.g. job enqueue)
|
|
83
|
+
* can carry the trace-id spine and the verified actor one hop downstream. The actor is
|
|
84
|
+
* verified at the handler; a worker inherits it from the job envelope (it can't
|
|
85
|
+
* re-verify a queued job). See the spec's "self-sufficient record" rule.
|
|
49
86
|
*/
|
|
50
|
-
export
|
|
51
|
-
|
|
52
|
-
|
|
87
|
+
export interface RequestContext {
|
|
88
|
+
requestId: string;
|
|
89
|
+
actor: { kind: PrincipalKind; id: string | null; role?: string };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Resolve the principal (and role) from the verified user / identified client. */
|
|
93
|
+
export function resolvePrincipal(
|
|
53
94
|
user: Record<string, unknown> | null,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
95
|
+
client: Record<string, unknown> | null,
|
|
96
|
+
): { id: string | null; kind: PrincipalKind; role?: string } {
|
|
97
|
+
if (user && typeof user.sub === 'string') {
|
|
98
|
+
return {
|
|
99
|
+
id: user.sub,
|
|
100
|
+
kind: 'user',
|
|
101
|
+
...(typeof user.role === 'string' ? { role: user.role } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (client) {
|
|
105
|
+
// App-defined client object; by convention carries `id` (or `clientId`).
|
|
106
|
+
const raw = client.id ?? client.clientId;
|
|
107
|
+
return { id: raw == null ? null : String(raw), kind: 'client' };
|
|
108
|
+
}
|
|
109
|
+
return { id: null, kind: 'anonymous' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Inputs the dispatch layer hands to emitAudit. */
|
|
113
|
+
export interface AuditInput {
|
|
114
|
+
/** `rpc` (RPC dispatch) or `rest` (table write). */
|
|
115
|
+
kind: 'rpc' | 'rest';
|
|
116
|
+
/** The RPC function name, or the REST table name. */
|
|
117
|
+
name: string;
|
|
118
|
+
user: Record<string, unknown> | null;
|
|
119
|
+
client: Record<string, unknown> | null;
|
|
120
|
+
result: AuditOutcome;
|
|
121
|
+
requestId?: string;
|
|
122
|
+
/** RPC body — used to extract the subject via the AuditConfig hint. */
|
|
123
|
+
body?: Record<string, unknown>;
|
|
124
|
+
/** REST: the operation. */
|
|
125
|
+
operation?: 'insert' | 'update' | 'delete' | 'select';
|
|
126
|
+
/** REST: the WHERE predicate (query string). */
|
|
127
|
+
filter?: string;
|
|
128
|
+
/** REST writes: the column names the body touched. */
|
|
129
|
+
columns?: string[];
|
|
130
|
+
}
|
|
60
131
|
|
|
61
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Emit one audit record for a non-GET access. No-op when no sink is configured.
|
|
134
|
+
* Never throws — a sink failure is logged loudly (never silent) but must not break
|
|
135
|
+
* the request.
|
|
136
|
+
*/
|
|
137
|
+
export async function emitAudit(audit: AuditConfig | undefined, input: AuditInput): Promise<void> {
|
|
138
|
+
if (!audit?.sink) return;
|
|
139
|
+
|
|
140
|
+
const principal = resolvePrincipal(input.user, input.client);
|
|
141
|
+
|
|
142
|
+
// RPC subject from the configured hint; REST has no per-row subject here (the
|
|
143
|
+
// `filter` is the target descriptor).
|
|
144
|
+
let subject: string | null = null;
|
|
145
|
+
if (input.kind === 'rpc') {
|
|
146
|
+
const param = audit.rpc?.[input.name]?.subject;
|
|
147
|
+
const raw = param ? input.body?.[param] : undefined;
|
|
148
|
+
subject = raw == null ? null : String(raw);
|
|
149
|
+
}
|
|
62
150
|
|
|
63
151
|
const record: AuditRecord = {
|
|
64
152
|
ts: new Date().toISOString(),
|
|
65
|
-
action
|
|
66
|
-
caller:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
153
|
+
action: `${input.kind}:${input.name}`,
|
|
154
|
+
caller: principal.id,
|
|
155
|
+
principalKind: principal.kind,
|
|
156
|
+
subject,
|
|
157
|
+
result: input.result,
|
|
158
|
+
...(principal.role ? { role: principal.role } : {}),
|
|
159
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
160
|
+
...(input.kind === 'rest' && input.operation
|
|
161
|
+
? { resource: { table: input.name, operation: input.operation } }
|
|
162
|
+
: {}),
|
|
163
|
+
...(input.filter ? { filter: input.filter } : {}),
|
|
164
|
+
...(input.columns && input.columns.length ? { columns: input.columns } : {}),
|
|
165
|
+
...(audit.app ? { app: audit.app } : {}),
|
|
166
|
+
...(audit.stage ? { stage: audit.stage } : {}),
|
|
70
167
|
};
|
|
71
168
|
|
|
72
169
|
try {
|
package/src/handler/index.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, and, or, not, asc, desc, sql } from 'drizzle-orm';
|
|
11
|
-
import {
|
|
12
|
-
export type { AuditConfig, AuditRecord } from './audit';
|
|
11
|
+
import { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
|
|
12
|
+
export type { AuditConfig, AuditRecord, RequestContext, AuditOutcome, PrincipalKind } from './audit';
|
|
13
13
|
export { getPublicSurface, assertPublicSurface } from './public-surface';
|
|
14
14
|
export type { PublicSurface } from './public-surface';
|
|
15
15
|
|
|
@@ -123,9 +123,11 @@ export interface CacheConfig {
|
|
|
123
123
|
/** RPC handler — plain function (backward compatible).
|
|
124
124
|
* Plain handlers dispatch directly: no transaction, no pgSettings — they own
|
|
125
125
|
* their DB access through whatever they close over, same privilege model as
|
|
126
|
-
* SSR loaders. The optional 4th argument is only provided to handlers
|
|
127
|
-
* registered with `settings: true` (see RpcHandlerWithRole)
|
|
128
|
-
|
|
126
|
+
* SSR loaders. The optional 4th argument (`db`) is only provided to handlers
|
|
127
|
+
* registered with `settings: true` (see RpcHandlerWithRole); plain handlers
|
|
128
|
+
* receive `ctx` as the 4th argument instead. The final `ctx` carries the
|
|
129
|
+
* trace-id spine (requestId + verified actor) for downstream propagation. */
|
|
130
|
+
export type RpcHandler = (body: unknown, user?: Record<string, unknown> | null, client?: Record<string, unknown> | null, db?: DrizzleDb | RequestContext, ctx?: RequestContext) => Promise<unknown>;
|
|
129
131
|
|
|
130
132
|
/** RPC handler with per-function role requirement and/or RLS context opt-in */
|
|
131
133
|
export interface RpcHandlerWithRole {
|
|
@@ -189,9 +191,10 @@ export interface HandlerOptions {
|
|
|
189
191
|
/** When true, error messages hide internal details (table names, column names, operators).
|
|
190
192
|
* Enable in production to prevent schema enumeration. Default: false. */
|
|
191
193
|
hideSchemaErrors?: boolean;
|
|
192
|
-
/** Forensic audit logging
|
|
193
|
-
*
|
|
194
|
-
*
|
|
194
|
+
/** Forensic audit logging (distinct from observability). When a sink is set,
|
|
195
|
+
* records every non-GET request (writes + RPCs) — the query, not the response —
|
|
196
|
+
* to an app-provided append-only sink, stamped with the trace-id (`x-amz-cf-id`).
|
|
197
|
+
* GET is delegated to RLS. See AuditConfig. */
|
|
195
198
|
audit?: AuditConfig;
|
|
196
199
|
}
|
|
197
200
|
|
|
@@ -453,6 +456,13 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
453
456
|
const pathSegments = pathname.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
454
457
|
const method = request.method.toUpperCase();
|
|
455
458
|
|
|
459
|
+
// The trace-id spine. Source from the CloudFront request id (`x-amz-cf-id`,
|
|
460
|
+
// added to every origin request) so the handler audit joins straight to the
|
|
461
|
+
// access log's `x-edge-request-id`. Falls back to trace/request ids off-CDN.
|
|
462
|
+
const requestId = request.headers.get('x-amz-cf-id')
|
|
463
|
+
?? request.headers.get('x-amzn-trace-id')
|
|
464
|
+
?? request.headers.get('x-request-id') ?? undefined;
|
|
465
|
+
|
|
456
466
|
// Phase 1: Client *identification* (if configured) — which app is calling.
|
|
457
467
|
// NOT a trust boundary. Two paths:
|
|
458
468
|
// 1. Secret-based (native/server): X-Client-Id + X-Client-Secret — a real secret.
|
|
@@ -533,8 +543,11 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
533
543
|
|
|
534
544
|
const fn = isObjectForm ? entry.fn : entry;
|
|
535
545
|
const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
|
|
536
|
-
|
|
537
|
-
|
|
546
|
+
// Request-scoped context for the trace-id spine — RPC handlers receive it
|
|
547
|
+
// as a trailing arg (plain: 4th; settings: 5th, after tx) so app code can
|
|
548
|
+
// carry requestId + the verified actor one hop into jobs. Existing fns that
|
|
549
|
+
// ignore the extra arg are unaffected.
|
|
550
|
+
const ctx: RequestContext = { requestId: requestId ?? '', actor: resolvePrincipal(user, client) };
|
|
538
551
|
try {
|
|
539
552
|
// Plain RPCs dispatch directly — wrapping them in the settings
|
|
540
553
|
// transaction deadlocks single-connection pools (the fn's queries
|
|
@@ -545,25 +558,33 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
545
558
|
? await executeWithSettings(
|
|
546
559
|
db,
|
|
547
560
|
options?.pgSettings?.(user, client),
|
|
548
|
-
(tx) => fn(body, user, client, tx),
|
|
561
|
+
(tx) => fn(body, user, client, tx, ctx),
|
|
549
562
|
)
|
|
550
|
-
: await fn(body, user, client);
|
|
551
|
-
// Forensic audit:
|
|
552
|
-
await
|
|
563
|
+
: await fn(body, user, client, ctx);
|
|
564
|
+
// Forensic audit: every non-GET is recorded when a sink is configured.
|
|
565
|
+
await emitAudit(options?.audit, {
|
|
566
|
+
kind: 'rpc', name: fnName, user, client,
|
|
567
|
+
result: 'allow', requestId, body: body as Record<string, unknown>,
|
|
568
|
+
});
|
|
553
569
|
return json(result, 200);
|
|
554
570
|
} catch (error) {
|
|
555
|
-
// A denied/errored access is forensically relevant too — record it.
|
|
556
|
-
await emitRpcAudit(options?.audit, fnName, user, body as Record<string, unknown>, 'deny', auditReqId);
|
|
557
|
-
if (error instanceof HandlerError) {
|
|
558
|
-
return json({ message: error.message }, error.status);
|
|
559
|
-
}
|
|
560
|
-
const message = error instanceof Error ? error.message : 'RPC execution failed';
|
|
561
571
|
const pgCode = (error as any)?.code;
|
|
562
572
|
let status = 500;
|
|
563
573
|
if (pgCode === '28P01') status = 401;
|
|
564
574
|
else if (pgCode === '42501') status = 403;
|
|
565
575
|
else if (pgCode === '23505') status = 409;
|
|
566
576
|
else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
|
|
577
|
+
// Split the outcome: an authz/validation refusal (4xx) is `deny`; a
|
|
578
|
+
// system fault (5xx) is `error`. Conflating them blinds an investigation.
|
|
579
|
+
const refused = error instanceof HandlerError ? error.status < 500 : status < 500;
|
|
580
|
+
await emitAudit(options?.audit, {
|
|
581
|
+
kind: 'rpc', name: fnName, user, client,
|
|
582
|
+
result: refused ? 'deny' : 'error', requestId, body: body as Record<string, unknown>,
|
|
583
|
+
});
|
|
584
|
+
if (error instanceof HandlerError) {
|
|
585
|
+
return json({ message: error.message }, error.status);
|
|
586
|
+
}
|
|
587
|
+
const message = error instanceof Error ? error.message : 'RPC execution failed';
|
|
567
588
|
if (options?.hideSchemaErrors && status === 500) {
|
|
568
589
|
return json({ message: 'Internal Server Error' }, 500);
|
|
569
590
|
}
|
|
@@ -608,8 +629,24 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
608
629
|
const settings = options?.pgSettings?.(user, client);
|
|
609
630
|
const table = schema[tableName];
|
|
610
631
|
|
|
632
|
+
// Forensic audit of the non-GET table surface (writes). Capture the query, not
|
|
633
|
+
// the response: the operation, the WHERE predicate, and the body's column NAMES
|
|
634
|
+
// (never values — values can be PII). GET is delegated to RLS, never audited.
|
|
635
|
+
const isWrite = method === 'POST' || method === 'PATCH' || method === 'DELETE';
|
|
636
|
+
const auditOp: 'insert' | 'update' | 'delete' =
|
|
637
|
+
method === 'POST' ? 'insert' : method === 'PATCH' ? 'update' : 'delete';
|
|
638
|
+
const auditFilter = url.searchParams.toString() || undefined;
|
|
639
|
+
let auditColumns: string[] | undefined;
|
|
640
|
+
if (isWrite && options?.audit?.sink && method !== 'DELETE') {
|
|
641
|
+
// Cheap clone (writes are low-volume) so the handler still reads the body.
|
|
642
|
+
const cloned = await request.clone().json().catch(() => null);
|
|
643
|
+
if (cloned && typeof cloned === 'object' && !Array.isArray(cloned)) {
|
|
644
|
+
auditColumns = Object.keys(cloned as Record<string, unknown>);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
611
648
|
try {
|
|
612
|
-
|
|
649
|
+
const response = await executeWithSettings(db, settings, async (dbOrTx) => {
|
|
613
650
|
if (method === 'GET') {
|
|
614
651
|
const cacheControl = getCacheControl(options);
|
|
615
652
|
return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
|
|
@@ -629,7 +666,22 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
629
666
|
|
|
630
667
|
return json({ message: `Method ${method} not allowed` }, 405);
|
|
631
668
|
});
|
|
669
|
+
if (isWrite) {
|
|
670
|
+
await emitAudit(options?.audit, {
|
|
671
|
+
kind: 'rest', name: tableName, user, client,
|
|
672
|
+
result: response.status < 400 ? 'allow' : response.status < 500 ? 'deny' : 'error',
|
|
673
|
+
requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return response;
|
|
632
677
|
} catch (error) {
|
|
678
|
+
if (isWrite) {
|
|
679
|
+
await emitAudit(options?.audit, {
|
|
680
|
+
kind: 'rest', name: tableName, user, client,
|
|
681
|
+
result: isPermissionDenied(error) ? 'deny' : 'error',
|
|
682
|
+
requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
633
685
|
if (isPermissionDenied(error)) {
|
|
634
686
|
return sanitizeError('Permission denied', 403, options);
|
|
635
687
|
}
|