@checkstack/auth-backend 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @checkstack/auth-backend
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [83557c7]
8
+ - Updated dependencies [83557c7]
9
+ - @checkstack/backend-api@0.4.0
10
+ - @checkstack/common@0.4.0
11
+ - @checkstack/command-backend@0.1.4
12
+ - @checkstack/auth-common@0.5.1
13
+ - @checkstack/notification-common@0.2.1
14
+
15
+ ## 0.4.0
16
+
17
+ ### Minor Changes
18
+
19
+ - d94121b: Add group-to-role mapping for SAML and LDAP authentication
20
+
21
+ **Features:**
22
+
23
+ - SAML and LDAP users can now be automatically assigned Checkstack roles based on their directory group memberships
24
+ - Configure group mappings in the authentication strategy settings with dynamic role dropdowns
25
+ - Managed role sync: roles configured in mappings are fully synchronized (added when user gains group, removed when user leaves group)
26
+ - Unmanaged roles (manually assigned, not in any mapping) are preserved during sync
27
+ - Optional default role for all users from a directory
28
+
29
+ **Bug Fix:**
30
+
31
+ - Fixed `x-options-resolver` not working for fields inside arrays with `.default([])` in DynamicForm schemas
32
+
33
+ ### Patch Changes
34
+
35
+ - Updated dependencies [d94121b]
36
+ - @checkstack/backend-api@0.3.3
37
+ - @checkstack/auth-common@0.5.0
38
+ - @checkstack/command-backend@0.1.3
39
+
3
40
  ## 0.3.0
4
41
 
5
42
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-backend",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -368,6 +368,271 @@ describe("Auth Router", () => {
368
368
  expect(mockDb.insert).toHaveBeenCalled();
369
369
  });
370
370
 
