diff --git a/CHANGELOG.md b/CHANGELOG.md index b195c482c..73092cb23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed issue with an unbounded `Promise.allSettled(...)` when retrieving details from the GitHub API about a large number of repositories (or orgs or users). [#591](https://github.com/sourcebot-dev/sourcebot/pull/591) - Fixed resource exhaustion (EAGAIN errors) when syncing generic-git-host connections with thousands of repositories. [#593](https://github.com/sourcebot-dev/sourcebot/pull/593) -## Removed +### Removed - Removed built-in secret manager. [#592](https://github.com/sourcebot-dev/sourcebot/pull/592) +### Changed +- Changed internal representation of how repo permissions are represented in the database. [#600](https://github.com/sourcebot-dev/sourcebot/pull/600) + ## [4.8.1] - 2025-10-29 ### Fixed diff --git a/packages/backend/src/ee/userPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts similarity index 51% rename from packages/backend/src/ee/userPermissionSyncer.ts rename to packages/backend/src/ee/accountPermissionSyncer.ts index f3069eafb..70ff0e132 100644 --- a/packages/backend/src/ee/userPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/node"; -import { PrismaClient, User, UserPermissionSyncJobStatus } from "@sourcebot/db"; +import { PrismaClient, AccountPermissionSyncJobStatus, Account } from "@sourcebot/db"; import { createLogger } from "@sourcebot/logger"; import { Job, Queue, Worker } from "bullmq"; import { Redis } from "ioredis"; @@ -14,16 +14,15 @@ const LOG_TAG = 'user-permission-syncer'; const logger = createLogger(LOG_TAG); const createJobLogger = (jobId: string) => createLogger(`${LOG_TAG}:job:${jobId}`); -const QUEUE_NAME = 'userPermissionSyncQueue'; +const QUEUE_NAME = 'accountPermissionSyncQueue'; -type UserPermissionSyncJob = { +type AccountPermissionSyncJob = { jobId: string; } - -export class UserPermissionSyncer { - private queue: Queue; - private worker: Worker; +export class AccountPermissionSyncer { + private queue: Queue; + private worker: Worker; private interval?: NodeJS.Timeout; constructor( @@ -31,10 +30,10 @@ export class UserPermissionSyncer { private settings: Settings, redis: Redis, ) { - this.queue = new Queue(QUEUE_NAME, { + this.queue = new Queue(QUEUE_NAME, { connection: redis, }); - this.worker = new Worker(QUEUE_NAME, this.runJob.bind(this), { + this.worker = new Worker(QUEUE_NAME, this.runJob.bind(this), { connection: redis, concurrency: 1, }); @@ -52,16 +51,12 @@ export class UserPermissionSyncer { this.interval = setInterval(async () => { const thresholdDate = new Date(Date.now() - this.settings.experiment_userDrivenPermissionSyncIntervalMs); - const users = await this.db.user.findMany({ + const accounts = await this.db.account.findMany({ where: { AND: [ { - accounts: { - some: { - provider: { - in: PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES - } - } + provider: { + in: PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } }, { @@ -79,15 +74,15 @@ export class UserPermissionSyncer { { status: { in: [ - UserPermissionSyncJobStatus.PENDING, - UserPermissionSyncJobStatus.IN_PROGRESS, + AccountPermissionSyncJobStatus.PENDING, + AccountPermissionSyncJobStatus.IN_PROGRESS, ], } }, // Don't schedule if there are recent failed jobs (within the threshold date). Note `gt` is used here since this is a inverse condition. { AND: [ - { status: UserPermissionSyncJobStatus.FAILED }, + { status: AccountPermissionSyncJobStatus.FAILED }, { completedAt: { gt: thresholdDate } }, ] } @@ -100,7 +95,7 @@ export class UserPermissionSyncer { } }); - await this.schedulePermissionSync(users); + await this.schedulePermissionSync(accounts); }, 1000 * 5); } @@ -112,18 +107,18 @@ export class UserPermissionSyncer { await this.queue.close(); } - private async schedulePermissionSync(users: User[]) { + private async schedulePermissionSync(accounts: Account[]) { // @note: we don't perform this in a transaction because // we want to avoid the situation where a job is created and run // prior to the transaction being committed. - const jobs = await this.db.userPermissionSyncJob.createManyAndReturn({ - data: users.map(user => ({ - userId: user.id, + const jobs = await this.db.accountPermissionSyncJob.createManyAndReturn({ + data: accounts.map(account => ({ + accountId: account.id, })), }); await this.queue.addBulk(jobs.map((job) => ({ - name: 'userPermissionSyncJob', + name: 'accountPermissionSyncJob', data: { jobId: job.id, }, @@ -134,103 +129,97 @@ export class UserPermissionSyncer { }))) } - private async runJob(job: Job) { + private async runJob(job: Job) { const id = job.data.jobId; const logger = createJobLogger(id); - const { user } = await this.db.userPermissionSyncJob.update({ + const { account } = await this.db.accountPermissionSyncJob.update({ where: { id, }, data: { - status: UserPermissionSyncJobStatus.IN_PROGRESS, + status: AccountPermissionSyncJobStatus.IN_PROGRESS, }, select: { - user: { + account: { include: { - accounts: true, + user: true, } } } }); - if (!user) { - throw new Error(`User ${id} not found`); - } - - logger.info(`Syncing permissions for user ${user.email}...`); + logger.info(`Syncing permissions for ${account.provider} account (id: ${account.id}) for user ${account.user.email}...`); // Get a list of all repos that the user has access to from all connected accounts. const repoIds = await (async () => { const aggregatedRepoIds: Set = new Set(); - for (const account of user.accounts) { - if (account.provider === 'github') { - if (!account.access_token) { - throw new Error(`User '${user.email}' does not have an GitHub OAuth access token associated with their GitHub account.`); - } + if (account.provider === 'github') { + if (!account.access_token) { + throw new Error(`User '${account.user.email}' does not have an GitHub OAuth access token associated with their GitHub account.`); + } - const { octokit } = await createOctokitFromToken({ - token: account.access_token, - url: env.AUTH_EE_GITHUB_BASE_URL, - }); - // @note: we only care about the private repos since we don't need to build a mapping - // for public repos. - // @see: packages/web/src/prisma.ts - const githubRepos = await getReposForAuthenticatedUser(/* visibility = */ 'private', octokit); - const gitHubRepoIds = githubRepos.map(repo => repo.id.toString()); - - const repos = await this.db.repo.findMany({ - where: { - external_codeHostType: 'github', - external_id: { - in: gitHubRepoIds, - } + const { octokit } = await createOctokitFromToken({ + token: account.access_token, + url: env.AUTH_EE_GITHUB_BASE_URL, + }); + // @note: we only care about the private repos since we don't need to build a mapping + // for public repos. + // @see: packages/web/src/prisma.ts + const githubRepos = await getReposForAuthenticatedUser(/* visibility = */ 'private', octokit); + const gitHubRepoIds = githubRepos.map(repo => repo.id.toString()); + + const repos = await this.db.repo.findMany({ + where: { + external_codeHostType: 'github', + external_id: { + in: gitHubRepoIds, } - }); - - repos.forEach(repo => aggregatedRepoIds.add(repo.id)); - } else if (account.provider === 'gitlab') { - if (!account.access_token) { - throw new Error(`User '${user.email}' does not have a GitLab OAuth access token associated with their GitLab account.`); } + }); - const api = await createGitLabFromOAuthToken({ - oauthToken: account.access_token, - url: env.AUTH_EE_GITLAB_BASE_URL, - }); - - // @note: we only care about the private and internal repos since we don't need to build a mapping - // for public repos. - // @see: packages/web/src/prisma.ts - const privateGitLabProjects = await getProjectsForAuthenticatedUser('private', api); - const internalGitLabProjects = await getProjectsForAuthenticatedUser('internal', api); - - const gitLabProjectIds = [ - ...privateGitLabProjects, - ...internalGitLabProjects, - ].map(project => project.id.toString()); - - const repos = await this.db.repo.findMany({ - where: { - external_codeHostType: 'gitlab', - external_id: { - in: gitLabProjectIds, - } + repos.forEach(repo => aggregatedRepoIds.add(repo.id)); + } else if (account.provider === 'gitlab') { + if (!account.access_token) { + throw new Error(`User '${account.user.email}' does not have a GitLab OAuth access token associated with their GitLab account.`); + } + + const api = await createGitLabFromOAuthToken({ + oauthToken: account.access_token, + url: env.AUTH_EE_GITLAB_BASE_URL, + }); + + // @note: we only care about the private and internal repos since we don't need to build a mapping + // for public repos. + // @see: packages/web/src/prisma.ts + const privateGitLabProjects = await getProjectsForAuthenticatedUser('private', api); + const internalGitLabProjects = await getProjectsForAuthenticatedUser('internal', api); + + const gitLabProjectIds = [ + ...privateGitLabProjects, + ...internalGitLabProjects, + ].map(project => project.id.toString()); + + const repos = await this.db.repo.findMany({ + where: { + external_codeHostType: 'gitlab', + external_id: { + in: gitLabProjectIds, } - }); + } + }); - repos.forEach(repo => aggregatedRepoIds.add(repo.id)); - } + repos.forEach(repo => aggregatedRepoIds.add(repo.id)); } return Array.from(aggregatedRepoIds); })(); await this.db.$transaction([ - this.db.user.update({ + this.db.account.update({ where: { - id: user.id, + id: account.id, }, data: { accessibleRepos: { @@ -238,9 +227,9 @@ export class UserPermissionSyncer { } } }), - this.db.userToRepoPermission.createMany({ + this.db.accountToRepoPermission.createMany({ data: repoIds.map(repoId => ({ - userId: user.id, + accountId: account.id, repoId, })), skipDuplicates: true, @@ -248,31 +237,35 @@ export class UserPermissionSyncer { ]); } - private async onJobCompleted(job: Job) { + private async onJobCompleted(job: Job) { const logger = createJobLogger(job.data.jobId); - const { user } = await this.db.userPermissionSyncJob.update({ + const { account } = await this.db.accountPermissionSyncJob.update({ where: { id: job.data.jobId, }, data: { - status: UserPermissionSyncJobStatus.COMPLETED, - user: { + status: AccountPermissionSyncJobStatus.COMPLETED, + account: { update: { permissionSyncedAt: new Date(), - } + }, }, completedAt: new Date(), }, select: { - user: true + account: { + include: { + user: true, + } + } } }); - logger.info(`Permissions synced for user ${user.email}`); + logger.info(`Permissions synced for ${account.provider} account (id: ${account.id}) for user ${account.user.email}`); } - private async onJobFailed(job: Job | undefined, err: Error) { + private async onJobFailed(job: Job | undefined, err: Error) { const logger = createJobLogger(job?.data.jobId ?? 'unknown'); Sentry.captureException(err, { @@ -282,26 +275,30 @@ export class UserPermissionSyncer { } }); - const errorMessage = (email: string) => `User permission sync job failed for user ${email}: ${err.message}`; + const errorMessage = (accountId: string, email: string) => `Account permission sync job failed for account (id: ${accountId}) for user ${email}: ${err.message}`; if (job) { - const { user } = await this.db.userPermissionSyncJob.update({ + const { account } = await this.db.accountPermissionSyncJob.update({ where: { id: job.data.jobId, }, data: { - status: UserPermissionSyncJobStatus.FAILED, + status: AccountPermissionSyncJobStatus.FAILED, completedAt: new Date(), errorMessage: err.message, }, select: { - user: true, + account: { + include: { + user: true, + } + } } }); - logger.error(errorMessage(user.email ?? user.id)); + logger.error(errorMessage(account.id, account.user.email ?? 'unknown user (email not found)')); } else { - logger.error(errorMessage('unknown job (id not found)')); + logger.error(errorMessage('unknown account (id not found)', 'unknown user (id not found)')); } } } \ No newline at end of file diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index 9a1593d6f..2e9be5f49 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -168,7 +168,7 @@ export class RepoPermissionSyncer { throw new Error(`No credentials found for repo ${id}`); } - const userIds = await (async () => { + const accountIds = await (async () => { if (repo.external_codeHostType === 'github') { const isGitHubCloud = credentials.hostUrl ? new URL(credentials.hostUrl).hostname === GITHUB_CLOUD_HOSTNAME : false; const { octokit } = await createOctokitFromToken({ @@ -195,12 +195,9 @@ export class RepoPermissionSyncer { in: githubUserIds, } }, - select: { - userId: true, - }, }); - return accounts.map(account => account.userId); + return accounts.map(account => account.id); } else if (repo.external_codeHostType === 'gitlab') { const api = await createGitLabFromPersonalAccessToken({ token: credentials.token, @@ -222,12 +219,9 @@ export class RepoPermissionSyncer { in: gitlabUserIds, } }, - select: { - userId: true, - }, }); - return accounts.map(account => account.userId); + return accounts.map(account => account.id); } return []; @@ -239,14 +233,14 @@ export class RepoPermissionSyncer { id: repo.id, }, data: { - permittedUsers: { + permittedAccounts: { deleteMany: {}, } } }), - this.db.userToRepoPermission.createMany({ - data: userIds.map(userId => ({ - userId, + this.db.accountToRepoPermission.createMany({ + data: accountIds.map(accountId => ({ + accountId, repoId: repo.id, })), }) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index fd7847415..ed66a390f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -11,7 +11,7 @@ import { ConnectionManager } from './connectionManager.js'; import { INDEX_CACHE_DIR, REPOS_CACHE_DIR } from './constants.js'; import { GithubAppManager } from "./ee/githubAppManager.js"; import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js'; -import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js"; +import { AccountPermissionSyncer } from "./ee/accountPermissionSyncer.js"; import { env } from "./env.js"; import { PromClient } from './promClient.js'; import { RepoIndexManager } from "./repoIndexManager.js"; @@ -52,7 +52,7 @@ if (hasEntitlement('github-app')) { const connectionManager = new ConnectionManager(prisma, settings, redis, promClient); const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis); -const userPermissionSyncer = new UserPermissionSyncer(prisma, settings, redis); +const accountPermissionSyncer = new AccountPermissionSyncer(prisma, settings, redis); const repoIndexManager = new RepoIndexManager(prisma, settings, redis, promClient); const configManager = new ConfigManager(prisma, connectionManager, env.CONFIG_PATH); @@ -65,7 +65,7 @@ if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && !hasEntitlement('per } else if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) { repoPermissionSyncer.startScheduler(); - userPermissionSyncer.startScheduler(); + accountPermissionSyncer.startScheduler(); } logger.info('Worker started.'); @@ -81,7 +81,7 @@ const cleanup = async (signal: string) => { repoIndexManager.dispose(), connectionManager.dispose(), repoPermissionSyncer.dispose(), - userPermissionSyncer.dispose(), + accountPermissionSyncer.dispose(), promClient.dispose(), configManager.dispose(), ]), diff --git a/packages/db/prisma/migrations/20251105021913_move_permission_syncing_to_account_level/migration.sql b/packages/db/prisma/migrations/20251105021913_move_permission_syncing_to_account_level/migration.sql new file mode 100644 index 000000000..dbb578fa3 --- /dev/null +++ b/packages/db/prisma/migrations/20251105021913_move_permission_syncing_to_account_level/migration.sql @@ -0,0 +1,65 @@ +/* + Warnings: + + - You are about to drop the column `permissionSyncedAt` on the `User` table. All the data in the column will be lost. + - You are about to drop the `UserPermissionSyncJob` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `UserToRepoPermission` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- CreateEnum +CREATE TYPE "AccountPermissionSyncJobStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED'); + +-- DropForeignKey +ALTER TABLE "UserPermissionSyncJob" DROP CONSTRAINT "UserPermissionSyncJob_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "UserToRepoPermission" DROP CONSTRAINT "UserToRepoPermission_repoId_fkey"; + +-- DropForeignKey +ALTER TABLE "UserToRepoPermission" DROP CONSTRAINT "UserToRepoPermission_userId_fkey"; + +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "permissionSyncedAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "permissionSyncedAt"; + +-- DropTable +DROP TABLE "UserPermissionSyncJob"; + +-- DropTable +DROP TABLE "UserToRepoPermission"; + +-- DropEnum +DROP TYPE "UserPermissionSyncJobStatus"; + +-- CreateTable +CREATE TABLE "AccountPermissionSyncJob" ( + "id" TEXT NOT NULL, + "status" "AccountPermissionSyncJobStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "errorMessage" TEXT, + "accountId" TEXT NOT NULL, + + CONSTRAINT "AccountPermissionSyncJob_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AccountToRepoPermission" ( + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "repoId" INTEGER NOT NULL, + "accountId" TEXT NOT NULL, + + CONSTRAINT "AccountToRepoPermission_pkey" PRIMARY KEY ("repoId","accountId") +); + +-- AddForeignKey +ALTER TABLE "AccountPermissionSyncJob" ADD CONSTRAINT "AccountPermissionSyncJob_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccountToRepoPermission" ADD CONSTRAINT "AccountToRepoPermission_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccountToRepoPermission" ADD CONSTRAINT "AccountToRepoPermission_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index d182eec4e..2e87ad4fc 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -59,7 +59,7 @@ model Repo { connections RepoToConnection[] imageUrl String? - permittedUsers UserToRepoPermission[] + permittedAccounts AccountToRepoPermission[] permissionSyncJobs RepoPermissionSyncJob[] permissionSyncedAt DateTime? /// When the permissions were last synced successfully. @@ -349,7 +349,6 @@ model User { accounts Account[] orgs UserToOrg[] accountRequest AccountRequest? - accessibleRepos UserToRepoPermission[] /// List of pending invites that the user has created invites Invite[] @@ -361,40 +360,38 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - permissionSyncJobs UserPermissionSyncJob[] - permissionSyncedAt DateTime? } -enum UserPermissionSyncJobStatus { +enum AccountPermissionSyncJobStatus { PENDING IN_PROGRESS COMPLETED FAILED } -model UserPermissionSyncJob { +model AccountPermissionSyncJob { id String @id @default(cuid()) - status UserPermissionSyncJobStatus @default(PENDING) + status AccountPermissionSyncJobStatus @default(PENDING) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt completedAt DateTime? errorMessage String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + accountId String } -model UserToRepoPermission { +model AccountToRepoPermission { createdAt DateTime @default(now()) repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade) repoId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + accountId String - @@id([repoId, userId]) + @@id([repoId, accountId]) } // @see : https://authjs.dev/concepts/database-models#account @@ -411,6 +408,12 @@ model Account { scope String? id_token String? session_state String? + + /// List of repos that this account has access to. + accessibleRepos AccountToRepoPermission[] + + permissionSyncJobs AccountPermissionSyncJob[] + permissionSyncedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index 4db4de460..473dab64e 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -1,5 +1,5 @@ import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants'; -import { ApiKey, Org, PrismaClient, User } from '@prisma/client'; +import { Account, ApiKey, Org, PrismaClient, User } from '@prisma/client'; import { beforeEach, vi } from 'vitest'; import { mockDeep, mockReset } from 'vitest-mock-extended'; @@ -35,7 +35,7 @@ export const MOCK_API_KEY: ApiKey = { createdById: '1', } -export const MOCK_USER: User = { +export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = { id: '1', name: 'Test User', email: 'test@test.com', @@ -44,7 +44,7 @@ export const MOCK_USER: User = { hashedPassword: null, emailVerified: null, image: null, - permissionSyncedAt: null + accounts: [], } export const userScopedPrismaClientExtension = vi.fn(); \ No newline at end of file diff --git a/packages/web/src/prisma.ts b/packages/web/src/prisma.ts index f4d253e9d..d9e488ceb 100644 --- a/packages/web/src/prisma.ts +++ b/packages/web/src/prisma.ts @@ -20,7 +20,7 @@ if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma * Creates a prisma client extension that scopes queries to striclty information * a given user should be able to access. */ -export const userScopedPrismaClientExtension = (userId?: string) => { +export const userScopedPrismaClientExtension = (accountIds?: string[]) => { return Prisma.defineExtension( (prisma) => { return prisma.$extends({ @@ -28,17 +28,21 @@ export const userScopedPrismaClientExtension = (userId?: string) => { ...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ? { repo: { async $allOperations({ args, query }) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const argsWithWhere = args as any; + const argsWithWhere = args as Record & { + where?: Prisma.RepoWhereInput; + } + argsWithWhere.where = { ...(argsWithWhere.where || {}), OR: [ // Only include repos that are permitted to the user - ...(userId ? [ + ...(accountIds ? [ { - permittedUsers: { + permittedAccounts: { some: { - userId, + accountId: { + in: accountIds, + } } } }, @@ -48,7 +52,7 @@ export const userScopedPrismaClientExtension = (userId?: string) => { isPublic: true, } ] - } + }; return query(args); } diff --git a/packages/web/src/withAuthV2.test.ts b/packages/web/src/withAuthV2.test.ts index 23d7d05b7..5a1dd343d 100644 --- a/packages/web/src/withAuthV2.test.ts +++ b/packages/web/src/withAuthV2.test.ts @@ -2,7 +2,7 @@ import { expect, test, vi, beforeEach, describe } from 'vitest'; import { Session } from 'next-auth'; import { notAuthenticated } from './lib/serviceError'; import { getAuthContext, getAuthenticatedUser, withAuthV2, withOptionalAuthV2 } from './withAuthV2'; -import { MOCK_API_KEY, MOCK_ORG, MOCK_USER, prisma } from './__mocks__/prisma'; +import { MOCK_API_KEY, MOCK_ORG, MOCK_USER_WITH_ACCOUNTS, prisma } from './__mocks__/prisma'; import { OrgRole } from '@sourcebot/db'; const mocks = vi.hoisted(() => { @@ -83,7 +83,7 @@ describe('getAuthenticatedUser', () => { test('should return a user object if a valid session is present', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -95,7 +95,7 @@ describe('getAuthenticatedUser', () => { test('should return a user object if a valid api key is present', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -165,7 +165,7 @@ describe('getAuthContext', () => { 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({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -183,7 +183,7 @@ describe('getAuthContext', () => { expect(authContext).not.toBeUndefined(); expect(authContext).toStrictEqual({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -195,7 +195,7 @@ describe('getAuthContext', () => { test('should return a auth context object if a valid session is present and the user is a member of the organization with OWNER role', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -213,7 +213,7 @@ describe('getAuthContext', () => { expect(authContext).not.toBeUndefined(); expect(authContext).toStrictEqual({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -225,7 +225,7 @@ describe('getAuthContext', () => { test('should return a auth context object if a valid session is present and the user is not a member of the organization. The role should be GUEST.', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -238,7 +238,7 @@ describe('getAuthContext', () => { expect(authContext).not.toBeUndefined(); expect(authContext).toStrictEqual({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -268,7 +268,7 @@ describe('withAuthV2', () => { test('should call the callback with the 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({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -286,7 +286,7 @@ describe('withAuthV2', () => { const result = await withAuthV2(cb); expect(cb).toHaveBeenCalledWith({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -298,7 +298,7 @@ describe('withAuthV2', () => { test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization with OWNER role', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -316,7 +316,7 @@ describe('withAuthV2', () => { const result = await withAuthV2(cb); expect(cb).toHaveBeenCalledWith({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -328,7 +328,7 @@ describe('withAuthV2', () => { test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization (api key)', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -351,7 +351,7 @@ describe('withAuthV2', () => { const result = await withAuthV2(cb); expect(cb).toHaveBeenCalledWith({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -363,7 +363,7 @@ describe('withAuthV2', () => { test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization with OWNER role (api key)', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -386,7 +386,7 @@ describe('withAuthV2', () => { const result = await withAuthV2(cb); expect(cb).toHaveBeenCalledWith({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -398,7 +398,7 @@ describe('withAuthV2', () => { test('should return a service error if the user is a member of the organization but does not have a valid session', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -421,7 +421,7 @@ describe('withAuthV2', () => { test('should return a service error if the user is a guest of the organization', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -445,7 +445,7 @@ describe('withAuthV2', () => { test('should return a service error if the user is not a member of the organization (guest role)', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -465,7 +465,7 @@ describe('withOptionalAuthV2', () => { test('should call the callback with the 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({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -483,7 +483,7 @@ describe('withOptionalAuthV2', () => { const result = await withOptionalAuthV2(cb); expect(cb).toHaveBeenCalledWith({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -495,7 +495,7 @@ describe('withOptionalAuthV2', () => { test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization with OWNER role', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -513,7 +513,7 @@ describe('withOptionalAuthV2', () => { const result = await withOptionalAuthV2(cb); expect(cb).toHaveBeenCalledWith({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -525,7 +525,7 @@ describe('withOptionalAuthV2', () => { test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization (api key)', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -548,7 +548,7 @@ describe('withOptionalAuthV2', () => { const result = await withOptionalAuthV2(cb); expect(cb).toHaveBeenCalledWith({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -560,7 +560,7 @@ describe('withOptionalAuthV2', () => { test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization with OWNER role (api key)', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -583,7 +583,7 @@ describe('withOptionalAuthV2', () => { const result = await withOptionalAuthV2(cb); expect(cb).toHaveBeenCalledWith({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: MOCK_ORG, @@ -595,7 +595,7 @@ describe('withOptionalAuthV2', () => { test('should return a service error if the user is a member of the organization but does not have a valid session', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -618,7 +618,7 @@ describe('withOptionalAuthV2', () => { test('should return a service error if the user is a guest of the organization', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -642,7 +642,7 @@ describe('withOptionalAuthV2', () => { test('should return a service error if the user is not a member of the organization (guest role)', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -662,7 +662,7 @@ describe('withOptionalAuthV2', () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -677,7 +677,7 @@ describe('withOptionalAuthV2', () => { const result = await withOptionalAuthV2(cb); expect(cb).toHaveBeenCalledWith({ user: { - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }, org: { @@ -696,7 +696,7 @@ describe('withOptionalAuthV2', () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ @@ -718,7 +718,7 @@ describe('withOptionalAuthV2', () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ - ...MOCK_USER, + ...MOCK_USER_WITH_ACCOUNTS, id: userId, }); prisma.org.findUnique.mockResolvedValue({ diff --git a/packages/web/src/withAuthV2.ts b/packages/web/src/withAuthV2.ts index c6cbb8bb3..c4bf80956 100644 --- a/packages/web/src/withAuthV2.ts +++ b/packages/web/src/withAuthV2.ts @@ -88,7 +88,8 @@ export const getAuthContext = async (): Promise account.id); + const prisma = __unsafePrisma.$extends(userScopedPrismaClientExtension(accountIds)) as PrismaClient; return { user: user ?? undefined, @@ -106,6 +107,9 @@ export const getAuthenticatedUser = async () => { const user = await __unsafePrisma.user.findUnique({ where: { id: userId, + }, + include: { + accounts: true, } }); @@ -125,6 +129,9 @@ export const getAuthenticatedUser = async () => { where: { id: apiKey.createdById, }, + include: { + accounts: true, + } }); if (!user) {