Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { PlanProvider } from "@/features/entitlements/planProvider";
import { getEntitlements } from "@/lib/entitlements";
import { IdentityProvidersProvider } from "@/features/auth/identityProvidersProvider";
import { getIdentityProviderMetadata } from "@/lib/identityProviders";
import { SentryUserProvider } from "./sentryUserProvider";

export const metadata: Metadata = {
metadataBase: env.AUTH_URL ? new URL(env.AUTH_URL) : undefined,
Expand Down Expand Up @@ -74,6 +75,9 @@ export default async function RootLayout({
<body>
<Toaster />
<SessionProvider>
<SentryUserProvider
isPiiEnabled={env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED === 'true'}
/>
<PlanProvider entitlements={entitlements}>
<IdentityProvidersProvider providers={identityProviders}>
<PostHogProvider
Expand Down Expand Up @@ -106,4 +110,4 @@ export default async function RootLayout({
</body>
</html>
);
}
}
52 changes: 52 additions & 0 deletions packages/web/src/app/sentryUserProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
setSentryUser: vi.fn(),
useSession: vi.fn(),
}));

vi.mock('@/lib/sentryUser', () => ({
setSentryUser: mocks.setSentryUser,
}));

vi.mock('next-auth/react', () => ({
useSession: mocks.useSession,
}));

const { SentryUserProvider } = await import('./sentryUserProvider');

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe('SentryUserProvider', () => {
test('waits for the session before changing the Sentry user', () => {
mocks.useSession.mockReturnValue({ data: undefined, status: 'loading' });

render(<SentryUserProvider isPiiEnabled={false} />);

expect(mocks.setSentryUser).not.toHaveBeenCalled();
});

test('sets and clears the Sentry user as authentication changes', () => {
const user = {
id: 'user-1',
email: 'user@example.com',
name: 'Example User',
};
mocks.useSession.mockReturnValue({
data: { user },
status: 'authenticated',
});

const { rerender } = render(<SentryUserProvider isPiiEnabled={true} />);
expect(mocks.setSentryUser).toHaveBeenLastCalledWith(user, true);

mocks.useSession.mockReturnValue({ data: null, status: 'unauthenticated' });
rerender(<SentryUserProvider isPiiEnabled={true} />);

expect(mocks.setSentryUser).toHaveBeenLastCalledWith(null, true);
});
});
23 changes: 23 additions & 0 deletions packages/web/src/app/sentryUserProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';

import { setSentryUser } from '@/lib/sentryUser';
import { useSession } from 'next-auth/react';
import { useEffect } from 'react';

interface SentryUserProviderProps {
isPiiEnabled: boolean;
}

export function SentryUserProvider({ isPiiEnabled }: SentryUserProviderProps) {
const { data: session, status } = useSession();

useEffect(() => {
if (status === 'loading') {
return;
}

setSentryUser(session?.user ?? null, isPiiEnabled);
}, [isPiiEnabled, session, status]);

return null;
}
8 changes: 7 additions & 1 deletion packages/web/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { getAnonymousId } from '@/lib/anonymousId';
import { captureEvent } from '@/lib/posthog';
import { isEmailCodeLoginEnabled, isCredentialsLoginEnabled } from '@sourcebot/shared'
import { onCreateUser } from './features/membership/onCreateUser';
import { setSentryUser } from './lib/sentryUser';

export const runtime = 'nodejs';

Expand Down Expand Up @@ -452,7 +453,12 @@ export const { handlers, signIn, signOut } = nextAuthResult;
* without re-running the upstream resolver.
*/
export const auth = cache(async (): Promise<Session | null> => {
return nextAuthResult.auth();
const session = await nextAuthResult.auth();
setSentryUser(
session?.user ?? null,
env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED === 'true',
);
return session;
});