371
+ it("upsertExternalUser syncs roles when syncRoles provided for new user", async () => {
372
+ const context = createMockRpcContext({ user: mockServiceUser });
373
+
374
+ // Mock user not found (empty result)
375
+ mockDb.select.mockImplementationOnce(() => ({
376
+ from: mock(() => createChain([])),
377
+ }));
378
+
379
+ // Mock registration allowed
380
+ mockConfigService.get.mockResolvedValueOnce({ allowRegistration: true });
381
+
382
+ // Mock valid roles lookup
383
+ mockDb.select.mockImplementationOnce(() => ({
384
+ from: mock(() =>
385
+ createChain([{ id: USERS_ROLE_ID }, { id: "custom-role" }]),
386
+ ),
387
+ }));
388
+
389
+ // Mock current roles lookup (empty for new user)
390
+ mockDb.select.mockImplementationOnce(() => ({
391
+ from: mock(() => createChain([])),
392
+ }));
393
+
394
+ const result = await call(
395
+ router.upsertExternalUser,
396
+ {
397
+ email: "saml-user@example.com",
398
+ name: "SAML User",
399
+ providerId: "saml",
400
+ accountId: "samluser",
401
+ password: "hashed-password",
402
+ syncRoles: [USERS_ROLE_ID, "custom-role", "invalid-role"],
403
+ },
404
+ { context },
405
+ );
406
+
407
+ expect(result.created).toBe(true);
408
+ expect(mockDb.insert).toHaveBeenCalled();
409
+ });
410
+
411
+ it("upsertExternalUser additively syncs roles for existing user", async () => {
412
+ const context = createMockRpcContext({ user: mockServiceUser });
413
+
414
+ // Mock existing user found
415
+ mockDb.select.mockImplementationOnce(() => ({
416
+ from: mock(() => createChain([{ id: "existing-user-id" }])),
417
+ }));
418
+
419
+ // Mock update chain
420
+ mockDb.update = mock(() => ({
421
+ set: mock(() => ({
422
+ where: mock(() => Promise.resolve()),
423
+ })),
424
+ }));
425
+
426
+ // Mock valid roles lookup
427
+ mockDb.select.mockImplementationOnce(() => ({
428
+ from: mock(() => createChain([{ id: "new-role" }])),
429
+ }));
430
+
431
+ // Mock current roles lookup (user already has existing role)
432
+ mockDb.select.mockImplementationOnce(() => ({
433
+ from: mock(() => createChain([{ roleId: USERS_ROLE_ID }])),
434
+ }));
435
+
436
+ const result = await call(
437
+ router.upsertExternalUser,
438
+ {
439
+ email: "existing@example.com",
440
+ name: "Existing User",
441
+ providerId: "ldap",
442
+ accountId: "existinguser",
443
+ password: "hashed-password",
444
+ autoUpdateUser: true,
445
+ syncRoles: ["new-role"],
446
+ },
447
+ { context },
448
+ );
449
+
450
+ expect(result.created).toBe(false);
451
+ expect(result.userId).toBe("existing-user-id");
452
+ // New role should be added (insert called)
453
+ expect(mockDb.insert).toHaveBeenCalled();
454
+ });
455
+
456
+ it("upsertExternalUser ignores invalid role IDs silently", async () => {
457
+ const context = createMockRpcContext({ user: mockServiceUser });
458
+
459
+ // Mock user not found (empty result)
460
+ mockDb.select.mockImplementationOnce(() => ({
461
+ from: mock(() => createChain([])),
462
+ }));
463
+
464
+ // Mock registration allowed
465
+ mockConfigService.get.mockResolvedValueOnce({ allowRegistration: true });
466
+
467
+ // Mock valid roles lookup - none of the provided roles are valid
468
+ mockDb.select.mockImplementationOnce(() => ({
469
+ from: mock(() => createChain([])), // No valid roles
470
+ }));
471
+
472
+ const result = await call(
473
+ router.upsertExternalUser,
474
+ {
475
+ email: "user@example.com",
476
+ name: "User",
477
+ providerId: "saml",
478
+ accountId: "user123",
479
+ password: "hashed-password",
480
+ syncRoles: ["invalid-role-1", "invalid-role-2"],
481
+ },
482
+ { context },
483
+ );
484
+
485
+ // Should still succeed even with all invalid roles
486
+ expect(result.created).toBe(true);
487
+ expect(result.userId).toBeDefined();
488
+ });
489
+
490
+ it("upsertExternalUser does not sync roles when syncRoles not provided", async () => {
491
+ const context = createMockRpcContext({ user: mockServiceUser });
492
+
493
+ // Mock user not found (empty result)
494
+ mockDb.select.mockImplementationOnce(() => ({
495
+ from: mock(() => createChain([])),
496
+ }));
497
+
498
+ // Mock registration allowed
499
+ mockConfigService.get.mockResolvedValueOnce({ allowRegistration: true });
500
+
501
+ // Reset insert mock to track calls
502
+ const insertMock = mock(() => ({
503
+ values: mock(() => createChain()),
504
+ }));
505
+ mockDb.insert = insertMock;
506
+
507
+ const result = await call(
508
+ router.upsertExternalUser,
509
+ {
510
+ email: "user@example.com",
511
+ name: "User",
512
+ providerId: "saml",
513
+ accountId: "user123",
514
+ password: "hashed-password",
515
+ // No syncRoles provided
516
+ },
517
+ { context },
518
+ );
519
+
520
+ expect(result.created).toBe(true);
521
+ // Transaction should be called for user creation, but no role sync
522
+ expect(mockDb.transaction).toHaveBeenCalled();
523
+ });
524
+
525
+ it("upsertExternalUser removes managed roles when user leaves directory groups", async () => {
526
+ const context = createMockRpcContext({ user: mockServiceUser });
527
+
528
+ // Mock existing user found
529
+ mockDb.select.mockImplementationOnce(() => ({
530
+ from: mock(() => createChain([{ id: "existing-user-id" }])),
531
+ }));
532
+
533
+ // Mock update chain (for autoUpdateUser)
534
+ mockDb.update = mock(() => ({
535
+ set: mock(() => ({
536
+ where: mock(() => Promise.resolve()),
537
+ })),
538
+ }));
539
+
540
+ // Note: When syncRoles is empty [], we skip the valid roles query
541
+ // So next select is the current roles lookup
542
+
543
+ // Mock current roles lookup - user has managed role that should be removed
544
+ mockDb.select.mockImplementationOnce(() => ({
545
+ from: mock(() =>
546
+ createChain([
547
+ { roleId: "managed-role-1" }, // Should be removed - managed but not in syncRoles
548
+ { roleId: "manual-role" }, // Should be preserved - not in managedRoleIds
549
+ ]),
550
+ ),
551
+ }));
552
+
553
+ // Mock delete
554
+ mockDb.delete = mock(() => ({
555
+ where: mock(() => Promise.resolve()),
556
+ }));
557
+
558
+ const result = await call(
559
+ router.upsertExternalUser,
560
+ {
561
+ email: "user@example.com",
562
+ name: "User",
563
+ providerId: "ldap",
564
+ accountId: "user123",
565
+ password: "hashed-password",
566
+ autoUpdateUser: true,
567
+ syncRoles: [], // User no longer has any groups
568
+ managedRoleIds: ["managed-role-1", "managed-role-2"], // Roles controlled by directory
569
+ },
570
+ { context },
571
+ );
572
+
573
+ expect(result.created).toBe(false);
574
+ // Delete should be called to remove the managed role
575
+ expect(mockDb.delete).toHaveBeenCalled();
576
+ });
577
+
578
+ it("upsertExternalUser preserves unmanaged roles during sync", async () => {
579
+ const context = createMockRpcContext({ user: mockServiceUser });
580
+
581
+ // Mock existing user found
582
+ mockDb.select.mockImplementationOnce(() => ({
583
+ from: mock(() => createChain([{ id: "existing-user-id" }])),
584
+ }));
585
+
586
+ // Mock update chain
587
+ mockDb.update = mock(() => ({
588
+ set: mock(() => ({
589
+ where: mock(() => Promise.resolve()),
590
+ })),
591
+ }));
592
+
593
+ // Mock valid sync roles lookup
594
+ mockDb.select.mockImplementationOnce(() => ({
595
+ from: mock(() => createChain([{ id: "new-managed-role" }])),
596
+ }));
597
+
598
+ // Mock current roles - user has both managed and unmanaged roles
599
+ mockDb.select.mockImplementationOnce(() => ({
600
+ from: mock(() =>
601
+ createChain([
602
+ { roleId: "old-managed-role" }, // Should be removed - managed but not in syncRoles
603
+ { roleId: "admin-role" }, // Should be preserved - manually assigned, not managed
604
+ ]),
605
+ ),
606
+ }));
607
+
608
+ // Mock delete
609
+ const deleteMock = mock(() => ({
610
+ where: mock(() => Promise.resolve()),
611
+ }));
612
+ mockDb.delete = deleteMock;
613
+
614
+ const result = await call(
615
+ router.upsertExternalUser,
616
+ {
617
+ email: "user@example.com",
618
+ name: "User",
619
+ providerId: "ldap",
620
+ accountId: "user123",
621
+ password: "hashed-password",
622
+ autoUpdateUser: true,
623
+ syncRoles: ["new-managed-role"],
624
+ managedRoleIds: ["old-managed-role", "new-managed-role"], // Only these are managed
625
+ },
626
+ { context },
627
+ );
628
+
629
+ expect(result.created).toBe(false);
630
+ // Insert should be called to add new role
631
+ expect(mockDb.insert).toHaveBeenCalled();
632
+ // Delete should be called to remove old-managed-role (but NOT admin-role)
633
+ expect(deleteMock).toHaveBeenCalled();
634
+ });
635
+
371
636
  // ==========================================================================
