diff --git a/backend/src/ee/routes/v1/github-org-sync-router.ts b/backend/src/ee/routes/v1/github-org-sync-router.ts index 3f33a5d8f..0d7279928 100644 --- a/backend/src/ee/routes/v1/github-org-sync-router.ts +++ b/backend/src/ee/routes/v1/github-org-sync-router.ts @@ -126,4 +126,39 @@ export const registerGithubOrgSyncRouter = async (server: FastifyZodProvider) => return { githubOrgSyncConfig }; } }); + + server.route({ + url: "/sync-all-teams", + method: "POST", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + response: { + 200: z.object({ + totalUsers: z.number(), + errors: z.array(z.string()), + createdTeams: z.array(z.string()), + updatedTeams: z.array(z.string()), + removedMemberships: z.number(), + syncDuration: z.number() + }) + } + }, + handler: async (req) => { + const result = await server.services.githubOrgSync.syncAllTeams({ + orgPermission: req.permission + }); + + return { + totalUsers: result.totalUsers, + errors: result.errors, + createdTeams: result.createdTeams, + updatedTeams: result.updatedTeams, + removedMemberships: result.removedMemberships, + syncDuration: result.syncDuration + }; + } + }); }; diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts index 37d1bd398..7c4ad15eb 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts @@ -1,14 +1,19 @@ +/* eslint-disable @typescript-eslint/return-await */ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; import { Octokit } from "@octokit/core"; import { paginateGraphql } from "@octokit/plugin-paginate-graphql"; import { Octokit as OctokitRest } from "@octokit/rest"; +import RE2 from "re2"; import { OrgMembershipRole } from "@app/db/schemas"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { retryWithBackoff } from "@app/lib/retry"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal"; @@ -16,20 +21,67 @@ import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TGithubOrgSyncDALFactory } from "./github-org-sync-dal"; -import { TCreateGithubOrgSyncDTO, TDeleteGithubOrgSyncDTO, TUpdateGithubOrgSyncDTO } from "./github-org-sync-types"; +import { + TCreateGithubOrgSyncDTO, + TDeleteGithubOrgSyncDTO, + TSyncAllTeamsDTO, + TSyncResult, + TUpdateGithubOrgSyncDTO, + TValidateGithubTokenDTO +} from "./github-org-sync-types"; const OctokitWithPlugin = Octokit.plugin(paginateGraphql); +// Type definitions for GitHub API errors +interface GitHubApiError extends Error { + status?: number; + response?: { + status?: number; + headers?: { + "x-ratelimit-reset"?: string; + }; + }; +} + +interface OrgMembershipWithUser { + id: string; + orgId: string; + role: string; + status: string; + isActive: boolean; + inviteEmail: string | null; + user: { + id: string; + email: string; + username: string | null; + firstName: string | null; + lastName: string | null; + } | null; +} + +interface GroupMembership { + id: string; + groupId: string; + groupName: string; + orgMembershipId: string; + firstName: string | null; + lastName: string | null; +} + type TGithubOrgSyncServiceFactoryDep = { githubOrgSyncDAL: TGithubOrgSyncDALFactory; permissionService: Pick; kmsService: Pick; userGroupMembershipDAL: Pick< TUserGroupMembershipDALFactory, - "findGroupMembershipsByUserIdInOrg" | "insertMany" | "delete" + "findGroupMembershipsByUserIdInOrg" | "findGroupMembershipsByGroupIdInOrg" | "insertMany" | "delete" >; groupDAL: Pick; licenseService: Pick; + orgMembershipDAL: Pick< + TOrgMembershipDALFactory, + "find" | "findOrgMembershipById" | "findOrgMembershipsWithUsersByOrgId" + >; }; export type TGithubOrgSyncServiceFactory = ReturnType; @@ -40,7 +92,8 @@ export const githubOrgSyncServiceFactory = ({ kmsService, userGroupMembershipDAL, groupDAL, - licenseService + licenseService, + orgMembershipDAL }: TGithubOrgSyncServiceFactoryDep) => { const createGithubOrgSync = async ({ githubOrgName, @@ -304,8 +357,8 @@ export const githubOrgSyncServiceFactory = ({ const removeFromTeams = infisicalUserGroups.filter((el) => !githubUserTeamSet.has(el.groupName)); if (newTeams.length || updateTeams.length || removeFromTeams.length) { - await groupDAL.transaction(async (tx) => { - if (newTeams.length) { + if (newTeams.length) { + await groupDAL.transaction(async (tx) => { const newGroups = await groupDAL.insertMany( newTeams.map((newGroupName) => ({ name: newGroupName, @@ -322,9 +375,11 @@ export const githubOrgSyncServiceFactory = ({ })), tx ); - } + }); + } - if (updateTeams.length) { + if (updateTeams.length) { + await groupDAL.transaction(async (tx) => { await userGroupMembershipDAL.insertMany( updateTeams.map((el) => ({ groupId: githubUserTeamOnInfisicalGroupByName[el][0].id, @@ -332,16 +387,433 @@ export const githubOrgSyncServiceFactory = ({ })), tx ); - } + }); + } - if (removeFromTeams.length) { + if (removeFromTeams.length) { + await groupDAL.transaction(async (tx) => { await userGroupMembershipDAL.delete( { userId, $in: { groupId: removeFromTeams.map((el) => el.groupId) } }, tx ); - } + }); + } + } + }; + + const validateGithubToken = async ({ orgPermission, githubOrgAccessToken }: TValidateGithubTokenDTO) => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); + + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to validate GitHub token due to plan restriction. Upgrade plan to use GitHub organization sync." }); } + + const config = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!config) { + throw new BadRequestError({ message: "GitHub organization sync is not configured" }); + } + + try { + const testOctokit = new OctokitRest({ + auth: githubOrgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + const { data: org } = await testOctokit.rest.orgs.get({ + org: config.githubOrgName + }); + + const octokitGraphQL = new OctokitWithPlugin({ + auth: githubOrgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + await octokitGraphQL.graphql(`query($org: String!) { organization(login: $org) { id name } }`, { + org: config.githubOrgName + }); + + return { + valid: true, + organizationInfo: { + id: org.id, + login: org.login, + name: org.name || org.login, + publicRepos: org.public_repos, + privateRepos: org.owned_private_repos || 0 + } + }; + } catch (error) { + logger.error(error, `GitHub token validation failed for org ${config.githubOrgName}`); + + const gitHubError = error as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode) { + if (statusCode === 401) { + throw new BadRequestError({ + message: "GitHub access token is invalid or expired." + }); + } + if (statusCode === 403) { + throw new BadRequestError({ + message: + "GitHub access token lacks required permissions. Required: 1) 'read:org' scope for organization teams, 2) Token owner must be an organization member with team visibility access, 3) Organization settings must allow team visibility. Check GitHub token scopes and organization member permissions." + }); + } + if (statusCode === 404) { + throw new BadRequestError({ + message: `Organization '${config.githubOrgName}' not found or access token does not have access to it.` + }); + } + } + + throw new BadRequestError({ + message: `GitHub token validation failed: ${(error as Error).message}` + }); + } + }; + + const syncAllTeams = async ({ orgPermission }: TSyncAllTeamsDTO): Promise => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.GithubOrgSyncManual + ); + + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to sync all GitHub teams due to plan restriction. Upgrade plan to use GitHub organization sync." + }); + } + + const config = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!config || !config?.isActive) { + throw new BadRequestError({ message: "GitHub organization sync is not configured or not active" }); + } + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgPermission.orgId + }); + + if (!config.encryptedGithubOrgAccessToken) { + throw new BadRequestError({ + message: "GitHub organization access token is required. Please set a token first." + }); + } + + const orgAccessToken = decryptor({ cipherTextBlob: config.encryptedGithubOrgAccessToken }).toString(); + + try { + const testOctokit = new OctokitRest({ + auth: orgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + await testOctokit.rest.orgs.get({ + org: config.githubOrgName + }); + + await testOctokit.rest.users.getAuthenticated(); + } catch (error) { + throw new BadRequestError({ + message: "Stored GitHub access token is invalid or expired. Please set a new token." + }); + } + + const allMembers = await orgMembershipDAL.findOrgMembershipsWithUsersByOrgId(orgPermission.orgId); + const activeMembers = allMembers.filter( + (member) => member.status === "accepted" && member.isActive + ) as OrgMembershipWithUser[]; + + const startTime = Date.now(); + const syncErrors: string[] = []; + + const octokit = new OctokitWithPlugin({ + auth: orgAccessToken, + request: { + signal: AbortSignal.timeout(30000) + } + }); + + const data = await retryWithBackoff(async () => { + return octokit.graphql + .paginate<{ + organization: { + teams: { + totalCount: number; + edges: { + node: { + name: string; + description: string; + members: { + edges: { + node: { + login: string; + }; + }[]; + }; + }; + }[]; + }; + }; + }>( + ` + query orgTeams($cursor: String, $org: String!) { + organization(login: $org) { + teams(first: 100, after: $cursor) { + totalCount + edges { + node { + name + description + members(first: 100) { + edges { + node { + login + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `, + { + org: config.githubOrgName + } + ) + .catch((err) => { + logger.error(err, "GitHub GraphQL error for batched team sync"); + + const gitHubError = err as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode) { + if (statusCode === 401) { + throw new BadRequestError({ + message: "GitHub access token is invalid or expired. Please provide a new token." + }); + } + if (statusCode === 403) { + throw new BadRequestError({ + message: + "GitHub access token lacks required permissions for organization team sync. Required: 1) 'admin:org' scope, 2) Token owner must be organization owner or have team read permissions, 3) Organization settings must allow team visibility. Check token scopes and user role." + }); + } + if (statusCode === 404) { + throw new BadRequestError({ + message: `Organization ${config.githubOrgName} not found or access token does not have sufficient permissions to read it.` + }); + } + } + + if ((err as Error)?.message?.includes("Although you appear to have the correct authorization credential")) { + throw new BadRequestError({ + message: + "Organization has restricted OAuth app access. Please check that: 1) Your organization has approved the Infisical OAuth application, 2) The token owner has sufficient organization permissions." + }); + } + throw new BadRequestError({ message: `GitHub GraphQL query failed: ${(err as Error)?.message}` }); + }); + }); + + const { + organization: { teams } + } = data; + + const userTeamMap = new Map(); + const allGithubUsernamesInTeams = new Set(); + + teams?.edges?.forEach((teamEdge) => { + const teamName = teamEdge.node.name.toLowerCase(); + + teamEdge.node.members.edges.forEach((memberEdge) => { + const username = memberEdge.node.login.toLowerCase(); + allGithubUsernamesInTeams.add(username); + + if (!userTeamMap.has(username)) { + userTeamMap.set(username, []); + } + userTeamMap.get(username)!.push(teamName); + }); + }); + + const allGithubTeamNames = Array.from(new Set(teams?.edges?.map((edge) => edge.node.name.toLowerCase()) || [])); + + const existingTeamsOnInfisical = await groupDAL.find({ + orgId: orgPermission.orgId, + $in: { name: allGithubTeamNames } + }); + const existingTeamsMap = groupBy(existingTeamsOnInfisical, (i) => i.name); + + const teamsToCreate = allGithubTeamNames.filter((teamName) => !(teamName in existingTeamsMap)); + const createdTeams = new Set(); + const updatedTeams = new Set(); + const totalRemovedMemberships = 0; + + await groupDAL.transaction(async (tx) => { + if (teamsToCreate.length > 0) { + const newGroups = await groupDAL.insertMany( + teamsToCreate.map((teamName) => ({ + name: teamName, + role: OrgMembershipRole.Member, + slug: teamName, + orgId: orgPermission.orgId + })), + tx + ); + + newGroups.forEach((group) => { + if (!existingTeamsMap[group.name]) { + existingTeamsMap[group.name] = []; + } + existingTeamsMap[group.name].push(group); + createdTeams.add(group.name); + }); + } + + const allTeams = [...Object.values(existingTeamsMap).flat()]; + + for (const team of allTeams) { + const teamName = team.name.toLowerCase(); + + const currentMemberships = (await userGroupMembershipDAL.findGroupMembershipsByGroupIdInOrg( + team.id, + orgPermission.orgId + )) as GroupMembership[]; + + const expectedUserIds = new Set(); + teams?.edges?.forEach((teamEdge) => { + if (teamEdge.node.name.toLowerCase() === teamName) { + teamEdge.node.members.edges.forEach((memberEdge) => { + const githubUsername = memberEdge.node.login.toLowerCase(); + + const matchingMember = activeMembers.find((member) => { + const email = member.user?.email || member.inviteEmail; + if (!email) return false; + + const emailPrefix = email.split("@")[0].toLowerCase(); + const emailDomain = email.split("@")[1].toLowerCase(); + + if (emailPrefix === githubUsername) { + return true; + } + const domainName = emailDomain.split(".")[0]; + if (githubUsername.endsWith(domainName) && githubUsername.length > domainName.length) { + const baseUsername = githubUsername.slice(0, -domainName.length); + if (emailPrefix === baseUsername) { + return true; + } + } + const emailSplitRegex = new RE2(/[._-]/); + const emailParts = emailPrefix.split(emailSplitRegex); + const longestEmailPart = emailParts.reduce((a, b) => (a.length > b.length ? a : b), ""); + if (longestEmailPart.length >= 4 && githubUsername.includes(longestEmailPart)) { + return true; + } + return false; + }); + + if (matchingMember?.user?.id) { + expectedUserIds.add(matchingMember.user.id); + logger.info( + `Matched GitHub user ${githubUsername} to email ${matchingMember.user?.email || matchingMember.inviteEmail}` + ); + } + }); + } + }); + + const currentUserIds = new Set(); + currentMemberships.forEach((membership) => { + const activeMember = activeMembers.find((am) => am.id === membership.orgMembershipId); + if (activeMember?.user?.id) { + currentUserIds.add(activeMember.user.id); + } + }); + + const usersToAdd = Array.from(expectedUserIds).filter((userId) => !currentUserIds.has(userId)); + + const membershipsToRemove = currentMemberships.filter((membership) => { + const activeMember = activeMembers.find((am) => am.id === membership.orgMembershipId); + return activeMember?.user?.id && !expectedUserIds.has(activeMember.user.id); + }); + + if (usersToAdd.length > 0) { + await userGroupMembershipDAL.insertMany( + usersToAdd.map((userId) => ({ + userId, + groupId: team.id + })), + tx + ); + updatedTeams.add(teamName); + } + + if (membershipsToRemove.length > 0) { + await userGroupMembershipDAL.delete( + { + $in: { + id: membershipsToRemove.map((m) => m.id) + } + }, + tx + ); + updatedTeams.add(teamName); + } + } + }); + + const syncDuration = Date.now() - startTime; + + logger.info( + { + orgId: orgPermission.orgId, + createdTeams: createdTeams.size, + syncDuration + }, + "GitHub team sync completed" + ); + + return { + totalUsers: activeMembers.length, + errors: syncErrors, + createdTeams: Array.from(createdTeams), + updatedTeams: Array.from(updatedTeams), + removedMemberships: totalRemovedMemberships, + syncDuration + }; }; return { @@ -349,6 +821,8 @@ export const githubOrgSyncServiceFactory = ({ updateGithubOrgSync, deleteGithubOrgSync, getGithubOrgSync, - syncUserGroups + syncUserGroups, + syncAllTeams, + validateGithubToken }; }; diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-types.ts b/backend/src/ee/services/github-org-sync/github-org-sync-types.ts index e1df71e82..909414c2f 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-types.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-types.ts @@ -21,3 +21,21 @@ export interface TDeleteGithubOrgSyncDTO { export interface TGetGithubOrgSyncDTO { orgPermission: OrgServiceActor; } + +export interface TSyncAllTeamsDTO { + orgPermission: OrgServiceActor; +} + +export interface TSyncResult { + totalUsers: number; + errors: string[]; + createdTeams: string[]; + updatedTeams: string[]; + removedMemberships: number; + syncDuration: number; +} + +export interface TValidateGithubTokenDTO { + orgPermission: OrgServiceActor; + githubOrgAccessToken: string; +} diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index d0b1ca3dd..d4155d02c 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -94,6 +94,7 @@ export enum OrgPermissionSubjects { Sso = "sso", Scim = "scim", GithubOrgSync = "github-org-sync", + GithubOrgSyncManual = "github-org-sync-manual", Ldap = "ldap", Groups = "groups", Billing = "billing", @@ -123,6 +124,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSyncManual] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] @@ -192,6 +194,10 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ subject: z.literal(OrgPermissionSubjects.GithubOrgSync).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") }), + z.object({ + subject: z.literal(OrgPermissionSubjects.GithubOrgSyncManual).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), z.object({ subject: z.literal(OrgPermissionSubjects.Ldap).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") @@ -315,6 +321,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync); can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); diff --git a/backend/src/lib/retry/index.ts b/backend/src/lib/retry/index.ts new file mode 100644 index 000000000..de7115399 --- /dev/null +++ b/backend/src/lib/retry/index.ts @@ -0,0 +1,43 @@ +/* eslint-disable no-await-in-loop */ +interface GitHubApiError extends Error { + status?: number; + response?: { + status?: number; + headers?: { + "x-ratelimit-reset"?: string; + }; + }; +} + +const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(() => resolve(), ms); + }); + +export const retryWithBackoff = async (fn: () => Promise, maxRetries = 3, baseDelay = 1000): Promise => { + let lastError: Error; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + const gitHubError = error as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode === 403) { + const rateLimitReset = gitHubError.response?.headers?.["x-ratelimit-reset"]; + if (rateLimitReset) { + const resetTime = parseInt(rateLimitReset, 10) * 1000; + const waitTime = Math.max(resetTime - Date.now(), baseDelay); + await delay(Math.min(waitTime, 60000)); + } else { + await delay(baseDelay * 2 ** attempt); + } + } else if (attempt < maxRetries) { + await delay(baseDelay * 2 ** attempt); + } + } + } + + throw lastError!; +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3d79836e5..3773048b0 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -680,7 +680,8 @@ export const registerRoutes = async ( kmsService, permissionService, groupDAL, - userGroupMembershipDAL + userGroupMembershipDAL, + orgMembershipDAL }); const ldapService = ldapConfigServiceFactory({ diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index 7cef4a6cc..2d7992d34 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -153,10 +153,64 @@ export const orgMembershipDALFactory = (db: TDbClient) => { } }; + const findOrgMembershipsWithUsersByOrgId = async (orgId: string) => { + try { + const members = await db + .replicaNode()(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.OrgMembership}.userId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.OrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("inviteEmail").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("isActive").withSchema(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId") + ) + .where({ isGhost: false }); + + return members.map((member) => ({ + id: member.id, + orgId: member.orgId, + role: member.role, + status: member.status, + isActive: member.isActive, + inviteEmail: member.inviteEmail, + user: { + id: member.userId, + email: member.email, + username: member.username, + firstName: member.firstName, + lastName: member.lastName + } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find org memberships with users by org id" }); + } + }; + return { ...orgMembershipOrm, findOrgMembershipById, findRecentInvitedMemberships, - updateLastInvitedAtByIds + updateLastInvitedAtByIds, + findOrgMembershipsWithUsersByOrgId }; }; diff --git a/docs/documentation/platform/github-org-sync.mdx b/docs/documentation/platform/github-org-sync.mdx index 519e12db8..71dd9dbf6 100644 --- a/docs/documentation/platform/github-org-sync.mdx +++ b/docs/documentation/platform/github-org-sync.mdx @@ -45,6 +45,64 @@ Once configured, the GitHub Organization Synchronization feature functions as fo When a user logs in via the GitHub OAuth flow and selects the configured organization, the system will then automatically synchronize the teams they are a part of in GitHub with corresponding groups in Infisical. +## Manual Team Sync + +You can manually synchronize GitHub teams for all organization members who have previously logged in with GitHub. This bulk sync operation updates team memberships without requiring users to log in again. + + + + To perform manual syncs, you'll need to create a GitHub Personal Access Token with the appropriate permissions. GitHub offers two types of tokens: + + + + 1. Go to [GitHub Settings → Personal Access Tokens → Tokens (classic)](https://github.com/settings/tokens) + 2. Click **Generate new token** → **Generate new token (classic)** + 3. Give your token a descriptive name (e.g., "Infisical GitHub Sync") + 4. Set an appropriate expiration date + 5. Select the **read:org** scope - Required to read organization team information + 6. Click **Generate token** + 7. Copy the token immediately (you won't be able to see it again) + + ![Classic Token Creation](../../images/platform/external-syncs/github-classic-token.png) + + + 1. Go to [GitHub Settings → Personal Access Tokens → Fine-grained tokens](https://github.com/settings/personal-access-tokens/new) + 2. Click **Generate new token** + 3. Give your token a descriptive name (e.g., "Infisical GitHub Sync") + 4. Set an appropriate expiration date + 5. Select your organization under **Resource owner** + 6. Under **Organization permissions**, set **Members** to **Read** + 7. Click **Generate token** + 8. Copy the token immediately (you won't be able to see it again) + + ![Fine-grained Token Creation](../../images/platform/external-syncs/github-fine-grained-token.png) + + + + + + 1. Navigate to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. + 2. Click the **Configure** button next to your GitHub Organization configuration. + 3. In the configuration modal, you'll find an optional **GitHub Access Token** field. + 4. Paste the token you generated in the previous step. + 5. Click **Update** to save the configuration. + + ![Token Configuration Modal](../../images/platform/external-syncs/github-token-config-modal.png) + + + + Once you have configured the GitHub access token: + + 1. Navigate to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. + 2. You'll see a **Sync Now** section with a button to trigger the manual sync. + 3. Click **Sync Now** to synchronize GitHub teams for all organization members. + + ![Manual Sync Button](../../images/platform/external-syncs/github-manual-sync-button.png) + + The sync operation will process all organization members who have previously logged in with GitHub and update their team memberships accordingly. + + + ## Troubleshooting diff --git a/docs/images/platform/external-syncs/github-classic-token.png b/docs/images/platform/external-syncs/github-classic-token.png new file mode 100644 index 000000000..f59c4f362 Binary files /dev/null and b/docs/images/platform/external-syncs/github-classic-token.png differ diff --git a/docs/images/platform/external-syncs/github-fine-grained-token.png b/docs/images/platform/external-syncs/github-fine-grained-token.png new file mode 100644 index 000000000..4924fedd0 Binary files /dev/null and b/docs/images/platform/external-syncs/github-fine-grained-token.png differ diff --git a/docs/images/platform/external-syncs/github-manual-sync-button.png b/docs/images/platform/external-syncs/github-manual-sync-button.png new file mode 100644 index 000000000..291b9f033 Binary files /dev/null and b/docs/images/platform/external-syncs/github-manual-sync-button.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png b/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png new file mode 100644 index 000000000..5f81232cf Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-manual-sync.png b/docs/images/platform/external-syncs/github-org-sync-manual-sync.png new file mode 100644 index 000000000..3ff168629 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-manual-sync.png differ diff --git a/docs/images/platform/external-syncs/github-token-config-modal.png b/docs/images/platform/external-syncs/github-token-config-modal.png new file mode 100644 index 000000000..106738d3c Binary files /dev/null and b/docs/images/platform/external-syncs/github-token-config-modal.png differ diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 74d5293e6..4a569be06 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -52,6 +52,7 @@ export enum OrgPermissionSubjects { Gateway = "gateway", SecretShare = "secret-share", GithubOrgSync = "github-org-sync", + GithubOrgSyncManual = "github-org-sync-manual", MachineIdentityAuthTemplate = "machine-identity-auth-template" } @@ -115,6 +116,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSyncManual] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx index 585426469..66b0246dc 100644 --- a/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx +++ b/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx @@ -1,6 +1,7 @@ export { useCreateGithubSyncOrgConfig, useDeleteGithubSyncOrgConfig, + useSyncAllGithubTeams, useUpdateGithubSyncOrgConfig } from "./mutations"; export { githubOrgSyncConfigQueryKeys } from "./queries"; diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx index 0cb9b56e8..3c78550ed 100644 --- a/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx +++ b/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx @@ -40,3 +40,19 @@ export const useDeleteGithubSyncOrgConfig = () => { } }); }; + +export const useSyncAllGithubTeams = () => { + return useMutation({ + mutationFn: async (): Promise<{ + totalUsers: number; + errors: string[]; + createdTeams: string[]; + updatedTeams: string[]; + removedMemberships: number; + syncDuration: number; + }> => { + const response = await apiRequest.post("/api/v1/github-org-sync-config/sync-all-teams"); + return response.data; + } + }); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProvisioningTab/GithubOrgSyncConfigModal.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProvisioningTab/GithubOrgSyncConfigModal.tsx index 0710afa8a..4f852e7b5 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgProvisioningTab/GithubOrgSyncConfigModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProvisioningTab/GithubOrgSyncConfigModal.tsx @@ -51,7 +51,7 @@ export const GithubOrgSyncConfigModal = ({ formState: { isSubmitting } } = useForm({ resolver: zodResolver(schema), - values: data ? { githubOrgName: data.githubOrgName } : undefined + values: data ? { githubOrgName: data.githubOrgName, githubOrgAccessToken: "" } : undefined }); const onFormSubmit = async ({ githubOrgName, githubOrgAccessToken }: FormData) => { @@ -123,21 +123,21 @@ export const GithubOrgSyncConfigModal = ({ )} /> - {/* ( - + )} - /> */} + />
+ )} + +
+

+ Manually sync GitHub teams for all organization members. This will update team + memberships for users who have previously logged in with GitHub. +

+ + )} {