diff --git a/apps/server/src/api/v2/slack/services/slack-authentication.test.ts b/apps/server/src/api/v2/slack/services/slack-authentication.test.ts index 44fd551fe..5149778cd 100644 --- a/apps/server/src/api/v2/slack/services/slack-authentication.test.ts +++ b/apps/server/src/api/v2/slack/services/slack-authentication.test.ts @@ -20,7 +20,7 @@ describe('slack-authentication', () => { const mockIntegration = { id: 'int-123', organizationId: 'org-123', - userId: 'user-123', + userId: 'installer-123', tokenVaultKey: 'token-key', }; @@ -40,7 +40,7 @@ describe('slack-authentication', () => { mockIntegration as any ); vi.mocked(SlackHelpers.getAccessToken).mockResolvedValue('slack-token'); - vi.mocked(SlackHelpers.getUserById).mockResolvedValue(mockUser as any); + vi.mocked(SlackHelpers.getUserByEmail).mockResolvedValue(mockUser as any); const mockSlackUserService = { isBot: vi.fn().mockResolvedValue(false), @@ -76,6 +76,11 @@ describe('slack-authentication', () => { expect(mockSlackUserService.isBot).toHaveBeenCalledWith('slack-token', 'U123456'); expect(mockSlackUserService.isDeleted).toHaveBeenCalledWith('slack-token', 'U123456'); expect(mockSlackUserService.getUserInfo).toHaveBeenCalledWith('slack-token', 'U123456'); + expect(vi.mocked(SlackHelpers.getUserByEmail)).toHaveBeenCalledWith('john@example.com'); + expect(vi.mocked(accessControls.checkUserInOrganization)).toHaveBeenCalledWith( + 'user-123', + 'org-123' + ); }); it('should return unauthorized for bot users', async () => { @@ -152,14 +157,21 @@ describe('slack-authentication', () => { const mockIntegration = { id: 'int-123', organizationId: 'org-123', - userId: 'user-123', + userId: 'installer-123', tokenVaultKey: 'token-key', }; + const mockUser = { + id: 'user-123', + email: 'john@example.com', + name: 'John Doe', + }; + vi.mocked(SlackHelpers.getActiveIntegrationByTeamId).mockResolvedValue( mockIntegration as any ); vi.mocked(SlackHelpers.getAccessToken).mockResolvedValue('slack-token'); + vi.mocked(SlackHelpers.getUserByEmail).mockResolvedValue(mockUser as any); const mockSlackUserService = { isBot: vi.fn().mockResolvedValue(false), @@ -215,6 +227,7 @@ describe('slack-authentication', () => { mockIntegration as any ); vi.mocked(SlackHelpers.getAccessToken).mockResolvedValue('slack-token'); + vi.mocked(SlackHelpers.getUserByEmail).mockResolvedValue(null); // No existing user const mockSlackUserService = { isBot: vi.fn().mockResolvedValue(false), @@ -230,7 +243,6 @@ describe('slack-authentication', () => { }; vi.mocked(SlackUserService).mockImplementation(() => mockSlackUserService as any); - vi.mocked(accessControls.checkUserInOrganization).mockResolvedValue(null); vi.mocked(accessControls.getOrganizationWithDefaults).mockResolvedValue(mockOrg as any); vi.mocked(accessControls.checkEmailDomainForOrganization).mockResolvedValue(true); vi.mocked(accessControls.createUserInOrganization).mockResolvedValue({ @@ -259,6 +271,69 @@ describe('slack-authentication', () => { ); }); + it('should auto-provision existing user when they are not in org but domain matches', async () => { + const mockIntegration = { + id: 'int-123', + organizationId: 'org-123', + userId: 'installer-123', + tokenVaultKey: 'token-key', + }; + + const mockOrg = { + id: 'org-123', + name: 'Example Org', + defaultRole: 'restricted_querier', + domains: ['example.com'], + }; + + const mockExistingUser = { + id: 'existing-user-123', + email: 'existing@example.com', + name: 'Existing User', + }; + + vi.mocked(SlackHelpers.getActiveIntegrationByTeamId).mockResolvedValue( + mockIntegration as any + ); + vi.mocked(SlackHelpers.getAccessToken).mockResolvedValue('slack-token'); + vi.mocked(SlackHelpers.getUserByEmail).mockResolvedValue(mockExistingUser as any); + + const mockSlackUserService = { + isBot: vi.fn().mockResolvedValue(false), + isDeleted: vi.fn().mockResolvedValue(false), + getUserInfo: vi.fn().mockResolvedValue({ + id: 'U123456', + real_name: 'Existing User', + name: 'existing', + profile: { + email: 'existing@example.com', + }, + }), + }; + vi.mocked(SlackUserService).mockImplementation(() => mockSlackUserService as any); + + vi.mocked(accessControls.checkUserInOrganization).mockResolvedValue(null); // Not in org + vi.mocked(accessControls.getOrganizationWithDefaults).mockResolvedValue(mockOrg as any); + vi.mocked(accessControls.checkEmailDomainForOrganization).mockResolvedValue(true); + vi.mocked(accessControls.createUserInOrganization).mockResolvedValue({ + user: mockExistingUser as any, + membership: { + userId: 'existing-user-123', + organizationId: 'org-123', + role: 'restricted_querier', + status: 'active', + }, + }); + + const result = await authenticateSlackUser('U123456', 'T123456'); + + expect(result).toEqual({ + type: 'auto_provisioned', + user: mockExistingUser, + organization: mockOrg, + }); + }); + it('should return unauthorized when domain does not match', async () => { const mockIntegration = { id: 'int-123', @@ -278,6 +353,7 @@ describe('slack-authentication', () => { mockIntegration as any ); vi.mocked(SlackHelpers.getAccessToken).mockResolvedValue('slack-token'); + vi.mocked(SlackHelpers.getUserByEmail).mockResolvedValue(null); // No existing user const mockSlackUserService = { isBot: vi.fn().mockResolvedValue(false), @@ -293,7 +369,6 @@ describe('slack-authentication', () => { }; vi.mocked(SlackUserService).mockImplementation(() => mockSlackUserService as any); - vi.mocked(accessControls.checkUserInOrganization).mockResolvedValue(null); vi.mocked(accessControls.getOrganizationWithDefaults).mockResolvedValue(mockOrg as any); vi.mocked(accessControls.checkEmailDomainForOrganization).mockResolvedValue(false); diff --git a/apps/server/src/api/v2/slack/services/slack-authentication.ts b/apps/server/src/api/v2/slack/services/slack-authentication.ts index d8e8b449c..27d840052 100644 --- a/apps/server/src/api/v2/slack/services/slack-authentication.ts +++ b/apps/server/src/api/v2/slack/services/slack-authentication.ts @@ -98,39 +98,42 @@ export async function authenticateSlackUser( }; } - // Check if user exists in the organization - const userOrgInfo = await checkUserInOrganization( - integration.userId, - integration.organizationId - ); + // Check if a Buster user exists with this email + const existingUser = await SlackHelpers.getUserByEmail(userEmail); + + if (existingUser) { + // User exists - check if they belong to this organization + const userOrgInfo = await checkUserInOrganization( + existingUser.id, + integration.organizationId + ); + + if (userOrgInfo) { + // User exists in organization - check their status + if (userOrgInfo.status !== 'active') { + return { + type: 'unauthorized', + reason: `User account is ${userOrgInfo.status}. Please contact your administrator.`, + }; + } + + // Get organization data + const organization = await getOrganizationWithDefaults(integration.organizationId); + + if (!organization) { + return { + type: 'unauthorized', + reason: 'Failed to load organization data', + }; + } - if (userOrgInfo) { - // User exists - check their status - if (userOrgInfo.status !== 'active') { return { - type: 'unauthorized', - reason: `User account is ${userOrgInfo.status}. Please contact your administrator.`, + type: 'authorized', + user: existingUser, + organization, }; } - - // Get full user and organization data concurrently - const [user, organization] = await Promise.all([ - SlackHelpers.getUserById(integration.userId), - getOrganizationWithDefaults(integration.organizationId), - ]); - - if (!user || !organization) { - return { - type: 'unauthorized', - reason: 'Failed to load user or organization data', - }; - } - - return { - type: 'authorized', - user, - organization, - }; + // User exists but not in this organization - fall through to domain check } // User doesn't exist - check if we can auto-provision diff --git a/apps/server/src/api/v2/slack/services/slack-helpers.ts b/apps/server/src/api/v2/slack/services/slack-helpers.ts index 422bce739..907cd95d8 100644 --- a/apps/server/src/api/v2/slack/services/slack-helpers.ts +++ b/apps/server/src/api/v2/slack/services/slack-helpers.ts @@ -460,6 +460,26 @@ export async function getUserById(userId: string): Promise { } } +/** + * Get user by email address + */ +export async function getUserByEmail(email: string): Promise { + try { + const [user] = await db + .select() + .from(users) + .where(and(eq(users.email, email))) + .limit(1); + + return user || null; + } catch (error) { + console.error('Failed to get user by email:', error); + throw new Error( + `Failed to get user by email: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + // Export as namespace for easier import export const SlackHelpers = { getActiveIntegration, @@ -477,4 +497,5 @@ export const SlackHelpers = { getActiveIntegrationByTeamId, getAccessToken, getUserById, + getUserByEmail, };