@everystack/api 0.2.6 → 0.2.7
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 +83 -0
- package/src/handler/index.ts +37 -12
- package/src/handler/public-surface.ts +74 -0
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
* The sink should be append-only with integrity (e.g. S3 Object-Lock / WORM) — an
|
|
11
|
+
* audit log the app's own DB role can rewrite is not evidence. A sink failure is
|
|
12
|
+
* logged loudly (a dropped record is never silent) but does not fail the request:
|
|
13
|
+
* audit outage must not become read outage.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One recorded access of a sensitive surface. */
|
|
17
|
+
export interface AuditRecord {
|
|
18
|
+
/** ISO-8601 timestamp of the access. */
|
|
19
|
+
ts: string;
|
|
20
|
+
/** The action — the RPC function name. */
|
|
21
|
+
action: string;
|
|
22
|
+
/** The authenticated caller (user `sub`), or null if unauthenticated. */
|
|
23
|
+
caller: string | null;
|
|
24
|
+
/** The subject accessed — the id from the configured subject param, or null. */
|
|
25
|
+
subject: string | null;
|
|
26
|
+
/** Whether the call succeeded ('allow') or errored/was denied ('deny'). */
|
|
27
|
+
result: 'allow' | 'deny';
|
|
28
|
+
/** Optional request correlation id. */
|
|
29
|
+
requestId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Audit configuration. Records are emitted only for the RPCs listed in `rpc`. */
|
|
33
|
+
export interface AuditConfig {
|
|
34
|
+
/** Append-only sink for audit records. The app provides a WORM-backed writer
|
|
35
|
+
* (e.g. S3 Object-Lock). Awaited per request, so a well-behaved sink gives
|
|
36
|
+
* durability before the response; a throwing sink is logged, never fatal. */
|
|
37
|
+
sink: (record: AuditRecord) => void | Promise<void>;
|
|
38
|
+
/** Which RPC functions are sensitive, and how to derive the subject id from the
|
|
39
|
+
* request body. The surfaces that bypass RLS (SECDEF PII reads: get_user,
|
|
40
|
+
* export_user_data, …) belong here by default.
|
|
41
|
+
* Example: { get_user: { subject: 'p_user_id' } } */
|
|
42
|
+
rpc?: Record<string, { subject?: string }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Emit an audit record for a sensitive RPC, if one is configured for `action`.
|
|
47
|
+
* No-op when the function isn't audited. Never throws — a sink failure is logged
|
|
48
|
+
* loudly (never silent) but must not break the request.
|
|
49
|
+
*/
|
|
50
|
+
export async function emitRpcAudit(
|
|
51
|
+
audit: AuditConfig | undefined,
|
|
52
|
+
action: string,
|
|
53
|
+
user: Record<string, unknown> | null,
|
|
54
|
+
body: Record<string, unknown> | undefined,
|
|
55
|
+
result: 'allow' | 'deny',
|
|
56
|
+
requestId?: string,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const entry = audit?.rpc?.[action];
|
|
59
|
+
if (!audit || !entry) return;
|
|
60
|
+
|
|
61
|
+
const rawSubject = entry.subject ? body?.[entry.subject] : undefined;
|
|
62
|
+
|
|
63
|
+
const record: AuditRecord = {
|
|
64
|
+
ts: new Date().toISOString(),
|
|
65
|
+
action,
|
|
66
|
+
caller: typeof user?.sub === 'string' ? user.sub : null,
|
|
67
|
+
subject: rawSubject == null ? null : String(rawSubject),
|
|
68
|
+
result,
|
|
69
|
+
...(requestId ? { requestId } : {}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await audit.sink(record);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// A dropped audit record is a forensic gap — make it loud, never silent.
|
|
76
|
+
// But do not fail the request on an audit-sink outage (availability).
|
|
77
|
+
console.error(
|
|
78
|
+
`[everystack/api] audit sink failed; record dropped: `
|
|
79
|
+
+ `action=${record.action} caller=${record.caller} subject=${record.subject}`,
|
|
80
|
+
err,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
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 { emitRpcAudit, type AuditConfig } from './audit';
|
|
12
|
+
export type { AuditConfig, AuditRecord } 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' } */
|
|
@@ -177,6 +189,10 @@ export interface HandlerOptions {
|
|
|
177
189
|
/** When true, error messages hide internal details (table names, column names, operators).
|
|
178
190
|
* Enable in production to prevent schema enumeration. Default: false. */
|
|
179
191
|
hideSchemaErrors?: boolean;
|
|
192
|
+
/** Forensic audit logging for sensitive RPCs (distinct from observability).
|
|
193
|
+
* Records (caller, action, subject, result, ts) to an app-provided append-only
|
|
194
|
+
* sink for the listed functions — the surfaces that bypass RLS. See AuditConfig. */
|
|
195
|
+
audit?: AuditConfig;
|
|
180
196
|
}
|
|
181
197
|
|
|
182
198
|
function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
|
|
@@ -437,13 +453,16 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
437
453
|
const pathSegments = pathname.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
438
454
|
const method = request.method.toUpperCase();
|
|
439
455
|
|
|
440
|
-
// Phase 1: Client
|
|
441
|
-
//
|
|
442
|
-
// 1.
|
|
443
|
-
// 2. Referer
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
456
|
+
// Phase 1: Client *identification* (if configured) — which app is calling.
|
|
457
|
+
// NOT a trust boundary. Two paths:
|
|
458
|
+
// 1. Secret-based (native/server): X-Client-Id + X-Client-Secret — a real secret.
|
|
459
|
+
// 2. Referer/Origin (web): the browser can't hold a secret, so this path has none.
|
|
460
|
+
// Origin/Referer are spoofable by any non-browser caller, so a referer-only
|
|
461
|
+
// client is UNTRUSTED — passing here proves nothing about the caller.
|
|
462
|
+
// Real authorization is Phase 2 (JWT + role) + RLS; client auth must never be the
|
|
463
|
+
// sole gate for sensitive data. Skipped for publicRoutes methods (e.g. GET).
|
|
464
|
+
// publicRpc skips JWT verification only, NOT client identification — so a publicRpc
|
|
465
|
+
// backed by this alone is internet-reachable. Treat the publicRpc list as public.
|
|
447
466
|
let user: Record<string, unknown> | null = null;
|
|
448
467
|
let client: Record<string, unknown> | null = null;
|
|
449
468
|
const isPublicRoute = options?.auth?.publicRoutes?.includes(method) ?? false;
|
|
@@ -514,6 +533,8 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
514
533
|
|
|
515
534
|
const fn = isObjectForm ? entry.fn : entry;
|
|
516
535
|
const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
|
|
536
|
+
const auditReqId = request.headers.get('x-amzn-trace-id')
|
|
537
|
+
?? request.headers.get('x-request-id') ?? undefined;
|
|
517
538
|
try {
|
|
518
539
|
// Plain RPCs dispatch directly — wrapping them in the settings
|
|
519
540
|
// transaction deadlocks single-connection pools (the fn's queries
|
|
@@ -527,8 +548,12 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
527
548
|
(tx) => fn(body, user, client, tx),
|
|
528
549
|
)
|
|
529
550
|
: await fn(body, user, client);
|
|
551
|
+
// Forensic audit: record access of a sensitive surface (no-op unless configured).
|
|
552
|
+
await emitRpcAudit(options?.audit, fnName, user, body as Record<string, unknown>, 'allow', auditReqId);
|
|
530
553
|
return json(result, 200);
|
|
531
554
|
} 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);
|
|
532
557
|
if (error instanceof HandlerError) {
|
|
533
558
|
return json({ message: error.message }, error.status);
|
|
534
559
|
}
|
|
@@ -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
|
+
}
|