@stamhoofd/backend 2.74.0 → 2.75.0

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.
Files changed (47) hide show
  1. package/index.ts +7 -2
  2. package/package.json +13 -13
  3. package/src/crons/update-cached-balances.ts +1 -2
  4. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +2 -2
  5. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -15
  6. package/src/endpoints/global/audit-logs/GetAuditLogsEndpoint.ts +2 -2
  7. package/src/endpoints/global/events/GetEventNotificationsCountEndpoint.ts +43 -0
  8. package/src/endpoints/global/events/GetEventNotificationsEndpoint.ts +181 -0
  9. package/src/endpoints/global/events/GetEventsEndpoint.ts +2 -2
  10. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.ts +288 -0
  11. package/src/endpoints/global/events/PatchEventsEndpoint.ts +2 -2
  12. package/src/endpoints/global/files/UploadFile.ts +56 -4
  13. package/src/endpoints/global/files/UploadImage.ts +9 -3
  14. package/src/endpoints/global/members/GetMembersEndpoint.ts +2 -2
  15. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +10 -1
  16. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +1 -5
  17. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +7 -0
  18. package/src/endpoints/global/registration/GetUserDocumentsEndpoint.ts +1 -1
  19. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +1756 -164
  20. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +2 -2
  21. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +48 -2
  22. package/src/endpoints/organization/dashboard/documents/GetDocumentsEndpoint.ts +2 -2
  23. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  24. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +2 -2
  25. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +2 -2
  26. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +8 -0
  27. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +3 -3
  28. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +2 -2
  29. package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +2 -2
  30. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -2
  31. package/src/helpers/AdminPermissionChecker.ts +80 -2
  32. package/src/helpers/AuthenticatedStructures.ts +88 -2
  33. package/src/helpers/FlagMomentCleanup.ts +1 -8
  34. package/src/helpers/GlobalHelper.ts +15 -0
  35. package/src/helpers/MembershipCharger.ts +2 -1
  36. package/src/services/EventNotificationService.ts +201 -0
  37. package/src/services/FileSignService.ts +227 -0
  38. package/src/sql-filters/event-notifications.ts +39 -0
  39. package/src/sql-filters/organizations.ts +1 -1
  40. package/src/sql-sorters/event-notifications.ts +96 -0
  41. package/src/sql-sorters/events.ts +2 -2
  42. package/src/sql-sorters/organizations.ts +2 -2
  43. package/tests/e2e/private-files.test.ts +497 -0
  44. package/tests/e2e/register.test.ts +762 -0
  45. package/tests/helpers/TestServer.ts +3 -0
  46. package/tests/jest.setup.ts +15 -2
  47. package/tsconfig.json +1 -0
