From 16b2d063f548a3d75710e01a4debc58664bcaced Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 15 Jul 2026 16:08:23 -0700 Subject: [PATCH 1/3] fix(backend): recover entitlements after license sync --- .../backend/src/ee/accountPermissionSyncer.ts | 18 ++- .../backend/src/ee/githubAppManager.test.ts | 57 ++++++++++ packages/backend/src/ee/githubAppManager.ts | 34 ++++-- .../backend/src/ee/repoPermissionSyncer.ts | 16 ++- packages/backend/src/github.ts | 31 +++--- packages/backend/src/githubAppAuth.test.ts | 104 ++++++++++++++++++ packages/backend/src/index.ts | 11 +- packages/backend/src/utils.ts | 48 ++++---- 8 files changed, 254 insertions(+), 65 deletions(-) create mode 100644 packages/backend/src/ee/githubAppManager.test.ts create mode 100644 packages/backend/src/githubAppAuth.test.ts diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 88f3e57b5..6394e719c 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -3,7 +3,7 @@ import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSo import { env, createLogger, getIdentityProviderConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; import { hasEntitlement } from "../entitlements.js"; import { ensureFreshAccountToken } from "./tokenRefresh.js"; -import { Job, Queue, Worker } from "bullmq"; +import { DelayedError, Job, Queue, Worker } from "bullmq"; import { Redis } from "ioredis"; import { createOctokitFromToken, @@ -26,6 +26,7 @@ const createJobLogger = (jobId: string) => createLogger(`${LOG_TAG}:job:${jobId} const QUEUE_NAME = 'accountPermissionSyncQueue'; const POLLING_INTERVAL_MS = 1000; +const ENTITLEMENT_RETRY_DELAY_MS = 30 * 1000; type AccountPermissionSyncJob = { jobId: string; @@ -59,13 +60,13 @@ export class AccountPermissionSyncer { } public async startScheduler() { - if (!await hasEntitlement('permission-syncing')) { - throw new Error('Permission syncing is not supported in current plan.'); - } - logger.debug('Starting scheduler'); this.interval = setIntervalAsync(async () => { + if (!await hasEntitlement('permission-syncing')) { + return; + } + const thresholdDate = new Date(Date.now() - this.settings.userDrivenPermissionSyncIntervalMs); const accounts = await this.db.account.findMany({ @@ -168,6 +169,11 @@ export class AccountPermissionSyncer { } private async runJob(job: Job) { + if (!await hasEntitlement('permission-syncing')) { + await job.moveToDelayed(Date.now() + ENTITLEMENT_RETRY_DELAY_MS, job.token); + throw new DelayedError('Permission syncing entitlement is not currently available.'); + } + const id = job.data.jobId; const logger = createJobLogger(id); @@ -443,4 +449,4 @@ export class AccountPermissionSyncer { 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/githubAppManager.test.ts b/packages/backend/src/ee/githubAppManager.test.ts new file mode 100644 index 000000000..5f348e69f --- /dev/null +++ b/packages/backend/src/ee/githubAppManager.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(), +})); + +vi.mock('@sourcebot/shared', () => ({ + createLogger: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + })), + env: { CONFIG_PATH: '/tmp/config.json' }, + getTokenFromConfig: vi.fn(), + loadConfig: mocks.loadConfig, +})); + +vi.mock('@octokit/app', () => ({ + App: vi.fn(), +})); + +const getManager = async () => { + const { GithubAppManager } = await import('./githubAppManager.js'); + return GithubAppManager.getInstance(); +}; + +describe('GithubAppManager.ensureInitialized', () => { + beforeEach(() => { + vi.resetModules(); + mocks.loadConfig.mockReset(); + }); + + test('shares initialization across concurrent callers', async () => { + mocks.loadConfig.mockResolvedValue({}); + const manager = await getManager(); + + await Promise.all([ + manager.ensureInitialized(), + manager.ensureInitialized(), + ]); + + expect(mocks.loadConfig).toHaveBeenCalledTimes(1); + }); + + test('retries initialization after a transient failure', async () => { + mocks.loadConfig + .mockRejectedValueOnce(new Error('GitHub unavailable')) + .mockResolvedValueOnce({}); + const manager = await getManager(); + + await expect(manager.ensureInitialized()).rejects.toThrow('GitHub unavailable'); + await expect(manager.ensureInitialized()).resolves.toBeUndefined(); + + expect(mocks.loadConfig).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/backend/src/ee/githubAppManager.ts b/packages/backend/src/ee/githubAppManager.ts index 0ee50acf6..ec125cc10 100644 --- a/packages/backend/src/ee/githubAppManager.ts +++ b/packages/backend/src/ee/githubAppManager.ts @@ -1,6 +1,5 @@ import { App } from "@octokit/app"; import { getTokenFromConfig } from "@sourcebot/shared"; -import { PrismaClient } from "@sourcebot/db"; import { createLogger } from "@sourcebot/shared"; import { GitHubAppConfig } from "@sourcebot/schemas/v3/index.type"; import { env, loadConfig } from "@sourcebot/shared"; @@ -21,8 +20,8 @@ export class GithubAppManager { private static instance: GithubAppManager | null = null; private octokitApps: Map; private installationMap: Map; - private db: PrismaClient | null = null; private initialized: boolean = false; + private initializationPromise: Promise | null = null; private constructor() { this.octokitApps = new Map(); @@ -36,16 +35,33 @@ export class GithubAppManager { return GithubAppManager.instance; } - private ensureInitialized(): void { + private assertInitialized(): void { if (!this.initialized) { - throw new Error('GithubAppManager must be initialized before use. Call init() first.'); + throw new Error('GithubAppManager must be initialized before use. Call ensureInitialized() first.'); } } - public async init(db: PrismaClient) { - this.db = db; + public async ensureInitialized(): Promise { + if (this.initialized) { + return; + } + + if (!this.initializationPromise) { + this.initializationPromise = this.init().catch((error) => { + // Allow a later operation to retry after a transient GitHub or + // secret-resolution failure. + this.initializationPromise = null; + throw error; + }); + } + + await this.initializationPromise; + } + + private async init(): Promise { const config = await loadConfig(env.CONFIG_PATH); if (!config.apps) { + this.initialized = true; return; } @@ -86,12 +102,12 @@ export class GithubAppManager { this.installationMap.set(this.generateMapKey(owner, deploymentHostname), installation); } } - + this.initialized = true; } public async getInstallationToken(owner: string, deploymentHostname: string = GITHUB_DEFAULT_DEPLOYMENT_HOSTNAME): Promise { - this.ensureInitialized(); + this.assertInitialized(); const key = this.generateMapKey(owner, deploymentHostname); const installation = this.installationMap.get(key) as Installation | undefined; @@ -112,4 +128,4 @@ export class GithubAppManager { private generateMapKey(owner: string, deploymentHostname: string): string { return `${deploymentHostname}/${owner}`; } -} \ No newline at end of file +} diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index 78c2633ef..536f48a09 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -3,7 +3,7 @@ import { PermissionSyncSource, PrismaClient, Repo, RepoPermissionSyncJobStatus } import { createLogger, PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "@sourcebot/shared"; import { env } from "@sourcebot/shared"; import { hasEntitlement } from "../entitlements.js"; -import { Job, Queue, Worker } from 'bullmq'; +import { DelayedError, Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js"; import { createGitLabFromPersonalAccessToken, getProjectMembers } from "../gitlab.js"; @@ -19,6 +19,7 @@ type RepoPermissionSyncJob = { const QUEUE_NAME = 'repoPermissionSyncQueue'; const POLLING_INTERVAL_MS = 1000; +const ENTITLEMENT_RETRY_DELAY_MS = 30 * 1000; const LOG_TAG = 'repo-permission-syncer'; const logger = createLogger(LOG_TAG); @@ -46,13 +47,13 @@ export class RepoPermissionSyncer { } public async startScheduler() { - if (!await hasEntitlement('permission-syncing')) { - throw new Error('Permission syncing is not supported in current plan.'); - } - logger.debug('Starting scheduler'); this.interval = setIntervalAsync(async () => { + if (!await hasEntitlement('permission-syncing')) { + return; + } + // @todo: make this configurable const thresholdDate = new Date(Date.now() - this.settings.repoDrivenPermissionSyncIntervalMs); @@ -160,6 +161,11 @@ export class RepoPermissionSyncer { } private async runJob(job: Job) { + if (!await hasEntitlement('permission-syncing')) { + await job.moveToDelayed(Date.now() + ENTITLEMENT_RETRY_DELAY_MS, job.token); + throw new DelayedError('Permission syncing entitlement is not currently available.'); + } + const id = job.data.jobId; const logger = createJobLogger(id); diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index 998563408..cb3b876cd 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -116,36 +116,37 @@ export const createOctokitFromToken = async ({ token, url }: { token?: string, u } /** - * Helper function to get an authenticated Octokit instance using GitHub App if available, - * otherwise falls back to the provided octokit instance. + * Uses GitHub App authentication when an app is configured. App initialization + * and token failures are propagated so callers cannot mistake a partial, + * unauthenticated response for an authoritative repository list. */ -const getOctokitWithGithubApp = async ( +export const getOctokitWithGithubApp = async ( octokit: Octokit, owner: string, url: string | undefined, context: string ): Promise => { - if (!await hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) { + const githubAppManager = GithubAppManager.getInstance(); + await githubAppManager.ensureInitialized(); + if (!githubAppManager.appsConfigured()) { return octokit; } + if (!await hasEntitlement('github-app')) { + throw new Error(`GitHub App authentication is not currently licensed for ${context}.`); + } + try { const hostname = url ? new URL(url).hostname : GITHUB_CLOUD_HOSTNAME; - const token = await GithubAppManager.getInstance().getInstallationToken(owner, hostname); - const { octokit: octokitFromToken, isAuthenticated } = await createOctokitFromToken({ + const token = await githubAppManager.getInstallationToken(owner, hostname); + const { octokit: octokitFromToken } = await createOctokitFromToken({ token, url, }); - - if (isAuthenticated) { - return octokitFromToken; - } else { - logger.error(`Failed to authenticate with GitHub App for ${context}. Falling back to legacy token resolution.`); - return octokit; - } + return octokitFromToken; } catch (error) { - logger.error(`Error getting GitHub App token for ${context}. Falling back to legacy token resolution.`, error); - return octokit; + logger.error(`Error getting GitHub App token for ${context}.`, error); + throw error; } } diff --git a/packages/backend/src/githubAppAuth.test.ts b/packages/backend/src/githubAppAuth.test.ts new file mode 100644 index 000000000..b9e56072f --- /dev/null +++ b/packages/backend/src/githubAppAuth.test.ts @@ -0,0 +1,104 @@ +import type { Octokit } from '@octokit/rest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + appsConfigured: vi.fn(), + ensureInitialized: vi.fn(), + getInstallationToken: vi.fn(), + hasEntitlement: vi.fn(), +})); + +vi.mock('@sentry/node', () => ({ + captureException: vi.fn(), +})); + +vi.mock('@sourcebot/shared', () => ({ + createLogger: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + })), + env: { + FALLBACK_GITHUB_CLOUD_TOKEN: undefined, + }, + getTokenFromConfig: vi.fn(), +})); + +vi.mock('./entitlements.js', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); + +vi.mock('./ee/githubAppManager.js', () => ({ + GithubAppManager: { + getInstance: () => ({ + appsConfigured: mocks.appsConfigured, + ensureInitialized: mocks.ensureInitialized, + getInstallationToken: mocks.getInstallationToken, + }), + }, +})); + +import { getOctokitWithGithubApp } from './github.js'; + +describe('getOctokitWithGithubApp', () => { + beforeEach(() => { + mocks.appsConfigured.mockReset().mockReturnValue(true); + mocks.ensureInitialized.mockReset().mockResolvedValue(undefined); + mocks.getInstallationToken.mockReset().mockResolvedValue('installation-token'); + mocks.hasEntitlement.mockReset(); + }); + + test('fails safely, then uses the GitHub App when the entitlement appears after startup', async () => { + const fallbackOctokit = {} as Octokit; + mocks.hasEntitlement + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + await expect(getOctokitWithGithubApp( + fallbackOctokit, + 'example', + undefined, + 'org example', + )).rejects.toThrow('GitHub App authentication is not currently licensed for org example.'); + + const entitledOctokit = await getOctokitWithGithubApp( + fallbackOctokit, + 'example', + undefined, + 'org example', + ); + + expect(mocks.ensureInitialized).toHaveBeenCalledTimes(2); + expect(mocks.getInstallationToken).toHaveBeenCalledWith('example', 'github.com'); + expect(entitledOctokit).not.toBe(fallbackOctokit); + }); + + test('uses legacy authentication when no GitHub App is configured', async () => { + const fallbackOctokit = {} as Octokit; + mocks.appsConfigured.mockReturnValue(false); + mocks.hasEntitlement.mockResolvedValue(false); + + await expect(getOctokitWithGithubApp( + fallbackOctokit, + 'example', + undefined, + 'org example', + )).resolves.toBe(fallbackOctokit); + + expect(mocks.hasEntitlement).not.toHaveBeenCalled(); + }); + + test('does not fall back when GitHub App token resolution fails', async () => { + const error = new Error('rate limited'); + mocks.hasEntitlement.mockResolvedValue(true); + mocks.getInstallationToken.mockRejectedValue(error); + + await expect(getOctokitWithGithubApp( + {} as Octokit, + 'example', + undefined, + 'org example', + )).rejects.toBe(error); + }); +}); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b97fe248f..df2a18993 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -2,7 +2,6 @@ import "./instrument.js"; import * as Sentry from "@sentry/node"; import { createLogger, env, getConfigSettings } from "@sourcebot/shared"; -import { hasEntitlement } from "./entitlements.js"; import { prisma } from "./prisma.js"; import 'express-async-errors'; import { existsSync } from 'fs'; @@ -14,7 +13,6 @@ import { ConnectionManager } from './connectionManager.js'; import { INDEX_CACHE_DIR, REPOS_CACHE_DIR, SHUTDOWN_SIGNALS } from './constants.js'; import { AccountPermissionSyncer } from "./ee/accountPermissionSyncer.js"; import { AuditLogPruner } from "./ee/auditLogPruner.js"; -import { GithubAppManager } from "./ee/githubAppManager.js"; import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js'; import { shutdownPosthog } from "./posthog.js"; import { PromClient } from './promClient.js'; @@ -46,10 +44,6 @@ const promClient = new PromClient(); const settings = await getConfigSettings(env.CONFIG_PATH); -if (await hasEntitlement('github-app')) { - await GithubAppManager.getInstance().init(prisma); -} - const connectionManager = new ConnectionManager(prisma, settings, redis, promClient); const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis); const accountPermissionSyncer = new AccountPermissionSyncer(prisma, settings, redis); @@ -63,10 +57,7 @@ await repoIndexManager.startScheduler(); auditLogPruner.startScheduler(); attachmentPruner.startScheduler(); -if (env.PERMISSION_SYNC_ENABLED === 'true' && !await hasEntitlement('permission-syncing')) { - logger.warn('Permission syncing is not supported in current plan. Please contact team@sourcebot.dev for assistance.'); -} -else if (env.PERMISSION_SYNC_ENABLED === 'true' && await hasEntitlement('permission-syncing')) { +if (env.PERMISSION_SYNC_ENABLED === 'true') { if (env.PERMISSION_SYNC_REPO_DRIVEN_ENABLED === 'true') { await repoPermissionSyncer.startScheduler(); } diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index a0c3cf028..70bdb5e8f 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -132,27 +132,35 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge }; } - // If we have github apps configured we assume that we must use them for github service auth - if (repo.external_codeHostType === 'github' && await hasEntitlement('github-app') && GithubAppManager.getInstance().appsConfigured()) { - logger?.debug(`Using GitHub App for service auth for repo ${repo.displayName} hosted at ${repo.external_codeHostUrl}`); - - const owner = repo.displayName?.split('/')[0]; - const deploymentHostname = new URL(repo.external_codeHostUrl).hostname; - if (!owner || !deploymentHostname) { - throw new Error(`Failed to fetch GitHub App for repo ${repo.displayName}:Invalid repo displayName (${repo.displayName}) or deployment hostname (${deploymentHostname})`); - } + if (repo.external_codeHostType === 'github') { + const githubAppManager = GithubAppManager.getInstance(); + await githubAppManager.ensureInitialized(); - const token = await GithubAppManager.getInstance().getInstallationToken(owner, deploymentHostname); - return { - hostUrl: repo.external_codeHostUrl, - token, - cloneUrlWithToken: createGitCloneUrlWithToken( - repo.cloneUrl, - { - username: 'x-access-token', - password: token - } - ), + if (githubAppManager.appsConfigured()) { + if (!await hasEntitlement('github-app')) { + throw new Error(`GitHub App authentication is not currently licensed for repo ${repo.displayName}.`); + } + + logger?.debug(`Using GitHub App for service auth for repo ${repo.displayName} hosted at ${repo.external_codeHostUrl}`); + + const owner = repo.displayName?.split('/')[0]; + const deploymentHostname = new URL(repo.external_codeHostUrl).hostname; + if (!owner || !deploymentHostname) { + throw new Error(`Failed to fetch GitHub App for repo ${repo.displayName}:Invalid repo displayName (${repo.displayName}) or deployment hostname (${deploymentHostname})`); + } + + const token = await githubAppManager.getInstallationToken(owner, deploymentHostname); + return { + hostUrl: repo.external_codeHostUrl, + token, + cloneUrlWithToken: createGitCloneUrlWithToken( + repo.cloneUrl, + { + username: 'x-access-token', + password: token + } + ), + } } } From ae7c73d22f3f1f6a79dd71ddce952cae5c634265 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 15 Jul 2026 16:08:52 -0700 Subject: [PATCH 2/3] chore: update changelog for #1454 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1df382b6..7962a58cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- [EE] Fixed worker startup races that could disable GitHub App authentication and permission syncing until restart after an online license refresh. [#1454](https://github.com/sourcebot-dev/sourcebot/pull/1454) + ## [5.1.1] - 2026-07-14 - Add book a call button to sidebar. [#1441](https://github.com/sourcebot-dev/sourcebot/pull/1441) From a19d6110e81f0d2645053243c6aeff86ee7a372e Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 15 Jul 2026 16:22:09 -0700 Subject: [PATCH 3/3] chore: document GitHub App sync failure behavior --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7962a58cf..2d8dc1af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - [EE] Fixed worker startup races that could disable GitHub App authentication and permission syncing until restart after an online license refresh. [#1454](https://github.com/sourcebot-dev/sourcebot/pull/1454) +- [EE] Fixed GitHub connection sync jobs to fail safely when GitHub App authentication is configured without the required entitlement. [#1454](https://github.com/sourcebot-dev/sourcebot/pull/1454) ## [5.1.1] - 2026-07-14