@nymphjs/tilmeld 1.0.0-beta.85 → 1.0.0-beta.86

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/src/Group.ts CHANGED
@@ -8,11 +8,13 @@ import type {
8
8
  Selector,
9
9
  SerializedEntityData,
10
10
  } from '@nymphjs/nymph';
11
- import { difference } from 'lodash-es';
11
+ import { difference, xor } from 'lodash-es';
12
+ import { splitn } from '@sciactive/splitn';
12
13
 
13
14
  import { enforceTilmeld } from './enforceTilmeld.js';
14
15
  import AbleObject from './AbleObject.js';
15
16
  import {
17
+ AccessControlError,
16
18
  BadDataError,
17
19
  BadEmailError,
18
20
  BadUsernameError,
@@ -119,6 +121,10 @@ export default class Group extends AbleObject<GroupData> {
119
121
  * This should only be used by the backend.
120
122
  */
121
123
  private $skipAcWhenDeleting = false;
124
+ /**
125
+ * Used to save the current groupname for domain admin permissions.
126
+ */
127
+ public $originalGroupname?: string;
122
128
 
123
129
  static async factoryGroupname(
124
130
  groupname?: string,
@@ -281,55 +287,37 @@ export default class Group extends AbleObject<GroupData> {
281
287
  }
282
288
 
283
289
  public $jsonAcceptData(input: EntityJson, allowConflict = false) {
284
- const tilmeld = enforceTilmeld(this);
285
290
  this.$check();
286
291
 
287
- if (
288
- 'abilities' in input.data &&
289
- input.data.abilities?.includes('system/admin') !==
290
- this.$data.abilities?.includes('system/admin') &&
291
- (!tilmeld.currentUser ||
292
- !tilmeld.currentUser.abilities?.includes('system/admin'))
293
- ) {
294
- throw new BadDataError(
295
- "You don't have the authority to grant or revoke system/admin.",
296
- );
292
+ if ('abilities' in input.data) {
293
+ this.$checkIncomingAbilities(input.data.abilities);
297
294
  }
298
295
 
299
- if (
300
- 'abilities' in input.data &&
301
- input.data.abilities?.includes('tilmeld/admin') !==
302
- this.$data.abilities?.includes('tilmeld/admin') &&
303
- (!tilmeld.currentUser ||
304
- !tilmeld.currentUser.abilities?.includes('system/admin'))
305
- ) {
306
- throw new BadDataError(
307
- "You don't have the authority to grant or revoke tilmeld/admin.",
308
- );
309
- }
296
+ super.$jsonAcceptData(input, allowConflict);
297
+ }
310
298
 
311
- if (
312
- 'abilities' in input.data &&
313
- input.data.abilities?.includes('tilmeld/switch') !==
314
- this.$data.abilities?.includes('tilmeld/switch') &&
315
- (!tilmeld.currentUser ||
316
- !tilmeld.currentUser.abilities?.includes('system/admin'))
317
- ) {
318
- throw new BadDataError(
319
- "You don't have the authority to grant or revoke tilmeld/switch.",
320
- );
299
+ public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
300
+ this.$check();
301
+
302
+ if ('abilities' in patch.set) {
303
+ this.$checkIncomingAbilities(patch.set.abilities);
321
304
  }
322
305
 
323
- super.$jsonAcceptData(input, allowConflict);
306
+ super.$jsonAcceptPatch(patch, allowConflict);
324
307
  }
325
308
 
326
- public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
309
+ private $checkIncomingAbilities(incoming: any) {
327
310
  const tilmeld = enforceTilmeld(this);
328
- this.$check();
329
311
 
330
312
  if (
331
- 'abilities' in patch.set &&
332
- patch.set.abilities?.includes('system/admin') !==
313
+ !Array.isArray(incoming) ||
314
+ incoming.find((ability) => typeof ability !== 'string')
315
+ ) {
316
+ throw new BadDataError('Abilities must be an array of strings.');
317
+ }
318
+
319
+ if (
320
+ incoming.includes('system/admin') !==
333
321
  this.$data.abilities?.includes('system/admin') &&
334
322
  (!tilmeld.currentUser ||
335
323
  !tilmeld.currentUser.abilities?.includes('system/admin'))
@@ -340,8 +328,7 @@ export default class Group extends AbleObject<GroupData> {
340
328
  }
341
329
 
342
330
  if (
343
- 'abilities' in patch.set &&
344
- patch.set.abilities?.includes('tilmeld/admin') !==
331
+ incoming.includes('tilmeld/admin') !==
345
332
  this.$data.abilities?.includes('tilmeld/admin') &&
346
333
  (!tilmeld.currentUser ||
347
334
  !tilmeld.currentUser.abilities?.includes('system/admin'))
@@ -352,8 +339,7 @@ export default class Group extends AbleObject<GroupData> {
352
339
  }
353
340
 
354
341
  if (
355
- 'abilities' in patch.set &&
356
- patch.set.abilities?.includes('tilmeld/switch') !==
342
+ incoming.includes('tilmeld/switch') !==
357
343
  this.$data.abilities?.includes('tilmeld/switch') &&
358
344
  (!tilmeld.currentUser ||
359
345
  !tilmeld.currentUser.abilities?.includes('system/admin'))
@@ -363,11 +349,32 @@ export default class Group extends AbleObject<GroupData> {
363
349
  );
364
350
  }
365
351
 
366
- super.$jsonAcceptPatch(patch, allowConflict);
352
+ if (
353
+ xor(
354
+ incoming.filter((ability: string) => ability.startsWith('tilmeld/')),
355
+ this.$data.abilities?.filter((ability: string) =>
356
+ ability.startsWith('tilmeld/'),
357
+ ) ?? [],
358
+ ).length &&
359
+ (!tilmeld.currentUser ||
360
+ (!tilmeld.currentUser.abilities?.includes('system/admin') &&
361
+ !tilmeld.currentUser.abilities?.includes('tilmeld/admin')))
362
+ ) {
363
+ throw new BadDataError(
364
+ "You don't have the authority to grant or revoke Tilmeld abilities.",
365
+ );
366
+ }
367
367
  }
368
368
 
369
- public $putData(data: EntityData, sdata?: SerializedEntityData) {
370
- super.$putData(data, sdata);
369
+ public $putData(
370
+ data: EntityData,
371
+ sdata?: SerializedEntityData,
372
+ source?: 'server',
373
+ ) {
374
+ super.$putData(data, sdata, source);
375
+ if (source === 'server') {
376
+ this.$originalGroupname = this.$data.groupname;
377
+ }
371
378
  this.$updateDataProtection();
372
379
  }
373
380
 
@@ -379,6 +386,13 @@ export default class Group extends AbleObject<GroupData> {
379
386
  public $updateDataProtection(givenUser?: User & UserData) {
380
387
  const tilmeld = enforceTilmeld(this);
381
388
  let user = givenUser ?? tilmeld.User.current();
389
+ let [_groupname, domain] = tilmeld.config.domainSupport
390
+ ? splitn(this.$data.groupname ?? '', '@', 2)
391
+ : [this.$data.groupname, null];
392
+ // Lowercase the domain part.
393
+ if (domain) {
394
+ domain = domain.toLowerCase();
395
+ }
382
396
 
383
397
  this.$privateData = [...Group.DEFAULT_PRIVATE_DATA];
384
398
  this.$allowlistData = [...Group.DEFAULT_ALLOWLIST_DATA];
@@ -402,6 +416,14 @@ export default class Group extends AbleObject<GroupData> {
402
416
  ) {
403
417
  // Users can see their group's data.
404
418
  this.$privateData = [];
419
+ } else if (
420
+ user != null &&
421
+ tilmeld.config.domainSupport &&
422
+ domain &&
423
+ user.abilities?.includes(`tilmeld/domain/${domain}/admin`)
424
+ ) {
425
+ // Domain admins can see their groups' data.
426
+ this.$privateData = [];
405
427
  }
406
428
  }
407
429
 
@@ -567,10 +589,25 @@ export default class Group extends AbleObject<GroupData> {
567
589
  }> {
568
590
  const tilmeld = enforceTilmeld(this);
569
591
  if (!tilmeld.config.emailUsernames) {
570
- if (this.$data.groupname == null || !this.$data.groupname.length) {
592
+ let [groupname, domain] = tilmeld.config.domainSupport
593
+ ? splitn(this.$data.groupname ?? '', '@', 2)
594
+ : [this.$data.groupname, null];
595
+
596
+ // Lowercase the domain part.
597
+ if (domain) {
598
+ domain = domain.toLowerCase();
599
+ this.$data.groupname = `${groupname}@${domain}`;
600
+ }
601
+
602
+ if (
603
+ this.$data.groupname == null ||
604
+ !this.$data.groupname.length ||
605
+ groupname == null ||
606
+ !groupname.length
607
+ ) {
571
608
  return { result: false, message: 'Please specify a groupname.' };
572
609
  }
573
- if (this.$data.groupname.length < tilmeld.config.minUsernameLength) {
610
+ if (groupname.length < tilmeld.config.minUsernameLength) {
574
611
  return {
575
612
  result: false,
576
613
  message:
@@ -579,7 +616,7 @@ export default class Group extends AbleObject<GroupData> {
579
616
  ' characters.',
580
617
  };
581
618
  }
582
- if (this.$data.groupname.length > tilmeld.config.maxUsernameLength) {
619
+ if (groupname.length > tilmeld.config.maxUsernameLength) {
583
620
  return {
584
621
  result: false,
585
622
  message:
@@ -589,22 +626,38 @@ export default class Group extends AbleObject<GroupData> {
589
626
  };
590
627
  }
591
628
  if (
592
- difference(
593
- this.$data.groupname.split(''),
594
- tilmeld.config.validChars.split(''),
595
- ).length
629
+ difference(groupname.split(''), tilmeld.config.validChars.split(''))
630
+ .length
596
631
  ) {
597
632
  return {
598
633
  result: false,
599
634
  message: tilmeld.config.validCharsNotice,
600
635
  };
601
636
  }
602
- if (!tilmeld.config.validRegex.test(this.$data.groupname)) {
637
+ if (!tilmeld.config.validRegex.test(groupname)) {
603
638
  return {
604
639
  result: false,
605
640
  message: tilmeld.config.validRegexNotice,
606
641
  };
607
642
  }
643
+ if (domain != null) {
644
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
645
+ return {
646
+ result: false,
647
+ message: tilmeld.config.validDomainRegexNotice,
648
+ };
649
+ }
650
+ if (
651
+ !this.$is(tilmeld.currentUser?.group) &&
652
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
653
+ ) {
654
+ return {
655
+ result: false,
656
+ message:
657
+ "You don't have the authority to manage groups on this domain.",
658
+ };
659
+ }
660
+ }
608
661
 
609
662
  const selector: Selector = {
610
663
  type: '&',
@@ -707,6 +760,59 @@ export default class Group extends AbleObject<GroupData> {
707
760
  return false;
708
761
  }
709
762
 
763
+ // Lowercase the domain part.
764
+ if (tilmeld.config.domainSupport) {
765
+ const [groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
766
+ if (domain) {
767
+ this.$data.groupname = `${groupname}@${domain.toLowerCase()}`;
768
+ }
769
+ }
770
+
771
+ if (tilmeld.config.domainSupport) {
772
+ const [_groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
773
+ const [_originalGroupname, originalDomain] = splitn(
774
+ this.$originalGroupname ?? '',
775
+ '@',
776
+ 2,
777
+ );
778
+
779
+ if (
780
+ this.guid != null &&
781
+ ((originalDomain == null && domain != null) ||
782
+ (domain == null && originalDomain != null))
783
+ ) {
784
+ throw new AccessControlError(
785
+ "Groups can't switch to or from having a domain once created.",
786
+ );
787
+ }
788
+
789
+ if (
790
+ !this.$skipAcWhenSaving &&
791
+ !tilmeld.gatekeeper('tilmeld/admin') &&
792
+ !tilmeld.gatekeeper('system/admin')
793
+ ) {
794
+ if (
795
+ domain != null &&
796
+ !this.$is(tilmeld.currentUser) &&
797
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
798
+ ) {
799
+ throw new AccessControlError(
800
+ "You don't have the authority to modify groups on this domain.",
801
+ );
802
+ }
803
+ if (
804
+ originalDomain != null &&
805
+ originalDomain !== domain &&
806
+ (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`) ||
807
+ !tilmeld.gatekeeper(`tilmeld/domain/${originalDomain}/admin`))
808
+ ) {
809
+ throw new AccessControlError(
810
+ "You don't have the authority to modify groups on this domain.",
811
+ );
812
+ }
813
+ }
814
+ }
815
+
710
816
  // Formatting.
711
817
  this.$data.groupname = this.$data.groupname.trim();
712
818
  if (tilmeld.config.emailUsernames) {
@@ -752,15 +858,23 @@ export default class Group extends AbleObject<GroupData> {
752
858
  delete this.$data.unverifiedSecondary;
753
859
  }
754
860
 
755
- // Groups should never have 'system/admin', 'tilmeld/admin', or
756
- // 'tilmeld/switch' abilities.
757
- this.$data.abilities = difference(this.$data.abilities ?? [], [
758
- 'system/admin',
759
- 'tilmeld/admin',
760
- 'tilmeld/switch',
761
- null,
762
- undefined,
763
- ]) as string[];
861
+ // Groups should never have 'system/admin', 'tilmeld/admin',
862
+ // 'tilmeld/switch', or domain admin abilities.
863
+ this.$data.abilities = (this.$data.abilities ?? []).filter((ability) => {
864
+ if (
865
+ ['system/admin', 'tilmeld/admin', 'tilmeld/switch'].indexOf(ability) !==
866
+ -1
867
+ ) {
868
+ return false;
869
+ }
870
+ if (ability == null) {
871
+ return false;
872
+ }
873
+ if (ability.startsWith('tilmeld/domain/') && ability.endsWith('/admin')) {
874
+ return false;
875
+ }
876
+ return true;
877
+ }) as string[];
764
878
 
765
879
  // Verification.
766
880
  const unCheck = await this.$checkGroupname();
@@ -818,7 +932,11 @@ export default class Group extends AbleObject<GroupData> {
818
932
  }
819
933
  }
820
934
 
821
- return await super.$save();
935
+ const ret = await super.$save();
936
+ if (ret) {
937
+ this.$originalGroupname = this.$data.groupname;
938
+ }
939
+ return ret;
822
940
  }
823
941
 
824
942
  /*
@@ -840,7 +958,14 @@ export default class Group extends AbleObject<GroupData> {
840
958
  public async $delete() {
841
959
  let tilmeld = enforceTilmeld(this);
842
960
  if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
843
- throw new BadDataError("You don't have the authority to delete groups.");
961
+ throw new AccessControlError(
962
+ "You don't have the authority to delete groups.",
963
+ );
964
+ }
965
+ if (this.$data.groupname !== this.$originalGroupname) {
966
+ throw new AccessControlError(
967
+ "You can't delete a group while switching its groupname.",
968
+ );
844
969
  }
845
970
 
846
971
  const transaction = 'tilmeld-delete-' + this.guid;
@@ -913,9 +1038,9 @@ export default class Group extends AbleObject<GroupData> {
913
1038
  }
914
1039
 
915
1040
  // Delete the group.
916
- const success = await super.$delete();
1041
+ let success = await super.$delete();
917
1042
  if (success) {
918
- await tnymph.commit(transaction);
1043
+ success = await tnymph.commit(transaction);
919
1044
  } else {
920
1045
  await tnymph.rollback(transaction);
921
1046
  }
package/src/Tilmeld.ts CHANGED
@@ -10,10 +10,11 @@ import {
10
10
  } from '@nymphjs/nymph';
11
11
  import { Request, Response } from 'express';
12
12
  import { xor } from 'lodash-es';
13
+ import { splitn } from '@sciactive/splitn';
13
14
 
14
15
  import { Config, ConfigDefaults as defaults } from './conf/index.js';
15
16
  import { AccessControlError } from './errors/index.js';
16
- import Group from './Group.js';
17
+ import Group, { GroupData } from './Group.js';
17
18
  import User, { UserData } from './User.js';
18
19
  import { AccessControlData } from './Tilmeld.types.js';
19
20
  import { enforceTilmeld } from './enforceTilmeld.js';
@@ -108,6 +109,12 @@ export default class Tilmeld implements TilmeldInterface {
108
109
  );
109
110
  }
110
111
  }
112
+
113
+ if (this.config.emailUsernames && this.config.domainSupport) {
114
+ throw new Error(
115
+ 'Email usernames and domain support cannot both be enabled.',
116
+ );
117
+ }
111
118
  }
112
119
 
113
120
  /**
@@ -165,6 +172,8 @@ export default class Tilmeld implements TilmeldInterface {
165
172
  user.mdate = currentUserMDate;
166
173
  user.tags = currentUserTags;
167
174
  user.$putData(currentUserData, currentUserSData);
175
+ user.$originalEmail = this.currentUser.$originalEmail;
176
+ user.$originalUsername = this.currentUser.$originalUsername;
168
177
  this.currentUser = user;
169
178
  this.currentUser.$updateDataProtection();
170
179
  this.currentUser.$updateGroupDataProtection();
@@ -738,6 +747,27 @@ export default class Tilmeld implements TilmeldInterface {
738
747
  return true;
739
748
  }
740
749
 
750
+ // Users and groups that belong to a domain are readable and editable by
751
+ // that domain's admins.
752
+ if (
753
+ this.config.domainSupport &&
754
+ (entity instanceof User || entity instanceof Group)
755
+ ) {
756
+ const [_local, domain] = splitn(
757
+ (entity instanceof User
758
+ ? (entity as User & UserData).username
759
+ : (entity as Group & GroupData).groupname) ?? '',
760
+ '@',
761
+ 2,
762
+ );
763
+ if (
764
+ domain &&
765
+ userOrEmpty.abilities?.includes(`tilmeld/domain/${domain}/admin`)
766
+ ) {
767
+ return true;
768
+ }
769
+ }
770
+
741
771
  // Load access control, since we need it now...
742
772
  const acUser = acProperties.acUser ?? TilmeldAccessLevels.FULL_ACCESS;
743
773
  const acGroup = acProperties.acGroup ?? TilmeldAccessLevels.READ_ACCESS;
package/src/User.test.ts CHANGED
@@ -3,6 +3,7 @@ import { Nymph, EntityUniqueConstraintError } from '@nymphjs/nymph';
3
3
 
4
4
  import Tilmeld from './Tilmeld.js';
5
5
  import defaults from './conf/defaults.js';
6
+ import { AccessControlError } from './errors/index.js';
6
7
 
7
8
  const tilmeld = new Tilmeld({
8
9
  appName: 'My App',
@@ -11,6 +12,7 @@ const tilmeld = new Tilmeld({
11
12
  cookiePath: '/',
12
13
  setupPath: '/user',
13
14
  emailUsernames: false,
15
+ domainSupport: true,
14
16
  verifyRedirect: 'http://localhost:8080',
15
17
  verifyChangeRedirect: 'http://localhost:8080',
16
18
  cancelChangeRedirect: 'http://localhost:8080',
@@ -134,4 +136,83 @@ describe('User', () => {
134
136
  }
135
137
  }
136
138
  });
139
+
140
+ it('allows a domain user', async () => {
141
+ const domainAdmin = await User.factory();
142
+
143
+ domainAdmin.username = 'admin';
144
+ domainAdmin.email = 'admin@localhost';
145
+ domainAdmin.nameFirst = 'Admin';
146
+ domainAdmin.nameLast = 'User';
147
+ domainAdmin.name = 'Admin User';
148
+ domainAdmin.$password('password');
149
+ domainAdmin.$grant('tilmeld/domain/example.com/admin');
150
+
151
+ expect(await domainAdmin.$saveSkipAC()).toEqual(true);
152
+
153
+ // Log in the domain admin.
154
+ await tilmeld.fillSession(domainAdmin);
155
+
156
+ const domainUser = await User.factory();
157
+
158
+ domainUser.username = 'new-user@example.com';
159
+ domainUser.email = 'newuser@localhost';
160
+ domainUser.nameFirst = 'New';
161
+ domainUser.nameLast = 'User';
162
+ domainUser.name = 'New User';
163
+ domainUser.$password('password');
164
+
165
+ expect(await domainUser.$save()).toEqual(true);
166
+
167
+ tilmeld.clearSession();
168
+
169
+ for (const user of [domainAdmin, domainUser]) {
170
+ if (user.guid != null) {
171
+ expect(await user.$deleteSkipAC()).toEqual(true);
172
+ }
173
+ }
174
+ });
175
+
176
+ it('disallows a domain user with no admin permission', async () => {
177
+ const domainAdmin = await User.factory();
178
+
179
+ domainAdmin.username = 'admin';
180
+ domainAdmin.email = 'admin@localhost';
181
+ domainAdmin.nameFirst = 'Admin';
182
+ domainAdmin.nameLast = 'User';
183
+ domainAdmin.name = 'Admin User';
184
+ domainAdmin.$password('password');
185
+ domainAdmin.$grant('tilmeld/domain/example.com/admin');
186
+
187
+ expect(await domainAdmin.$saveSkipAC()).toEqual(true);
188
+
189
+ // Log in the domain admin.
190
+ await tilmeld.fillSession(domainAdmin);
191
+
192
+ const domainUser = await User.factory();
193
+
194
+ // The wrong domain name.
195
+ domainUser.username = 'new-user@example.net';
196
+ domainUser.email = 'newuser@localhost';
197
+ domainUser.nameFirst = 'New';
198
+ domainUser.nameLast = 'User';
199
+ domainUser.name = 'New User';
200
+ domainUser.$password('password');
201
+
202
+ try {
203
+ await domainUser.$save();
204
+
205
+ throw new Error("Shouldn't get here because of access control.");
206
+ } catch (e: any) {
207
+ expect(e).toBeInstanceOf(AccessControlError);
208
+ }
209
+
210
+ tilmeld.clearSession();
211
+
212
+ for (const user of [domainAdmin, domainUser]) {
213
+ if (user.guid != null) {
214
+ expect(await user.$deleteSkipAC()).toEqual(true);
215
+ }
216
+ }
217
+ });
137
218
  });