372
637
  // ADMIN USER CREATION TESTS
373
638
  // ==========================================================================
package/src/router.ts CHANGED
@@ -856,8 +856,16 @@ export const createAuthRouter = (
856
856
 
857
857
  const upsertExternalUser = os.upsertExternalUser.handler(
858
858
  async ({ input, context }) => {
859
- const { email, name, providerId, accountId, password, autoUpdateUser } =
860
- input;
859
+ const {
860
+ email,
861
+ name,
862
+ providerId,
863
+ accountId,
864
+ password,
865
+ autoUpdateUser,
866
+ syncRoles,
867
+ managedRoleIds,
868
+ } = input;
861
869
 
862
870
  // Check if user exists
863
871
  const existingUsers = await internalDb
@@ -866,9 +874,12 @@ export const createAuthRouter = (
866
874
  .where(eq(schema.user.email, email))
867
875
  .limit(1);
868
876
 
877
+ let userId: string;
878
+ let created = false;
879
+
869
880
  if (existingUsers.length > 0) {
870
881
  // User exists - update if autoUpdateUser is enabled
871
- const userId = existingUsers[0].id;
882
+ userId = existingUsers[0].id;
872
883
 
873
884
  if (autoUpdateUser) {
874
885
  await internalDb
@@ -876,49 +887,106 @@ export const createAuthRouter = (
876
887
  .set({ name, updatedAt: new Date() })
877
888
  .where(eq(schema.user.id, userId));
878
889
  }
890
+ } else {
891
+ // Check if registration is allowed before creating new user
892
+ const registrationAllowed = await isRegistrationAllowed(configService);
893
+ if (!registrationAllowed) {
894
+ throw new ORPCError("FORBIDDEN", {
895
+ message:
896
+ "Registration is disabled. Please contact an administrator.",
897
+ });
898
+ }
879
899
 
880
- return { userId, created: false };
881
- }
900
+ // Create new user and account in a transaction
901
+ userId = crypto.randomUUID();
902
+ const accountEntryId = crypto.randomUUID();
903
+ const now = new Date();
904
+
905
+ await internalDb.transaction(async (tx) => {
906
+ // Create user
907
+ await tx.insert(schema.user).values({
908
+ id: userId,
909
+ email,
910
+ name,
911
+ emailVerified: false,
912
+ createdAt: now,
913
+ updatedAt: now,
914
+ });
882
915
 
883
- // Check if registration is allowed before creating new user
884
- const registrationAllowed = await isRegistrationAllowed(configService);
885
- if (!registrationAllowed) {
886
- throw new ORPCError("FORBIDDEN", {
887
- message: "Registration is disabled. Please contact an administrator.",
916
+ // Create account
917
+ await tx.insert(schema.account).values({
918
+ id: accountEntryId,
919
+ accountId,
920
+ providerId,
921
+ userId,
922
+ password,
923
+ createdAt: now,
924
+ updatedAt: now,
925
+ });
888
926
  });
889
- }
890
927
 
891
- // Create new user and account in a transaction
892
- const userId = crypto.randomUUID();
893
- const accountEntryId = crypto.randomUUID();
894
- const now = new Date();
928
+ context.logger.info(`Created new user from ${providerId}: ${email}`);
929
+ created = true;
930
+ }
895
931
 
896
- await internalDb.transaction(async (tx) => {
897
- // Create user
898
- await tx.insert(schema.user).values({
899
- id: userId,
900
- email,
901
- name,
902
- emailVerified: false,
903
- createdAt: now,
904
- updatedAt: now,
905
- });
932
+ // Handle role sync if syncRoles is provided
933
+ // Uses managedRoleIds to determine which roles are controlled by directory
934
+ if (syncRoles) {
935
+ const syncRoleSet = new Set(syncRoles);
936
+
937
+ // Validate which sync roles actually exist in the database
938
+ const validSyncRoles =
939
+ syncRoles.length > 0
940
+ ? await internalDb
941
+ .select({ id: schema.role.id })
942
+ .from(schema.role)
943
+ .where(inArray(schema.role.id, syncRoles))
944
+ : [];
945
+ const validSyncRoleIds = new Set(validSyncRoles.map((r) => r.id));
946
+
947
+ // Get current user roles
948
+ const currentRoles = await internalDb
949
+ .select({ roleId: schema.userRole.roleId })
950
+ .from(schema.userRole)
951
+ .where(eq(schema.userRole.userId, userId));
952
+ const currentRoleIds = new Set(currentRoles.map((r) => r.roleId));
906
953
 
907
- // Create account
908
- await tx.insert(schema.account).values({
909
- id: accountEntryId,
910
- accountId,
911
- providerId,
912
- userId,
913
- password,
914
- createdAt: now,
915
- updatedAt: now,
916
- });
917
- });
954
+ // Add new roles that user should have
955
+ const rolesToAdd = [...validSyncRoleIds].filter(
956
+ (id) => !currentRoleIds.has(id),
957
+ );
958
+ if (rolesToAdd.length > 0) {
959
+ await internalDb
960
+ .insert(schema.userRole)
961
+ .values(rolesToAdd.map((roleId) => ({ userId, roleId })));
962
+ context.logger.info(
963
+ `Added ${rolesToAdd.length} roles for external user: ${email}`,
964
+ );
965
+ }
918
966
 
919
- context.logger.info(`Created new user from ${providerId}: ${email}`);
967
+ // Remove roles that are managed but user no longer has in directory
968
+ if (managedRoleIds && managedRoleIds.length > 0) {
969
+ // Roles to remove: currently has + is managed + NOT in sync roles
970
+ const rolesToRemove = [...currentRoleIds].filter(
971
+ (id) => managedRoleIds.includes(id) && !syncRoleSet.has(id),
972
+ );
973
+ if (rolesToRemove.length > 0) {
974
+ await internalDb
975
+ .delete(schema.userRole)
976
+ .where(
977
+ and(
978
+ eq(schema.userRole.userId, userId),
979
+ inArray(schema.userRole.roleId, rolesToRemove),
980
+ ),
981
+ );
982
+ context.logger.info(
983
+ `Removed ${rolesToRemove.length} managed roles for external user: ${email}`,
984
+ );
985
+ }
986
+ }
987
+ }
920
988
 
921
- return { userId, created: true };
989
+ return { userId, created };
922
990
  },
923
991
  );
924
992