2025-09-20 23:51:14 +00:00
import * as Sentry from "@sentry/node" ;
2025-11-05 03:04:22 +00:00
import { PrismaClient , AccountPermissionSyncJobStatus , Account } from "@sourcebot/db" ;
2025-09-20 23:51:14 +00:00
import { createLogger } from "@sourcebot/logger" ;
import { Job , Queue , Worker } from "bullmq" ;
import { Redis } from "ioredis" ;
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js" ;
import { env } from "../env.js" ;
import { createOctokitFromToken , getReposForAuthenticatedUser } from "../github.js" ;
2025-10-30 18:08:10 +00:00
import { createGitLabFromOAuthToken , getProjectsForAuthenticatedUser } from "../gitlab.js" ;
2025-09-20 23:51:14 +00:00
import { hasEntitlement } from "@sourcebot/shared" ;
import { Settings } from "../types.js" ;
2025-10-30 18:08:10 +00:00
const LOG_TAG = 'user-permission-syncer' ;
const logger = createLogger ( LOG_TAG ) ;
const createJobLogger = ( jobId : string ) = > createLogger ( ` ${ LOG_TAG } :job: ${ jobId } ` ) ;
2025-09-20 23:51:14 +00:00
2025-11-05 03:04:22 +00:00
const QUEUE_NAME = 'accountPermissionSyncQueue' ;
2025-09-20 23:51:14 +00:00
2025-11-05 03:04:22 +00:00
type AccountPermissionSyncJob = {
2025-09-20 23:51:14 +00:00
jobId : string ;
}
2025-11-05 03:04:22 +00:00
export class AccountPermissionSyncer {
private queue : Queue < AccountPermissionSyncJob > ;
private worker : Worker < AccountPermissionSyncJob > ;
2025-09-20 23:51:14 +00:00
private interval? : NodeJS.Timeout ;
constructor (
private db : PrismaClient ,
private settings : Settings ,
redis : Redis ,
) {
2025-11-05 03:04:22 +00:00
this . queue = new Queue < AccountPermissionSyncJob > ( QUEUE_NAME , {
2025-09-20 23:51:14 +00:00
connection : redis ,
} ) ;
2025-11-05 03:04:22 +00:00
this . worker = new Worker < AccountPermissionSyncJob > ( QUEUE_NAME , this . runJob . bind ( this ) , {
2025-09-20 23:51:14 +00:00
connection : redis ,
concurrency : 1 ,
} ) ;
this . worker . on ( 'completed' , this . onJobCompleted . bind ( this ) ) ;
this . worker . on ( 'failed' , this . onJobFailed . bind ( this ) ) ;
}
public startScheduler() {
if ( ! hasEntitlement ( 'permission-syncing' ) ) {
throw new Error ( 'Permission syncing is not supported in current plan.' ) ;
}
logger . debug ( 'Starting scheduler' ) ;
this . interval = setInterval ( async ( ) = > {
const thresholdDate = new Date ( Date . now ( ) - this . settings . experiment_userDrivenPermissionSyncIntervalMs ) ;
2025-11-05 03:04:22 +00:00
const accounts = await this . db . account . findMany ( {
2025-09-20 23:51:14 +00:00
where : {
AND : [
{
2025-11-05 03:04:22 +00:00
provider : {
in : PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES
2025-09-20 23:51:14 +00:00
}
} ,
{
OR : [
{ permissionSyncedAt : null } ,
{ permissionSyncedAt : { lt : thresholdDate } } ,
]
} ,
{
NOT : {
permissionSyncJobs : {
some : {
OR : [
// Don't schedule if there are active jobs
{
status : {
in : [
2025-11-05 03:04:22 +00:00
AccountPermissionSyncJobStatus . PENDING ,
AccountPermissionSyncJobStatus . IN_PROGRESS ,
2025-09-20 23:51:14 +00:00
] ,
}
} ,
// 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 : [
2025-11-05 03:04:22 +00:00
{ status : AccountPermissionSyncJobStatus.FAILED } ,
2025-09-20 23:51:14 +00:00
{ completedAt : { gt : thresholdDate } } ,
]
}
]
}
}
}
} ,
]
}
} ) ;
2025-11-05 03:04:22 +00:00
await this . schedulePermissionSync ( accounts ) ;
2025-09-20 23:51:14 +00:00
} , 1000 * 5 ) ;
}
2025-10-18 23:31:22 +00:00
public async dispose() {
2025-09-20 23:51:14 +00:00
if ( this . interval ) {
clearInterval ( this . interval ) ;
}
2025-10-18 23:31:22 +00:00
await this . worker . close ( ) ;
await this . queue . close ( ) ;
2025-09-20 23:51:14 +00:00
}
2025-11-05 03:04:22 +00:00
private async schedulePermissionSync ( accounts : Account [ ] ) {
2025-10-30 18:08:10 +00:00
// @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.
2025-11-05 03:04:22 +00:00
const jobs = await this . db . accountPermissionSyncJob . createManyAndReturn ( {
data : accounts.map ( account = > ( {
accountId : account.id ,
2025-10-30 18:08:10 +00:00
} ) ) ,
2025-09-20 23:51:14 +00:00
} ) ;
2025-10-30 18:08:10 +00:00
await this . queue . addBulk ( jobs . map ( ( job ) = > ( {
2025-11-05 03:04:22 +00:00
name : 'accountPermissionSyncJob' ,
2025-10-30 18:08:10 +00:00
data : {
jobId : job.id ,
} ,
opts : {
removeOnComplete : env.REDIS_REMOVE_ON_COMPLETE ,
removeOnFail : env.REDIS_REMOVE_ON_FAIL ,
}
} ) ) )
2025-09-20 23:51:14 +00:00
}
2025-11-05 03:04:22 +00:00
private async runJob ( job : Job < AccountPermissionSyncJob > ) {
2025-09-20 23:51:14 +00:00
const id = job . data . jobId ;
2025-10-30 18:08:10 +00:00
const logger = createJobLogger ( id ) ;
2025-11-05 03:04:22 +00:00
const { account } = await this . db . accountPermissionSyncJob . update ( {
2025-09-20 23:51:14 +00:00
where : {
id ,
} ,
data : {
2025-11-05 03:04:22 +00:00
status : AccountPermissionSyncJobStatus.IN_PROGRESS ,
2025-09-20 23:51:14 +00:00
} ,
select : {
2025-11-05 03:04:22 +00:00
account : {
2025-09-20 23:51:14 +00:00
include : {
2025-11-05 03:04:22 +00:00
user : true ,
2025-09-20 23:51:14 +00:00
}
}
}
} ) ;
2025-11-05 03:04:22 +00:00
logger . info ( ` Syncing permissions for ${ account . provider } account (id: ${ account . id } ) for user ${ account . user . email } ... ` ) ;
2025-09-20 23:51:14 +00:00
// Get a list of all repos that the user has access to from all connected accounts.
const repoIds = await ( async ( ) = > {
const aggregatedRepoIds : Set < number > = new Set ( ) ;
2025-11-05 03:04:22 +00:00
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. ` ) ;
}
2025-09-20 23:51:14 +00:00
2025-11-05 03:04:22 +00:00
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 ,
2025-09-20 23:51:14 +00:00
}
2025-10-30 18:08:10 +00:00
}
2025-11-05 03:04:22 +00:00
} ) ;
2025-10-30 18:08:10 +00:00
2025-11-05 03:04:22 +00:00
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 ,
2025-10-30 18:08:10 +00:00
}
2025-11-05 03:04:22 +00:00
}
} ) ;
2025-10-30 18:08:10 +00:00
2025-11-05 03:04:22 +00:00
repos . forEach ( repo = > aggregatedRepoIds . add ( repo . id ) ) ;
2025-09-20 23:51:14 +00:00
}
return Array . from ( aggregatedRepoIds ) ;
} ) ( ) ;
await this . db . $transaction ( [
2025-11-05 03:04:22 +00:00
this . db . account . update ( {
2025-09-20 23:51:14 +00:00
where : {
2025-11-05 03:04:22 +00:00
id : account.id ,
2025-09-20 23:51:14 +00:00
} ,
data : {
accessibleRepos : {
deleteMany : { } ,
}
}
} ) ,
2025-11-05 03:04:22 +00:00
this . db . accountToRepoPermission . createMany ( {
2025-09-20 23:51:14 +00:00
data : repoIds.map ( repoId = > ( {
2025-11-05 03:04:22 +00:00
accountId : account.id ,
2025-09-20 23:51:14 +00:00
repoId ,
} ) ) ,
skipDuplicates : true ,
} )
] ) ;
}
2025-11-05 03:04:22 +00:00
private async onJobCompleted ( job : Job < AccountPermissionSyncJob > ) {
2025-10-30 18:08:10 +00:00
const logger = createJobLogger ( job . data . jobId ) ;
2025-11-05 03:04:22 +00:00
const { account } = await this . db . accountPermissionSyncJob . update ( {
2025-09-20 23:51:14 +00:00
where : {
id : job.data.jobId ,
} ,
data : {
2025-11-05 03:04:22 +00:00
status : AccountPermissionSyncJobStatus.COMPLETED ,
account : {
2025-09-20 23:51:14 +00:00
update : {
permissionSyncedAt : new Date ( ) ,
2025-11-05 03:04:22 +00:00
} ,
2025-09-20 23:51:14 +00:00
} ,
completedAt : new Date ( ) ,
} ,
select : {
2025-11-05 03:04:22 +00:00
account : {
include : {
user : true ,
}
}
2025-09-20 23:51:14 +00:00
}
} ) ;
2025-11-05 03:04:22 +00:00
logger . info ( ` Permissions synced for ${ account . provider } account (id: ${ account . id } ) for user ${ account . user . email } ` ) ;
2025-09-20 23:51:14 +00:00
}
2025-11-05 03:04:22 +00:00
private async onJobFailed ( job : Job < AccountPermissionSyncJob > | undefined , err : Error ) {
2025-10-30 18:08:10 +00:00
const logger = createJobLogger ( job ? . data . jobId ? ? 'unknown' ) ;
2025-09-20 23:51:14 +00:00
Sentry . captureException ( err , {
tags : {
jobId : job?.data.jobId ,
queue : QUEUE_NAME ,
}
} ) ;
2025-11-05 03:04:22 +00:00
const errorMessage = ( accountId : string , email : string ) = > ` Account permission sync job failed for account (id: ${ accountId } ) for user ${ email } : ${ err . message } ` ;
2025-09-20 23:51:14 +00:00
if ( job ) {
2025-11-05 03:04:22 +00:00
const { account } = await this . db . accountPermissionSyncJob . update ( {
2025-09-20 23:51:14 +00:00
where : {
id : job.data.jobId ,
} ,
data : {
2025-11-05 03:04:22 +00:00
status : AccountPermissionSyncJobStatus.FAILED ,
2025-09-20 23:51:14 +00:00
completedAt : new Date ( ) ,
errorMessage : err.message ,
} ,
select : {
2025-11-05 03:04:22 +00:00
account : {
include : {
user : true ,
}
}
2025-09-20 23:51:14 +00:00
}
} ) ;
2025-11-05 03:04:22 +00:00
logger . error ( errorMessage ( account . id , account . user . email ? ? 'unknown user (email not found)' ) ) ;
2025-09-20 23:51:14 +00:00
} else {
2025-11-05 03:04:22 +00:00
logger . error ( errorMessage ( 'unknown account (id not found)' , 'unknown user (id not found)' ) ) ;
2025-09-20 23:51:14 +00:00
}
}
}