@checkstack/auth-backend 0.3.0 → 0.4.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.
- package/CHANGELOG.md +25 -0
- package/package.json +1 -1
- package/src/router.test.ts +265 -0
- package/src/router.ts +106 -38
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @checkstack/auth-backend
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d94121b: Add group-to-role mapping for SAML and LDAP authentication
|
|
8
|
+
|
|
9
|
+
**Features:**
|
|
10
|
+
|
|
11
|
+
- SAML and LDAP users can now be automatically assigned Checkstack roles based on their directory group memberships
|
|
12
|
+
- Configure group mappings in the authentication strategy settings with dynamic role dropdowns
|
|
13
|
+
- Managed role sync: roles configured in mappings are fully synchronized (added when user gains group, removed when user leaves group)
|
|
14
|
+
- Unmanaged roles (manually assigned, not in any mapping) are preserved during sync
|
|
15
|
+
- Optional default role for all users from a directory
|
|
16
|
+
|
|
17
|
+
**Bug Fix:**
|
|
18
|
+
|
|
19
|
+
- Fixed `x-options-resolver` not working for fields inside arrays with `.default([])` in DynamicForm schemas
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [d94121b]
|
|
24
|
+
- @checkstack/backend-api@0.3.3
|
|
25
|
+
- @checkstack/auth-common@0.5.0
|
|
26
|
+
- @checkstack/command-backend@0.1.3
|
|
27
|
+
|
|
3
28
|
## 0.3.0
|
|
4
29
|
|
|
5
30
|
### Minor Changes
|
package/package.json
CHANGED
package/src/router.test.ts
CHANGED
|
@@ -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 {
|
|
860
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
const now = new Date();
|
|
928
|
+
context.logger.info(`Created new user from ${providerId}: ${email}`);
|
|
929
|
+
created = true;
|
|
930
|
+
}
|
|
895
931
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
//
|
|
908
|
-
|
|
909
|
-
id
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
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
|
|
989
|
+
return { userId, created };
|
|
922
990
|
},
|
|
923
991
|
);
|
|
924
992
|
|