@@ -0,0 +1,497 @@
1
+ import { File, MemberDetails, MemberWithRegistrationsBlob, RecordCategory, RecordFileAnswer, RecordSettings, RecordType, Version } from '@stamhoofd/structures';
2
+
3
+ import { PatchableArray, PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
4
+ import { Request } from '@simonbackx/simple-endpoints';
5
+ import { Member, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
6
+ import { PermissionLevel, Permissions } from '@stamhoofd/structures';
7
+ import { PatchUserMembersEndpoint } from '../../src/endpoints/global/registration/PatchUserMembersEndpoint';
8
+ import { testServer } from '../helpers/TestServer';
9
+ import { GetUserMembersEndpoint } from '../../src/endpoints/global/registration/GetUserMembersEndpoint';
10
+
11
+ const baseUrl = `/v${Version}/members`;
12
+ const endpoint = new PatchUserMembersEndpoint();
13
+ const getMembersEndpoint = new GetUserMembersEndpoint();
14
+
15
+ describe('E2E.PrivateFiles', () => {
16
+ const recordSettings = RecordSettings.create({
17
+ id: 'test',
18
+ name: 'Bestand test',
19
+ type: RecordType.File,
20
+ });
21
+
22
+ async function createOrganization() {
23
+ const organization = await new OrganizationFactory({}).create();
24
+
25
+ // Add record settings type
26
+ const category = RecordCategory.create({
27
+ name: 'Voorbeeld',
28
+ defaultEnabled: true,
29
+ records: [
30
+ recordSettings,
31
+ ],
32
+ });
33
+
34
+ organization.meta.recordsConfiguration.recordCategories = [category];
35
+ await organization.save();
36
+
37
+ return organization;
38
+ }
39
+
40
+ test('Cannot set unsigned private files', async () => {
41
+ const organization = await createOrganization();
42
+
43
+ const user = await new UserFactory({
44
+ organization,
45
+ permissions: Permissions.create({
46
+ level: PermissionLevel.Full,
47
+ }),
48
+ }).create();
49
+ const token = await Token.createToken(user);
50
+
51
+ // Create a user member
52
+ const member = MemberWithRegistrationsBlob.create({
53
+ details: MemberDetails.create({
54
+ firstName: 'John',
55
+ lastName: 'Doe',
56
+ }),
57
+ });
58
+
59
+ // A malicious user could try to set a private file to a member in order to get
60
+ // access to the signed URL. This should not be possible without a valid signature
61
+ const privateFile = new File({
62
+ id: 'test',
63
+ server: 'test.com',
64
+ path: 'test.txt',
65
+ size: 100,
66
+ isPrivate: true,
67
+ });
68
+
69
+ member.details.recordAnswers.set(recordSettings.id, RecordFileAnswer.create({
70
+ file: privateFile,
71
+ settings: recordSettings,
72
+ }));
73
+
74
+ const arr = new PatchableArray() as PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
75
+ arr.addPut(member);
76
+
77
+ const r = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
78
+ r.headers.authorization = 'Bearer ' + token.accessToken;
79
+
80
+ await expect(testServer.test(endpoint, r)).rejects.toThrow(/Missing signature for private file/);
81
+ });
82
+
83
+ test('Cannot set private files with invalid signatures', async () => {
84
+ const organization = await createOrganization();
85
+
86
+ const user = await new UserFactory({
87
+ organization,
88
+ permissions: Permissions.create({
89
+ level: PermissionLevel.Full,
90
+ }),
91
+ }).create();
92
+ const token = await Token.createToken(user);
93
+
94
+ // Create a user member
95
+ const member = MemberWithRegistrationsBlob.create({
96
+ details: MemberDetails.create({
97
+ firstName: 'John',
98
+ lastName: 'Doe',
99
+ }),
100
+ });
101
+
102
+ // A malicious user could try to set a private file to a member in order to get
103
+ // access to the signed URL. This should not be possible without a valid signature
104
+ const privateFile = new File({
105
+ id: 'test',
106
+ server: 'test.com',
107
+ path: 'test.txt',
108
+ size: 100,
109
+ isPrivate: true,
110
+ signature: 'invalid',
111
+ });
112
+
113
+ member.details.recordAnswers.set(recordSettings.id, RecordFileAnswer.create({
114
+ file: privateFile,
115
+ settings: recordSettings,
116
+ }));
117
+
118
+ const arr = new PatchableArray() as PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
119
+ arr.addPut(member);
120
+
121
+ const r = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
122
+ r.headers.authorization = 'Bearer ' + token.accessToken;
123
+
124
+ await expect(testServer.test(endpoint, r)).rejects.toThrow(/Invalid signature for file/);
125
+ });
126
+
127
+ test('Can set signed private files', async () => {
128
+ const organization = await createOrganization();
129
+
130
+ const user = await new UserFactory({
131
+ organization,
132
+ permissions: Permissions.create({
133
+ level: PermissionLevel.Full,
134
+ }),
135
+ }).create();
136
+ const token = await Token.createToken(user);
137
+
138
+ // Create a user member
139
+ const member = MemberWithRegistrationsBlob.create({
140
+ details: MemberDetails.create({
141
+ firstName: 'John',
142
+ lastName: 'Doe',
143
+ }),
144
+ });
145
+
146
+ // A malicious user could try to set a private file to a member in order to get
147
+ // access to the signed URL. This should not be possible without a valid signature
148
+ const privateFile = new File({
149
+ id: 'test',
150
+ server: 'test.com',
151
+ path: 'test.txt',
152
+ size: 100,
153
+ isPrivate: true,
154
+ });
155
+ await privateFile.sign();
156
+
157
+ member.details.recordAnswers.set(recordSettings.id, RecordFileAnswer.create({
158
+ file: privateFile,
159
+ settings: recordSettings,
160
+ }));
161
+
162
+ const arr = new PatchableArray() as PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
163
+ arr.addPut(member);
164
+
165
+ const r = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
166
+ r.headers.authorization = 'Bearer ' + token.accessToken;
167
+
168
+ const response = await testServer.test(endpoint, r);
169
+ expect(response.body).toBeDefined();
170
+
171
+ // Check file has a signed URL in the response
172
+ const memberStruct = response.body.members[0];
173
+ const answer = memberStruct.details.recordAnswers.get(recordSettings.id);
174
+ if (!answer) {
175
+ // eslint-disable-next-line jest/no-conditional-expect
176
+ expect(memberStruct.details.recordAnswers).toHaveProperty(recordSettings.id);
177
+ throw new Error('Unexpected: Answer is not defined');
178
+ }
179
+
180
+ if (!(answer instanceof RecordFileAnswer)) {
181
+ throw new Error('Unexpected: Answer is not a RecordFileAnswer');
182
+ }
183
+
184
+ expect(answer.file).toBeDefined();
185
+ expect(answer.file!.isPrivate).toBe(true);
186
+ expect(answer.file!.signature).toBeTruthy();
187
+ expect(answer.file!.signedUrl).toBeTruthy();
188
+ });
189
+
190
+ test('Can set public files', async () => {
191
+ const organization = await createOrganization();
192
+
193
+ const user = await new UserFactory({
194
+ organization,
195
+ permissions: Permissions.create({
196
+ level: PermissionLevel.Full,
197
+ }),
198
+ }).create();
199
+ const token = await Token.createToken(user);
200
+
201
+ // Create a user member
202
+ const member = MemberWithRegistrationsBlob.create({
203
+ details: MemberDetails.create({
204
+ firstName: 'John',
205
+ lastName: 'Doe',
206
+ }),
207
+ });
208
+
209
+ // A malicious user could try to set a private file to a member in order to get
210
+ // access to the signed URL. This should not be possible without a valid signature
211
+ const publicFile = new File({
212
+ id: 'test',
213
+ server: 'test.com',
214
+ path: 'test.txt',
215
+ size: 100,
216
+ isPrivate: false,
217
+ });
218
+
219
+ member.details.recordAnswers.set(recordSettings.id, RecordFileAnswer.create({
220
+ file: publicFile,
221
+ settings: recordSettings,
222
+ }));
223
+
224
+ const arr = new PatchableArray() as PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
225
+ arr.addPut(member);
226
+
227
+ const r = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
228
+ r.headers.authorization = 'Bearer ' + token.accessToken;
229
+
230
+ const response = await testServer.test(endpoint, r);
231
+ expect(response.body).toBeDefined();
232
+
233
+ // Check file has not signed URL
234
+ const memberStruct = response.body.members[0];
235
+ const answer = memberStruct.details.recordAnswers.get(recordSettings.id);
236
+ if (!answer) {
237
+ // eslint-disable-next-line jest/no-conditional-expect
238
+ expect(memberStruct.details.recordAnswers).toHaveProperty(recordSettings.id);
239
+ throw new Error('Unexpected: Answer is not defined');
240
+ }
241
+
242
+ if (!(answer instanceof RecordFileAnswer)) {
243
+ throw new Error('Unexpected: Answer is not a RecordFileAnswer');
244
+ }
245
+
246
+ expect(answer.file).toBeDefined();
247
+ expect(answer.file!.isPrivate).toBe(false);
248
+ expect(answer.file!.signature).toBeFalsy();
249
+ expect(answer.file!.signedUrl).toBeFalsy();
250
+ });
251
+
252
+ /**
253
+ * Tests that when an unverified file is stored on the server (in case someone managed to bypass upload security),
254
+ * the server will never generate a signed URL for it.
255
+ */
256
+ test('Does not generate signed urls for unverifiable files', async () => {
257
+ const organization = await createOrganization();
258
+
259
+ const user = await new UserFactory({
260
+ organization,
261
+ permissions: Permissions.create({
262
+ level: PermissionLevel.Full,
263
+ }),
264
+ }).create();
265
+ const token = await Token.createToken(user);
266
+
267
+ // Create a user member
268
+ const member = MemberWithRegistrationsBlob.create({
269
+ details: MemberDetails.create({
270
+ firstName: 'John',
271
+ lastName: 'Doe',
272
+ }),
273
+ });
274
+
275
+ // A malicious user could try to set a private file to a member in order to get
276
+ // access to the signed URL. This should not be possible without a valid signature
277
+ const maliciousFile = new File({
278
+ id: 'test',
279
+ server: 'test.com',
280
+ path: 'test.txt',
281
+ size: 100,
282
+ isPrivate: true,
283
+ signature: 'invalid',
284
+ });
285
+
286
+ const arr = new PatchableArray() as PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
287
+ arr.addPut(member);
288
+
289
+ const r = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
290
+ r.headers.authorization = 'Bearer ' + token.accessToken;
291
+
292
+ const response = await testServer.test(endpoint, r);
293
+ expect(response.body).toBeDefined();
294
+
295
+ const memberId = response.body.members[0].id;
296
+
297
+ // Do a direct change in the database to simulate a file that was uploaded without a signature
298
+ const model = await Member.getByID(memberId);
299
+ if (!model) {
300
+ throw new Error('Member not found');
301
+ }
302
+
303
+ model.details.recordAnswers.set(recordSettings.id, RecordFileAnswer.create({
304
+ file: maliciousFile,
305
+ settings: recordSettings,
306
+ }));
307
+
308
+ await model.save();
309
+
310
+ // Now try to fetch the member again using the API
311
+ const r2 = Request.buildJson('GET', '/user/members', organization.getApiHost());
312
+ r2.headers.authorization = 'Bearer ' + token.accessToken;
313
+
314
+ const response2 = await testServer.test(getMembersEndpoint, r2);
315
+ expect(response2.body).toBeDefined();
316
+
317
+ // Check file has not signed URL
318
+ const memberStruct = response2.body.members[0];
319
+ const answer = memberStruct.details.recordAnswers.get(recordSettings.id);
320
+ if (!answer) {
321
+ throw new Error('Unexpected: Answer is not defined but expected');
322
+ }
323
+
324
+ if (!(answer instanceof RecordFileAnswer)) {
325
+ throw new Error('Unexpected: Answer is not a RecordFileAnswer');
326
+ }
327
+
328
+ expect(answer.file).toBeDefined();
329
+ expect(answer.file!.isPrivate).toBe(true);
330
+ expect(answer.file!.signedUrl).toBeFalsy();
331
+ });
332
+
333
+ /**
334
+ * Tests that when an verified file is stored on the server with a signed url, the server will never return that signed url.
335
+ */
336
+ test('Does not return stored signed urls from database', async () => {
337
+ const organization = await createOrganization();
338
+
339
+ const user = await new UserFactory({
340
+ organization,
341
+ permissions: Permissions.create({
342
+ level: PermissionLevel.Full,
343
+ }),
344
+ }).create();
345
+ const token = await Token.createToken(user);
346
+
347
+ // Create a user member
348
+ const member = MemberWithRegistrationsBlob.create({
349
+ details: MemberDetails.create({
350
+ firstName: 'John',
351
+ lastName: 'Doe',
352
+ }),
353
+ });
354
+
355
+ // A malicious user could try to set a private file to a member in order to get
356
+ // access to the signed URL. This should not be possible without a valid signature
357
+ const maliciousFile = new File({
358
+ id: 'test',
359
+ server: 'test.com',
360
+ path: 'test.txt',
361
+ size: 100,
362
+ isPrivate: true,
363
+ });
364
+
365
+ await maliciousFile.sign();
366
+ maliciousFile.signedUrl = 'https://test.com/test.exe';
367
+
368
+ const arr = new PatchableArray() as PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
369
+ arr.addPut(member);
370
+
371
+ const r = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
372
+ r.headers.authorization = 'Bearer ' + token.accessToken;
373
+
374
+ const response = await testServer.test(endpoint, r);
375
+ expect(response.body).toBeDefined();
376
+
377
+ const memberId = response.body.members[0].id;
378
+
379
+ // Do a direct change in the database to simulate a file that was uploaded without a signature
380
+ const model = await Member.getByID(memberId);
381
+ if (!model) {
382
+ throw new Error('Member not found');
383
+ }
384
+
385
+ model.details.recordAnswers.set(recordSettings.id, RecordFileAnswer.create({
386
+ file: maliciousFile,
387
+ settings: recordSettings,
388
+ }));
389
+
390
+ await model.save();
391
+
392
+ // Now try to fetch the member again using the API
393
+ const r2 = Request.buildJson('GET', '/user/members', organization.getApiHost());
394
+ r2.headers.authorization = 'Bearer ' + token.accessToken;
395
+
396
+ const response2 = await testServer.test(getMembersEndpoint, r2);
397
+ expect(response2.body).toBeDefined();
398
+
399
+ // Check file has not signed URL
400
+ const memberStruct = response2.body.members[0];
401
+ const answer = memberStruct.details.recordAnswers.get(recordSettings.id);
402
+ if (!answer) {
403
+ throw new Error('Unexpected: Answer is not defined but expected');
404
+ }
405
+
406
+ if (!(answer instanceof RecordFileAnswer)) {
407
+ throw new Error('Unexpected: Answer is not a RecordFileAnswer');
408
+ }
409
+
410
+ expect(answer.file).toBeDefined();
411
+ expect(answer.file!.isPrivate).toBe(true);
412
+ expect(answer.file!.signedUrl).toBeString();
413
+ expect(answer.file!.signedUrl).not.toEqual('https://test.com/test.exe'); // It got replaced with a proper signed url
414
+ });
415
+
416
+ /**
417
+ * Tests that when an unverified file is stored on the server with a signed url, the server will never return that signed url.
418
+ */
419
+ test('Does not return stored signed urls from database for unverified files', async () => {
420
+ const organization = await createOrganization();
421
+
422
+ const user = await new UserFactory({
423
+ organization,
424
+ permissions: Permissions.create({
425
+ level: PermissionLevel.Full,
426
+ }),
427
+ }).create();
428
+ const token = await Token.createToken(user);
429
+
430
+ // Create a user member
431
+ const member = MemberWithRegistrationsBlob.create({
432
+ details: MemberDetails.create({
433
+ firstName: 'John',
434
+ lastName: 'Doe',
435
+ }),
436
+ });
437
+
438
+ // A malicious user could try to set a private file to a member in order to get
439
+ // access to the signed URL. This should not be possible without a valid signature
440
+ const maliciousFile = new File({
441
+ id: 'test',
442
+ server: 'test.com',
443
+ path: 'test.txt',
444
+ size: 100,
445
+ isPrivate: true,
446
+ signature: 'invalid',
447
+ });
448
+
449
+ maliciousFile.signedUrl = 'https://test.com/test.exe';
450
+
451
+ const arr = new PatchableArray() as PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
452
+ arr.addPut(member);
453
+
454
+ const r = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
455
+ r.headers.authorization = 'Bearer ' + token.accessToken;
456
+
457
+ const response = await testServer.test(endpoint, r);
458
+ expect(response.body).toBeDefined();
459
+
460
+ const memberId = response.body.members[0].id;
461
+
462
+ // Do a direct change in the database to simulate a file that was uploaded without a signature
463
+ const model = await Member.getByID(memberId);
464
+ if (!model) {
465
+ throw new Error('Member not found');
466
+ }
467
+
468
+ model.details.recordAnswers.set(recordSettings.id, RecordFileAnswer.create({
469
+ file: maliciousFile,
470
+ settings: recordSettings,
471
+ }));
472
+
473
+ await model.save();
474
+
475
+ // Now try to fetch the member again using the API
476
+ const r2 = Request.buildJson('GET', '/user/members', organization.getApiHost());
477
+ r2.headers.authorization = 'Bearer ' + token.accessToken;
478
+
479
+ const response2 = await testServer.test(getMembersEndpoint, r2);
480
+ expect(response2.body).toBeDefined();
481
+
482
+ // Check file has not signed URL
483
+ const memberStruct = response2.body.members[0];
484
+ const answer = memberStruct.details.recordAnswers.get(recordSettings.id);
485
+ if (!answer) {
486
+ throw new Error('Unexpected: Answer is not defined but expected');
487
+ }
488
+
489
+ if (!(answer instanceof RecordFileAnswer)) {
490
+ throw new Error('Unexpected: Answer is not a RecordFileAnswer');
491
+ }
492
+
493
+ expect(answer.file).toBeDefined();
494
+ expect(answer.file!.isPrivate).toBe(true);
495
+ expect(answer.file!.signedUrl).toBeNull();
496
+ });
497
+ });