/**
Expand Down
45 changes: 45 additions & 0 deletions packages/web/src/lib/sentryUser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';

const setUser = vi.fn();

vi.mock('@sentry/nextjs', () => ({
setUser,
}));

const { setSentryUser } = await import('./sentryUser');

describe('setSentryUser', () => {
beforeEach(() => {
setUser.mockClear();
});

test('associates errors with the user id without PII by default', () => {
setSentryUser({
id: 'user-1',
email: 'user@example.com',
name: 'Example User',
}, false);

expect(setUser).toHaveBeenCalledWith({ id: 'user-1' });
});

test('includes user details when PII collection is enabled', () => {
setSentryUser({
id: 'user-1',
email: 'user@example.com',
name: 'Example User',
}, true);

expect(setUser).toHaveBeenCalledWith({
id: 'user-1',
email: 'user@example.com',
username: 'Example User',
});
});

test('clears the user for unauthenticated requests', () => {
setSentryUser(null, true);

expect(setUser).toHaveBeenCalledWith(null);
});
});
27 changes: 27 additions & 0 deletions packages/web/src/lib/sentryUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as Sentry from "@sentry/nextjs";

type SentryUser = {
id: string;
email?: string | null;
name?: string | null;
};

/**
* Associates subsequent Sentry events with the current user, including their
* email and name only when PII collection is enabled. If Sentry is not
* configured, this is effectively a no-op and does not send any data.
*/
export function setSentryUser(user: SentryUser | null, isPiiEnabled: boolean) {
if (!user) {
Sentry.setUser(null);
return;
}

Sentry.setUser({
id: user.id,
...(isPiiEnabled ? {
email: user.email ?? undefined,
username: user.name ?? undefined,
} : {}),
});
}
42 changes: 42 additions & 0 deletions packages/web/src/middleware/withAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => {
isAnonymousAccessAvailable: vi.fn(() => false),
syncWithLighthouse: vi.fn(async (_orgId: number) => undefined),
getSeatCap: vi.fn(() => undefined as number | undefined),
setSentryUser: vi.fn(),
env: {} as Record<string, string>,
}
});
Expand All @@ -29,6 +30,10 @@ vi.mock('../auth', () => ({
auth: mocks.auth,
}));

vi.mock('@/lib/sentryUser', () => ({
setSentryUser: mocks.setSentryUser,
}));

vi.mock('next/headers', () => ({
headers: mocks.headers,
}));
Expand Down Expand Up @@ -390,6 +395,39 @@ describe('getAuthenticatedUser', () => {
});

describe('getAuthContext', () => {
test('sets the Sentry user for direct callers', async () => {
const userId = 'test-user-id';
const user = {
...MOCK_USER_WITH_ACCOUNTS,
id: userId,
};
prisma.user.findUnique.mockResolvedValue(user);
prisma.org.findUnique.mockResolvedValue(MOCK_ORG);
prisma.userToOrg.findUnique.mockResolvedValue({
joinedAt: new Date(),
userId,
orgId: MOCK_ORG.id,
suspendedAt: null,
scimExternalId: null,
lastActiveAt: new Date(),
role: OrgRole.MEMBER,
});
mocks.env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED = 'true';
setMockSession(createMockSession({ user: { id: userId } }));

await getAuthContext();

expect(mocks.setSentryUser).toHaveBeenCalledWith(user, true);
});

test('clears the Sentry user for unauthenticated direct callers', async () => {
prisma.org.findUnique.mockResolvedValue(MOCK_ORG);

await getAuthContext();

expect(mocks.setSentryUser).toHaveBeenCalledWith(null, false);
});

test('should return a auth context object if a valid session is present and the user is a member of the organization', async () => {
const userId = 'test-user-id';
prisma.user.findUnique.mockResolvedValue({
Expand Down Expand Up @@ -734,6 +772,10 @@ describe('getAuthContext', () => {
org: MOCK_ORG,
prisma: undefined,
});
expect(mocks.setSentryUser).toHaveBeenCalledWith(
expect.objectContaining({ id: userId }),
false,
);
});

describe('DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS', () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/web/src/middleware/withAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { activatePendingMembership } from "@/features/membership/membership.serv
import { hasRequiredOAuthScopes, parseOAuthScopeString } from "@/ee/features/oauth/utils";
import { DPOP_AUTH_SCHEME, DPOP_PROOF_HEADER, verifyDpopProof } from "@/ee/features/oauth/dpop";
import { getCurrentRequest } from "@/lib/requestContext";
import { setSentryUser } from "@/lib/sentryUser";

const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000;

Expand Down Expand Up @@ -70,6 +71,12 @@ export const withOptionalAuth = async <T>(fn: (params: OptionalAuthContext) => P

export const getAuthContext = async (options: AuthOptions = {}): Promise<OptionalAuthContext | ServiceError> => {
const authResult = await getAuthenticatedUser();
const user = authResult?.user;

setSentryUser(
user ?? null,
env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED === 'true',
);

const org = await __unsafePrisma.org.findUnique({
where: {
Expand All @@ -81,8 +88,6 @@ export const getAuthContext = async (options: AuthOptions = {}): Promise<Optiona
return notFound("Organization not found");
}

const user = authResult?.user;

const membership = user ? await __unsafePrisma.userToOrg.findUnique({
where: {
orgId_userId: {
Expand Down
Loading