@everystack/api 0.2.6 → 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 +180 -0
- package/src/handler/index.ts +99 -22
- package/src/handler/public-surface.ts +74 -0
package/package.json
CHANGED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forensic audit logging at the dispatch choke point.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from observability. Observability answers "is the system healthy?" —
|
|
5
|
+
* counts, latency, errors. Audit answers "did principal X access subject Y's PII?",
|
|
6
|
+
* the question the platform must be able to answer after an incident. The handler is
|
|
7
|
+
* the only place the caller's identity, the action, and the params all meet, so the
|
|
8
|
+
* record is captured here and handed to an app-provided append-only sink.
|
|
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
|
+
*
|
|
18
|
+
* The sink should be append-only with integrity (e.g. S3 Object-Lock / WORM) — an
|
|
19
|
+
* audit log the app's own DB role can rewrite is not evidence. A sink failure is
|
|
20
|
+
* logged loudly (a dropped record is never silent) but does not fail the request:
|
|
21
|
+
* audit outage must not become read outage.
|
|
22
|
+
*/
|
|
23
|
+
|
|
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. */
|
|
31
|
+
export interface AuditRecord {
|
|
32
|
+
/** ISO-8601 timestamp of the access. */
|
|
33
|
+
ts: string;
|
|
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. */
|
|
39
|
+
caller: string | null;
|
|
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. */
|
|
55
|
+
subject: string | null;
|
|
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;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Audit configuration. When a `sink` is set, every non-GET request is recorded. */
|
|
66
|
+
export interface AuditConfig {
|
|
67
|
+
/** Append-only sink for audit records. The app provides a WORM-backed writer
|
|
68
|
+
* (e.g. S3 Object-Lock). Awaited per request, so a well-behaved sink gives
|
|
69
|
+
* durability before the response; a throwing sink is logged, never fatal. */
|
|
70
|
+
sink: (record: AuditRecord) => void | Promise<void>;
|
|
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.
|
|
74
|
+
* Example: { get_user: { subject: 'p_user_id' } } */
|
|
75
|
+
rpc?: Record<string, { subject?: string }>;
|
|
76
|
+
/** Stamped on every record (multi-app / multi-stage trails). */
|
|
77
|
+
app?: string;
|
|
78
|
+
stage?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
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.
|
|
86
|
+
*/
|
|
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(
|
|
94
|
+
user: Record<string, unknown> | null,
|
|
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
|
+
}
|
|
131
|
+
|
|
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
|
+
}
|
|
150
|
+
|
|
151
|
+
const record: AuditRecord = {
|
|
152
|
+
ts: new Date().toISOString(),
|
|
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 } : {}),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await audit.sink(record);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// A dropped audit record is a forensic gap — make it loud, never silent.
|
|
173
|
+
// But do not fail the request on an audit-sink outage (availability).
|
|
174
|
+
console.error(
|
|
175
|
+
`[everystack/api] audit sink failed; record dropped: `
|
|
176
|
+
+ `action=${record.action} caller=${record.caller} subject=${record.subject}`,
|
|
177
|
+
err,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/handler/index.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
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 { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
|
|
12
|
+
export type { AuditConfig, AuditRecord, RequestContext, AuditOutcome, PrincipalKind } from './audit';
|
|
13
|
+
export { getPublicSurface, assertPublicSurface } from './public-surface';
|
|
14
|
+
export type { PublicSurface } from './public-surface';
|
|
11
15
|
|
|
12
16
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
13
17
|
type DrizzleDb = any;
|
|
@@ -47,11 +51,19 @@ export interface AuthConfig {
|
|
|
47
51
|
publicRpc?: string[];
|
|
48
52
|
/** Called after successful authentication with the user payload */
|
|
49
53
|
onAuthenticated?: (user: Record<string, unknown>) => void;
|
|
50
|
-
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* -
|
|
54
|
-
*
|
|
54
|
+
/** Identify the calling app and return client metadata, or null to reject.
|
|
55
|
+
* Called with (clientId, clientSecret, referer); the callback picks the path:
|
|
56
|
+
* - Secret-based (native/server): clientId + clientSecret — a real shared secret.
|
|
57
|
+
* - Referer-based (web): referer/origin only — browsers can't hold a secret.
|
|
58
|
+
*
|
|
59
|
+
* SECURITY: this is *identification*, not a trust boundary. The referer/origin
|
|
60
|
+
* path is NOT authentication — Origin/Referer are trivially spoofable by any
|
|
61
|
+
* non-browser caller (curl, a script), so a referer-validated client is an
|
|
62
|
+
* UNTRUSTED public client and passing here proves nothing about the caller.
|
|
63
|
+
* Never gate sensitive data on client auth alone — real authorization is the
|
|
64
|
+
* user JWT + RLS (see verifyToken). Anything in `publicRpc` is reachable by the
|
|
65
|
+
* whole internet. (Origin is sound only as a CSRF signal on cookie auth — never
|
|
66
|
+
* as a data-access gate; the two threat models are different.) */
|
|
55
67
|
verifyClient?: (clientId: string, clientSecret: string, referer?: string) => Promise<Record<string, unknown> | null>;
|
|
56
68
|
/** Custom header names for client credentials.
|
|
57
69
|
* Defaults: { id: 'X-Client-Id', secret: 'X-Client-Secret' } */
|
|
@@ -111,9 +123,11 @@ export interface CacheConfig {
|
|
|
111
123
|
/** RPC handler — plain function (backward compatible).
|
|
112
124
|
* Plain handlers dispatch directly: no transaction, no pgSettings — they own
|
|
113
125
|
* their DB access through whatever they close over, same privilege model as
|
|
114
|
-
* SSR loaders. The optional 4th argument is only provided to handlers
|
|
115
|
-
* registered with `settings: true` (see RpcHandlerWithRole)
|
|
116
|
-
|
|
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>;
|
|
117
131
|
|
|
118
132
|
/** RPC handler with per-function role requirement and/or RLS context opt-in */
|
|
119
133
|
export interface RpcHandlerWithRole {
|
|
@@ -177,6 +191,11 @@ export interface HandlerOptions {
|
|
|
177
191
|
/** When true, error messages hide internal details (table names, column names, operators).
|
|
178
192
|
* Enable in production to prevent schema enumeration. Default: false. */
|
|
179
193
|
hideSchemaErrors?: boolean;
|
|
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. */
|
|
198
|
+
audit?: AuditConfig;
|
|
180
199
|
}
|
|
181
200
|
|
|
182
201
|
function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
|
|
@@ -437,13 +456,23 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
437
456
|
const pathSegments = pathname.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
438
457
|
const method = request.method.toUpperCase();
|
|
439
458
|
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
+
|
|
466
|
+
// Phase 1: Client *identification* (if configured) — which app is calling.
|
|
467
|
+
// NOT a trust boundary. Two paths:
|
|
468
|
+
// 1. Secret-based (native/server): X-Client-Id + X-Client-Secret — a real secret.
|
|
469
|
+
// 2. Referer/Origin (web): the browser can't hold a secret, so this path has none.
|
|
470
|
+
// Origin/Referer are spoofable by any non-browser caller, so a referer-only
|
|
471
|
+
// client is UNTRUSTED — passing here proves nothing about the caller.
|
|
472
|
+
// Real authorization is Phase 2 (JWT + role) + RLS; client auth must never be the
|
|
473
|
+
// sole gate for sensitive data. Skipped for publicRoutes methods (e.g. GET).
|
|
474
|
+
// publicRpc skips JWT verification only, NOT client identification — so a publicRpc
|
|
475
|
+
// backed by this alone is internet-reachable. Treat the publicRpc list as public.
|
|
447
476
|
let user: Record<string, unknown> | null = null;
|
|
448
477
|
let client: Record<string, unknown> | null = null;
|
|
449
478
|
const isPublicRoute = options?.auth?.publicRoutes?.includes(method) ?? false;
|
|
@@ -514,6 +543,11 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
514
543
|
|
|
515
544
|
const fn = isObjectForm ? entry.fn : entry;
|
|
516
545
|
const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
|
|
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) };
|
|
517
551
|
try {
|
|
518
552
|
// Plain RPCs dispatch directly — wrapping them in the settings
|
|
519
553
|
// transaction deadlocks single-connection pools (the fn's queries
|
|
@@ -524,21 +558,33 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
524
558
|
? await executeWithSettings(
|
|
525
559
|
db,
|
|
526
560
|
options?.pgSettings?.(user, client),
|
|
527
|
-
(tx) => fn(body, user, client, tx),
|
|
561
|
+
(tx) => fn(body, user, client, tx, ctx),
|
|
528
562
|
)
|
|
529
|
-
: await fn(body, user, client);
|
|
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
|
+
});
|
|
530
569
|
return json(result, 200);
|
|
531
570
|
} catch (error) {
|
|
532
|
-
if (error instanceof HandlerError) {
|
|
533
|
-
return json({ message: error.message }, error.status);
|
|
534
|
-
}
|
|
535
|
-
const message = error instanceof Error ? error.message : 'RPC execution failed';
|
|
536
571
|
const pgCode = (error as any)?.code;
|
|
537
572
|
let status = 500;
|
|
538
573
|
if (pgCode === '28P01') status = 401;
|
|
539
574
|
else if (pgCode === '42501') status = 403;
|
|
540
575
|
else if (pgCode === '23505') status = 409;
|
|
541
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';
|
|
542
588
|
if (options?.hideSchemaErrors && status === 500) {
|
|
543
589
|
return json({ message: 'Internal Server Error' }, 500);
|
|
544
590
|
}
|
|
@@ -583,8 +629,24 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
583
629
|
const settings = options?.pgSettings?.(user, client);
|
|
584
630
|
const table = schema[tableName];
|
|
585
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
|
+
|
|
586
648
|
try {
|
|
587
|
-
|
|
649
|
+
const response = await executeWithSettings(db, settings, async (dbOrTx) => {
|
|
588
650
|
if (method === 'GET') {
|
|
589
651
|
const cacheControl = getCacheControl(options);
|
|
590
652
|
return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
|
|
@@ -604,7 +666,22 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
604
666
|
|
|
605
667
|
return json({ message: `Method ${method} not allowed` }, 405);
|
|
606
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;
|
|
607
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
|
+
}
|
|
608
685
|
if (isPermissionDenied(error)) {
|
|
609
686
|
return sanitizeError('Permission denied', 403, options);
|
|
610
687
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-surface guard.
|
|
3
|
+
*
|
|
4
|
+
* `publicRoutes` (HTTP methods that skip auth) and `publicRpc` (RPC names that skip
|
|
5
|
+
* JWT) are the explicitly internet-reachable surface. Client auth is NOT a gate for
|
|
6
|
+
* them — Origin/Referer is spoofable, so anything here is reachable by the whole
|
|
7
|
+
* internet (see verifyClient). This guard makes that surface a signed-off registry:
|
|
8
|
+
* snapshot the approved set in a test and any silent addition fails it. Each entry
|
|
9
|
+
* is a deliberate "safe to hand the whole internet" decision, not an accident.
|
|
10
|
+
*
|
|
11
|
+
* import { assertPublicSurface } from '@everystack/api';
|
|
12
|
+
* it('public surface is signed off', () => {
|
|
13
|
+
* assertPublicSurface(handlerOptions, {
|
|
14
|
+
* publicRoutes: ['GET'],
|
|
15
|
+
* publicRpc: ['health', 'signup', 'signin'], // each reviewed as internet-safe
|
|
16
|
+
* });
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The explicitly internet-reachable surface. */
|
|
21
|
+
export interface PublicSurface {
|
|
22
|
+
/** HTTP methods served without authentication. */
|
|
23
|
+
publicRoutes: string[];
|
|
24
|
+
/** RPC function names served without a JWT. */
|
|
25
|
+
publicRpc: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Minimal structural view of the parts of HandlerOptions this reads. */
|
|
29
|
+
type PublicSurfaceSource = {
|
|
30
|
+
auth?: { publicRoutes?: string[]; publicRpc?: string[] };
|
|
31
|
+
} | undefined;
|
|
32
|
+
|
|
33
|
+
/** Extract the live public surface from handler options (sorted, deduped-by-source). */
|
|
34
|
+
export function getPublicSurface(options: PublicSurfaceSource): PublicSurface {
|
|
35
|
+
return {
|
|
36
|
+
publicRoutes: [...(options?.auth?.publicRoutes ?? [])].sort(),
|
|
37
|
+
publicRpc: [...(options?.auth?.publicRpc ?? [])].sort(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function diff(live: string[], approved: string[]): { added: string[]; removed: string[] } {
|
|
42
|
+
return {
|
|
43
|
+
added: live.filter(x => !approved.includes(x)),
|
|
44
|
+
removed: approved.filter(x => !live.includes(x)),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Assert the live public surface equals the approved set. Throws on ANY drift —
|
|
50
|
+
* an addition (new internet-reachable surface that wasn't signed off) or a removal
|
|
51
|
+
* (the approved list is stale). Use as a test gate so the attack surface can't grow
|
|
52
|
+
* silently.
|
|
53
|
+
*/
|
|
54
|
+
export function assertPublicSurface(options: PublicSurfaceSource, approved: PublicSurface): void {
|
|
55
|
+
const live = getPublicSurface(options);
|
|
56
|
+
const routes = diff(live.publicRoutes, [...approved.publicRoutes].sort());
|
|
57
|
+
const rpc = diff(live.publicRpc, [...approved.publicRpc].sort());
|
|
58
|
+
|
|
59
|
+
const problems: string[] = [];
|
|
60
|
+
if (routes.added.length) problems.push(`publicRoutes added (internet-reachable, unsigned): ${routes.added.join(', ')}`);
|
|
61
|
+
if (rpc.added.length) problems.push(`publicRpc added (internet-reachable, unsigned): ${rpc.added.join(', ')}`);
|
|
62
|
+
if (routes.removed.length) problems.push(`publicRoutes in the approved set but no longer live: ${routes.removed.join(', ')}`);
|
|
63
|
+
if (rpc.removed.length) problems.push(`publicRpc in the approved set but no longer live: ${rpc.removed.join(', ')}`);
|
|
64
|
+
|
|
65
|
+
if (problems.length) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'[everystack/api] public surface drift — the internet-reachable surface changed '
|
|
68
|
+
+ 'without sign-off:\n ' + problems.join('\n ')
|
|
69
|
+
+ '\nEvery publicRoutes/publicRpc entry is reachable by the whole internet with no '
|
|
70
|
+
+ 'JWT (client auth is not a gate). Update the approved set only after confirming '
|
|
71
|
+
+ 'each entry is safe to expose.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|