@striae-org/striae 5.0.0 → 5.1.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/.env.example +7 -3
- package/app/components/actions/case-export/download-handlers.ts +23 -7
- package/app/components/actions/case-manage.ts +24 -9
- package/app/components/actions/generate-pdf.ts +52 -4
- package/app/components/actions/image-manage.ts +48 -48
- package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
- package/app/routes/striae/striae.tsx +16 -4
- package/app/types/file.ts +18 -2
- package/app/utils/api/image-api-client.ts +49 -1
- package/app/utils/data/operations/case-operations.ts +13 -1
- package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
- package/app/utils/data/operations/file-annotation-operations.ts +13 -1
- package/functions/api/image/[[path]].ts +2 -1
- package/package.json +2 -2
- package/scripts/deploy-config.sh +191 -20
- package/scripts/deploy-pages-secrets.sh +0 -6
- package/scripts/deploy-worker-secrets.sh +67 -6
- package/worker-configuration.d.ts +15 -7
- package/workers/audit-worker/package.json +1 -4
- package/workers/audit-worker/src/audit-worker.example.ts +522 -61
- package/workers/audit-worker/wrangler.jsonc.example +5 -0
- package/workers/data-worker/package.json +1 -4
- package/workers/data-worker/src/data-worker.example.ts +280 -2
- package/workers/data-worker/src/encryption-utils.ts +145 -1
- package/workers/data-worker/wrangler.jsonc.example +3 -1
- package/workers/image-worker/package.json +1 -4
- package/workers/image-worker/src/encryption-utils.ts +217 -0
- package/workers/image-worker/src/image-worker.example.ts +449 -129
- package/workers/image-worker/worker-configuration.d.ts +3 -2
- package/workers/image-worker/wrangler.jsonc.example +7 -0
- package/workers/keys-worker/package.json +1 -4
- package/workers/pdf-worker/package.json +1 -4
- package/workers/user-worker/package.json +1 -4
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
interface Env {
|
|
2
2
|
R2_KEY_SECRET: string;
|
|
3
3
|
STRIAE_AUDIT: R2Bucket;
|
|
4
|
+
DATA_AT_REST_ENCRYPTION_ENABLED?: string;
|
|
5
|
+
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
|
|
6
|
+
DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
|
|
7
|
+
DATA_AT_REST_ENCRYPTION_KEY_ID?: string;
|
|
4
8
|
}
|
|
5
9
|
|
|
6
10
|
interface AuditEntry {
|
|
7
11
|
timestamp: string;
|
|
8
12
|
userId: string;
|
|
9
13
|
action: string;
|
|
10
|
-
// Optional metadata fields that can be included
|
|
11
14
|
[key: string]: unknown;
|
|
12
15
|
}
|
|
13
16
|
|
|
@@ -26,7 +29,15 @@ interface AuditRetrievalResponse {
|
|
|
26
29
|
total: number;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
type APIResponse = SuccessResponse | ErrorResponse | AuditRetrievalResponse
|
|
32
|
+
type APIResponse = SuccessResponse | ErrorResponse | AuditRetrievalResponse | Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
interface DataAtRestEnvelope {
|
|
35
|
+
algorithm: string;
|
|
36
|
+
encryptionVersion: string;
|
|
37
|
+
keyId: string;
|
|
38
|
+
dataIv: string;
|
|
39
|
+
wrappedKey: string;
|
|
40
|
+
}
|
|
30
41
|
|
|
31
42
|
const corsHeaders: Record<string, string> = {
|
|
32
43
|
'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
|
|
@@ -36,58 +47,513 @@ const corsHeaders: Record<string, string> = {
|
|
|
36
47
|
};
|
|
37
48
|
|
|
38
49
|
const createResponse = (data: APIResponse, status: number = 200): Response => new Response(
|
|
39
|
-
JSON.stringify(data),
|
|
50
|
+
JSON.stringify(data),
|
|
40
51
|
{ status, headers: corsHeaders }
|
|
41
52
|
);
|
|
42
53
|
|
|
43
|
-
const hasValidHeader = (request: Request, env: Env): boolean =>
|
|
44
|
-
request.headers.get(
|
|
54
|
+
const hasValidHeader = (request: Request, env: Env): boolean =>
|
|
55
|
+
request.headers.get('X-Custom-Auth-Key') === env.R2_KEY_SECRET;
|
|
56
|
+
|
|
57
|
+
const DATA_AT_REST_BACKFILL_PATH = '/api/admin/data-at-rest-backfill';
|
|
58
|
+
const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
|
|
59
|
+
const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
|
|
60
|
+
|
|
61
|
+
function isDataAtRestEncryptionEnabled(env: Env): boolean {
|
|
62
|
+
const value = env.DATA_AT_REST_ENCRYPTION_ENABLED;
|
|
63
|
+
if (!value) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const normalizedValue = value.trim().toLowerCase();
|
|
68
|
+
return normalizedValue === '1' || normalizedValue === 'true' || normalizedValue === 'yes' || normalizedValue === 'on';
|
|
69
|
+
}
|
|
45
70
|
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
|
71
|
+
function generateAuditFileName(userId: string): string {
|
|
72
|
+
const date = new Date().toISOString().split('T')[0];
|
|
49
73
|
return `audit-trails/${userId}/${date}.json`;
|
|
50
|
-
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isValidAuditEntry(entry: unknown): entry is AuditEntry {
|
|
77
|
+
const candidate = entry as Partial<AuditEntry> | null;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
typeof candidate === 'object' &&
|
|
81
|
+
candidate !== null &&
|
|
82
|
+
typeof candidate.timestamp === 'string' &&
|
|
83
|
+
typeof candidate.userId === 'string' &&
|
|
84
|
+
typeof candidate.action === 'string'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function base64UrlDecode(value: string): Uint8Array {
|
|
89
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
90
|
+
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
91
|
+
const decoded = atob(normalized + padding);
|
|
92
|
+
const bytes = new Uint8Array(decoded.length);
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < decoded.length; i += 1) {
|
|
95
|
+
bytes[i] = decoded.charCodeAt(i);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return bytes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function base64UrlEncode(value: Uint8Array): string {
|
|
102
|
+
let binary = '';
|
|
103
|
+
const chunkSize = 8192;
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < value.length; i += chunkSize) {
|
|
106
|
+
const chunk = value.subarray(i, Math.min(i + chunkSize, value.length));
|
|
107
|
+
for (let j = 0; j < chunk.length; j += 1) {
|
|
108
|
+
binary += String.fromCharCode(chunk[j]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return btoa(binary)
|
|
113
|
+
.replace(/\+/g, '-')
|
|
114
|
+
.replace(/\//g, '_')
|
|
115
|
+
.replace(/=+$/g, '');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
|
|
119
|
+
const normalizedKey = privateKey
|
|
120
|
+
.trim()
|
|
121
|
+
.replace(/^['"]|['"]$/g, '')
|
|
122
|
+
.replace(/\\n/g, '\n');
|
|
123
|
+
|
|
124
|
+
const pemBody = normalizedKey
|
|
125
|
+
.replace('-----BEGIN PRIVATE KEY-----', '')
|
|
126
|
+
.replace('-----END PRIVATE KEY-----', '')
|
|
127
|
+
.replace(/\s+/g, '');
|
|
128
|
+
|
|
129
|
+
if (!pemBody) {
|
|
130
|
+
throw new Error('Encryption private key is invalid');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const binary = atob(pemBody);
|
|
134
|
+
const bytes = new Uint8Array(binary.length);
|
|
135
|
+
|
|
136
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
137
|
+
bytes[index] = binary.charCodeAt(index);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return bytes.buffer;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseSpkiPublicKey(publicKey: string): ArrayBuffer {
|
|
144
|
+
const normalizedKey = publicKey
|
|
145
|
+
.trim()
|
|
146
|
+
.replace(/^['"]|['"]$/g, '')
|
|
147
|
+
.replace(/\\n/g, '\n');
|
|
148
|
+
|
|
149
|
+
const pemBody = normalizedKey
|
|
150
|
+
.replace('-----BEGIN PUBLIC KEY-----', '')
|
|
151
|
+
.replace('-----END PUBLIC KEY-----', '')
|
|
152
|
+
.replace(/\s+/g, '');
|
|
153
|
+
|
|
154
|
+
if (!pemBody) {
|
|
155
|
+
throw new Error('Encryption public key is invalid');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const binary = atob(pemBody);
|
|
159
|
+
const bytes = new Uint8Array(binary.length);
|
|
160
|
+
|
|
161
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
162
|
+
bytes[index] = binary.charCodeAt(index);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return bytes.buffer;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey> {
|
|
169
|
+
return crypto.subtle.importKey(
|
|
170
|
+
'pkcs8',
|
|
171
|
+
parsePkcs8PrivateKey(privateKeyPem),
|
|
172
|
+
{
|
|
173
|
+
name: 'RSA-OAEP',
|
|
174
|
+
hash: 'SHA-256'
|
|
175
|
+
},
|
|
176
|
+
false,
|
|
177
|
+
['decrypt']
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
|
|
182
|
+
return crypto.subtle.importKey(
|
|
183
|
+
'spki',
|
|
184
|
+
parseSpkiPublicKey(publicKeyPem),
|
|
185
|
+
{
|
|
186
|
+
name: 'RSA-OAEP',
|
|
187
|
+
hash: 'SHA-256'
|
|
188
|
+
},
|
|
189
|
+
false,
|
|
190
|
+
['encrypt']
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function createAesGcmKey(usages: KeyUsage[]): Promise<CryptoKey> {
|
|
195
|
+
return crypto.subtle.generateKey(
|
|
196
|
+
{
|
|
197
|
+
name: 'AES-GCM',
|
|
198
|
+
length: 256
|
|
199
|
+
},
|
|
200
|
+
true,
|
|
201
|
+
usages
|
|
202
|
+
) as Promise<CryptoKey>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function wrapAesKey(aesKey: CryptoKey, publicKeyPem: string): Promise<string> {
|
|
206
|
+
const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
|
|
207
|
+
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
|
|
208
|
+
const wrappedKey = await crypto.subtle.encrypt(
|
|
209
|
+
{ name: 'RSA-OAEP' },
|
|
210
|
+
rsaPublicKey,
|
|
211
|
+
rawAesKey as BufferSource
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return base64UrlEncode(new Uint8Array(wrappedKey));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function unwrapAesKey(wrappedKeyBase64: string, privateKeyPem: string): Promise<CryptoKey> {
|
|
218
|
+
const rsaPrivateKey = await importRsaOaepPrivateKey(privateKeyPem);
|
|
219
|
+
const wrappedKeyBytes = base64UrlDecode(wrappedKeyBase64);
|
|
220
|
+
|
|
221
|
+
const rawAesKey = await crypto.subtle.decrypt(
|
|
222
|
+
{ name: 'RSA-OAEP' },
|
|
223
|
+
rsaPrivateKey,
|
|
224
|
+
wrappedKeyBytes as BufferSource
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
return crypto.subtle.importKey(
|
|
228
|
+
'raw',
|
|
229
|
+
rawAesKey,
|
|
230
|
+
{ name: 'AES-GCM' },
|
|
231
|
+
false,
|
|
232
|
+
['decrypt']
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function decryptJsonFromStorage(
|
|
237
|
+
ciphertext: ArrayBuffer,
|
|
238
|
+
envelope: DataAtRestEnvelope,
|
|
239
|
+
privateKeyPem: string
|
|
240
|
+
): Promise<string> {
|
|
241
|
+
const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
|
|
242
|
+
const iv = base64UrlDecode(envelope.dataIv);
|
|
243
|
+
|
|
244
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
245
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
246
|
+
aesKey,
|
|
247
|
+
ciphertext as BufferSource
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return new TextDecoder().decode(plaintext);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function encryptJsonForStorage(
|
|
254
|
+
plaintextJson: string,
|
|
255
|
+
publicKeyPem: string,
|
|
256
|
+
keyId: string
|
|
257
|
+
): Promise<{ ciphertext: Uint8Array; envelope: DataAtRestEnvelope }> {
|
|
258
|
+
const aesKey = await createAesGcmKey(['encrypt', 'decrypt']);
|
|
259
|
+
const wrappedKey = await wrapAesKey(aesKey, publicKeyPem);
|
|
260
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
261
|
+
|
|
262
|
+
const plaintextBytes = new TextEncoder().encode(plaintextJson);
|
|
263
|
+
const encryptedBuffer = await crypto.subtle.encrypt(
|
|
264
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
265
|
+
aesKey,
|
|
266
|
+
plaintextBytes as BufferSource
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
ciphertext: new Uint8Array(encryptedBuffer),
|
|
271
|
+
envelope: {
|
|
272
|
+
algorithm: DATA_AT_REST_ENCRYPTION_ALGORITHM,
|
|
273
|
+
encryptionVersion: DATA_AT_REST_ENCRYPTION_VERSION,
|
|
274
|
+
keyId,
|
|
275
|
+
dataIv: base64UrlEncode(iv),
|
|
276
|
+
wrappedKey
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function extractDataAtRestEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
|
|
282
|
+
const metadata = file.customMetadata;
|
|
283
|
+
if (!metadata) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const {
|
|
288
|
+
algorithm,
|
|
289
|
+
encryptionVersion,
|
|
290
|
+
keyId,
|
|
291
|
+
dataIv,
|
|
292
|
+
wrappedKey
|
|
293
|
+
} = metadata;
|
|
51
294
|
|
|
52
|
-
|
|
53
|
-
|
|
295
|
+
if (
|
|
296
|
+
typeof algorithm !== 'string' ||
|
|
297
|
+
typeof encryptionVersion !== 'string' ||
|
|
298
|
+
typeof keyId !== 'string' ||
|
|
299
|
+
typeof dataIv !== 'string' ||
|
|
300
|
+
typeof wrappedKey !== 'string'
|
|
301
|
+
) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
algorithm,
|
|
307
|
+
encryptionVersion,
|
|
308
|
+
keyId,
|
|
309
|
+
dataIv,
|
|
310
|
+
wrappedKey
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function hasDataAtRestMetadata(metadata: Record<string, string> | undefined): boolean {
|
|
315
|
+
if (!metadata) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
typeof metadata.algorithm === 'string' &&
|
|
321
|
+
typeof metadata.encryptionVersion === 'string' &&
|
|
322
|
+
typeof metadata.keyId === 'string' &&
|
|
323
|
+
typeof metadata.dataIv === 'string' &&
|
|
324
|
+
typeof metadata.wrappedKey === 'string'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function clampBackfillBatchSize(size: number | undefined): number {
|
|
329
|
+
if (typeof size !== 'number' || !Number.isFinite(size)) {
|
|
330
|
+
return 100;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const normalized = Math.floor(size);
|
|
334
|
+
if (normalized < 1) {
|
|
335
|
+
return 1;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (normalized > 1000) {
|
|
339
|
+
return 1000;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return normalized;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function readAuditEntriesFromObject(file: R2ObjectBody, env: Env): Promise<AuditEntry[]> {
|
|
346
|
+
const atRestEnvelope = extractDataAtRestEnvelope(file);
|
|
347
|
+
if (!atRestEnvelope) {
|
|
348
|
+
const fileText = await file.text();
|
|
349
|
+
return JSON.parse(fileText) as AuditEntry[];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (atRestEnvelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
|
|
353
|
+
throw new Error('Unsupported data-at-rest encryption algorithm');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (atRestEnvelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
|
|
357
|
+
throw new Error('Unsupported data-at-rest encryption version');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY) {
|
|
361
|
+
throw new Error('Data-at-rest decryption is not configured on this server');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const encryptedData = await file.arrayBuffer();
|
|
365
|
+
const plaintext = await decryptJsonFromStorage(
|
|
366
|
+
encryptedData,
|
|
367
|
+
atRestEnvelope,
|
|
368
|
+
env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
return JSON.parse(plaintext) as AuditEntry[];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function writeAuditEntriesToObject(
|
|
375
|
+
bucket: R2Bucket,
|
|
376
|
+
filename: string,
|
|
377
|
+
entries: AuditEntry[],
|
|
378
|
+
env: Env
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
const serializedData = JSON.stringify(entries);
|
|
381
|
+
|
|
382
|
+
if (!isDataAtRestEncryptionEnabled(env)) {
|
|
383
|
+
await bucket.put(filename, serializedData);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
|
|
388
|
+
throw new Error('Data-at-rest encryption is enabled but not fully configured');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const encryptedPayload = await encryptJsonForStorage(
|
|
392
|
+
serializedData,
|
|
393
|
+
env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
|
|
394
|
+
env.DATA_AT_REST_ENCRYPTION_KEY_ID
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
await bucket.put(filename, encryptedPayload.ciphertext, {
|
|
398
|
+
customMetadata: {
|
|
399
|
+
algorithm: encryptedPayload.envelope.algorithm,
|
|
400
|
+
encryptionVersion: encryptedPayload.envelope.encryptionVersion,
|
|
401
|
+
keyId: encryptedPayload.envelope.keyId,
|
|
402
|
+
dataIv: encryptedPayload.envelope.dataIv,
|
|
403
|
+
wrappedKey: encryptedPayload.envelope.wrappedKey
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function appendAuditEntry(
|
|
409
|
+
bucket: R2Bucket,
|
|
410
|
+
filename: string,
|
|
411
|
+
newEntry: AuditEntry,
|
|
412
|
+
env: Env
|
|
413
|
+
): Promise<number> {
|
|
54
414
|
try {
|
|
55
415
|
const existingFile = await bucket.get(filename);
|
|
56
416
|
let entries: AuditEntry[] = [];
|
|
57
|
-
|
|
417
|
+
|
|
58
418
|
if (existingFile) {
|
|
59
|
-
|
|
60
|
-
entries = JSON.parse(existingData);
|
|
419
|
+
entries = await readAuditEntriesFromObject(existingFile, env);
|
|
61
420
|
}
|
|
62
|
-
|
|
421
|
+
|
|
63
422
|
entries.push(newEntry);
|
|
64
|
-
await bucket
|
|
423
|
+
await writeAuditEntriesToObject(bucket, filename, entries, env);
|
|
65
424
|
return entries.length;
|
|
66
425
|
} catch (error) {
|
|
67
426
|
console.error('Error appending audit entry:', error);
|
|
68
427
|
throw error;
|
|
69
428
|
}
|
|
70
|
-
}
|
|
429
|
+
}
|
|
71
430
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
431
|
+
async function handleDataAtRestBackfill(request: Request, env: Env): Promise<Response> {
|
|
432
|
+
if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
|
|
433
|
+
return createResponse(
|
|
434
|
+
{ error: 'Data-at-rest encryption is not configured for backfill writes' },
|
|
435
|
+
400
|
|
436
|
+
);
|
|
437
|
+
}
|
|
75
438
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
439
|
+
const requestBody = await request.json().catch(() => ({})) as {
|
|
440
|
+
dryRun?: boolean;
|
|
441
|
+
prefix?: string;
|
|
442
|
+
cursor?: string;
|
|
443
|
+
batchSize?: number;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const dryRun = requestBody.dryRun === true;
|
|
447
|
+
const prefix = typeof requestBody.prefix === 'string' ? requestBody.prefix : '';
|
|
448
|
+
const cursor = typeof requestBody.cursor === 'string' && requestBody.cursor.length > 0
|
|
449
|
+
? requestBody.cursor
|
|
450
|
+
: undefined;
|
|
451
|
+
const batchSize = clampBackfillBatchSize(requestBody.batchSize);
|
|
452
|
+
|
|
453
|
+
const bucket = env.STRIAE_AUDIT;
|
|
454
|
+
const listed = await bucket.list({
|
|
455
|
+
prefix: prefix.length > 0 ? prefix : undefined,
|
|
456
|
+
cursor,
|
|
457
|
+
limit: batchSize
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
let scanned = 0;
|
|
461
|
+
let eligible = 0;
|
|
462
|
+
let encrypted = 0;
|
|
463
|
+
let skippedEncrypted = 0;
|
|
464
|
+
let skippedNonJson = 0;
|
|
465
|
+
let failed = 0;
|
|
466
|
+
const failures: Array<{ key: string; error: string }> = [];
|
|
467
|
+
|
|
468
|
+
for (const object of listed.objects) {
|
|
469
|
+
scanned += 1;
|
|
470
|
+
const key = object.key;
|
|
471
|
+
|
|
472
|
+
if (!key.endsWith('.json')) {
|
|
473
|
+
skippedNonJson += 1;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const objectHead = await bucket.head(key);
|
|
478
|
+
if (!objectHead) {
|
|
479
|
+
failed += 1;
|
|
480
|
+
if (failures.length < 20) {
|
|
481
|
+
failures.push({ key, error: 'Object not found during metadata check' });
|
|
482
|
+
}
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (hasDataAtRestMetadata(objectHead.customMetadata)) {
|
|
487
|
+
skippedEncrypted += 1;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
eligible += 1;
|
|
492
|
+
|
|
493
|
+
if (dryRun) {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
const existingObject = await bucket.get(key);
|
|
499
|
+
if (!existingObject) {
|
|
500
|
+
failed += 1;
|
|
501
|
+
if (failures.length < 20) {
|
|
502
|
+
failures.push({ key, error: 'Object disappeared before processing' });
|
|
503
|
+
}
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const plaintext = await existingObject.text();
|
|
508
|
+
const encryptedPayload = await encryptJsonForStorage(
|
|
509
|
+
plaintext,
|
|
510
|
+
env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
|
|
511
|
+
env.DATA_AT_REST_ENCRYPTION_KEY_ID
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
await bucket.put(key, encryptedPayload.ciphertext, {
|
|
515
|
+
customMetadata: {
|
|
516
|
+
algorithm: encryptedPayload.envelope.algorithm,
|
|
517
|
+
encryptionVersion: encryptedPayload.envelope.encryptionVersion,
|
|
518
|
+
keyId: encryptedPayload.envelope.keyId,
|
|
519
|
+
dataIv: encryptedPayload.envelope.dataIv,
|
|
520
|
+
wrappedKey: encryptedPayload.envelope.wrappedKey
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
encrypted += 1;
|
|
525
|
+
} catch (error) {
|
|
526
|
+
failed += 1;
|
|
527
|
+
if (failures.length < 20) {
|
|
528
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown backfill failure';
|
|
529
|
+
failures.push({ key, error: errorMessage });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return createResponse({
|
|
535
|
+
success: failed === 0,
|
|
536
|
+
dryRun,
|
|
537
|
+
prefix: prefix.length > 0 ? prefix : null,
|
|
538
|
+
batchSize,
|
|
539
|
+
scanned,
|
|
540
|
+
eligible,
|
|
541
|
+
encrypted,
|
|
542
|
+
skippedEncrypted,
|
|
543
|
+
skippedNonJson,
|
|
544
|
+
failed,
|
|
545
|
+
failures,
|
|
546
|
+
hasMore: listed.truncated,
|
|
547
|
+
nextCursor: listed.truncated ? listed.cursor : null
|
|
548
|
+
});
|
|
549
|
+
}
|
|
84
550
|
|
|
85
551
|
export default {
|
|
86
|
-
async fetch(request: Request, env: Env): Promise<Response> {
|
|
552
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
87
553
|
if (request.method === 'OPTIONS') {
|
|
88
554
|
return new Response(null, { headers: corsHeaders });
|
|
89
555
|
}
|
|
90
|
-
|
|
556
|
+
|
|
91
557
|
if (!hasValidHeader(request, env)) {
|
|
92
558
|
return createResponse({ error: 'Forbidden' }, 403);
|
|
93
559
|
}
|
|
@@ -97,7 +563,10 @@ export default {
|
|
|
97
563
|
const pathname = url.pathname;
|
|
98
564
|
const bucket = env.STRIAE_AUDIT;
|
|
99
565
|
|
|
100
|
-
|
|
566
|
+
if (request.method === 'POST' && pathname === DATA_AT_REST_BACKFILL_PATH) {
|
|
567
|
+
return await handleDataAtRestBackfill(request, env);
|
|
568
|
+
}
|
|
569
|
+
|
|
101
570
|
if (!pathname.startsWith('/audit/')) {
|
|
102
571
|
return createResponse({ error: 'This worker only handles audit endpoints. Use /audit/ path.' }, 404);
|
|
103
572
|
}
|
|
@@ -105,77 +574,69 @@ export default {
|
|
|
105
574
|
const userId = url.searchParams.get('userId');
|
|
106
575
|
const startDate = url.searchParams.get('startDate');
|
|
107
576
|
const endDate = url.searchParams.get('endDate');
|
|
108
|
-
|
|
577
|
+
|
|
109
578
|
if (request.method === 'POST') {
|
|
110
|
-
// Add audit entry
|
|
111
579
|
if (!userId) {
|
|
112
580
|
return createResponse({ error: 'userId parameter is required' }, 400);
|
|
113
581
|
}
|
|
114
|
-
|
|
582
|
+
|
|
115
583
|
const auditEntry: unknown = await request.json();
|
|
116
|
-
|
|
117
|
-
// Validate audit entry structure using type guard
|
|
584
|
+
|
|
118
585
|
if (!isValidAuditEntry(auditEntry)) {
|
|
119
586
|
return createResponse({ error: 'Invalid audit entry structure. Required fields: timestamp, userId, action' }, 400);
|
|
120
587
|
}
|
|
121
|
-
|
|
588
|
+
|
|
122
589
|
const filename = generateAuditFileName(userId);
|
|
123
|
-
|
|
590
|
+
|
|
124
591
|
try {
|
|
125
|
-
const entryCount = await appendAuditEntry(bucket, filename, auditEntry);
|
|
126
|
-
return createResponse({
|
|
127
|
-
success: true,
|
|
592
|
+
const entryCount = await appendAuditEntry(bucket, filename, auditEntry, env);
|
|
593
|
+
return createResponse({
|
|
594
|
+
success: true,
|
|
128
595
|
entryCount,
|
|
129
|
-
filename
|
|
596
|
+
filename
|
|
130
597
|
});
|
|
131
598
|
} catch (error) {
|
|
132
599
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
133
600
|
return createResponse({ error: `Failed to store audit entry: ${errorMessage}` }, 500);
|
|
134
601
|
}
|
|
135
602
|
}
|
|
136
|
-
|
|
603
|
+
|
|
137
604
|
if (request.method === 'GET') {
|
|
138
|
-
// Retrieve audit entries
|
|
139
605
|
if (!userId) {
|
|
140
606
|
return createResponse({ error: 'userId parameter is required' }, 400);
|
|
141
607
|
}
|
|
142
|
-
|
|
608
|
+
|
|
143
609
|
try {
|
|
144
610
|
let allEntries: AuditEntry[] = [];
|
|
145
|
-
|
|
611
|
+
|
|
146
612
|
if (startDate && endDate) {
|
|
147
|
-
// Get entries for date range
|
|
148
613
|
const start = new Date(startDate);
|
|
149
614
|
const end = new Date(endDate);
|
|
150
615
|
const currentDate = new Date(start);
|
|
151
|
-
|
|
616
|
+
|
|
152
617
|
while (currentDate <= end) {
|
|
153
618
|
const dateStr = currentDate.toISOString().split('T')[0];
|
|
154
619
|
const filename = `audit-trails/${userId}/${dateStr}.json`;
|
|
155
620
|
const file = await bucket.get(filename);
|
|
156
|
-
|
|
621
|
+
|
|
157
622
|
if (file) {
|
|
158
|
-
const
|
|
159
|
-
const entries: AuditEntry[] = JSON.parse(fileText);
|
|
623
|
+
const entries = await readAuditEntriesFromObject(file, env);
|
|
160
624
|
allEntries.push(...entries);
|
|
161
625
|
}
|
|
162
|
-
|
|
626
|
+
|
|
163
627
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
164
628
|
}
|
|
165
629
|
} else {
|
|
166
|
-
// Get today's entries
|
|
167
630
|
const filename = generateAuditFileName(userId);
|
|
168
631
|
const file = await bucket.get(filename);
|
|
169
|
-
|
|
632
|
+
|
|
170
633
|
if (file) {
|
|
171
|
-
|
|
172
|
-
allEntries = JSON.parse(fileText);
|
|
634
|
+
allEntries = await readAuditEntriesFromObject(file, env);
|
|
173
635
|
}
|
|
174
636
|
}
|
|
175
|
-
|
|
176
|
-
// Sort by timestamp (newest first)
|
|
637
|
+
|
|
177
638
|
allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
178
|
-
|
|
639
|
+
|
|
179
640
|
return createResponse({
|
|
180
641
|
entries: allEntries,
|
|
181
642
|
total: allEntries.length
|
|
@@ -185,7 +646,7 @@ export default {
|
|
|
185
646
|
return createResponse({ error: `Failed to retrieve audit entries: ${errorMessage}` }, 500);
|
|
186
647
|
}
|
|
187
648
|
}
|
|
188
|
-
|
|
649
|
+
|
|
189
650
|
return createResponse({ error: 'Method not allowed for audit endpoints. Only GET and POST are supported.' }, 405);
|
|
190
651
|
|
|
191
652
|
} catch (error) {
|
|
@@ -194,4 +655,4 @@ export default {
|
|
|
194
655
|
return createResponse({ error: errorMessage }, 500);
|
|
195
656
|
}
|
|
196
657
|
}
|
|
197
|
-
};
|
|
658
|
+
};
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
+
// Required secrets: R2_KEY_SECRET
|
|
3
|
+
// Optional data-at-rest secrets/vars:
|
|
4
|
+
// - DATA_AT_REST_ENCRYPTION_ENABLED=true
|
|
5
|
+
// - DATA_AT_REST_ENCRYPTION_PRIVATE_KEY (required for decrypting encrypted records)
|
|
6
|
+
// - DATA_AT_REST_ENCRYPTION_PUBLIC_KEY and DATA_AT_REST_ENCRYPTION_KEY_ID (required when encrypt-on-write is enabled)
|
|
2
7
|
"name": "AUDIT_WORKER_NAME",
|
|
3
8
|
"account_id": "ACCOUNT_ID",
|
|
4
9
|
"main": "src/audit-worker.ts",
|