feat: completed github org sync

This commit is contained in:
=
2025-04-28 19:48:56 +05:30
parent dbb0b28453
commit 6d2175cf9f
32 changed files with 1398 additions and 81 deletions
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TGithubOrgSyncDALFactory = ReturnType<typeof githubOrgSyncDALFactory>;
export const githubOrgSyncDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.GithubOrgSyncConfig);
return orm;
};
@@ -0,0 +1,344 @@
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 { OrgMembershipRole } from "@app/db/schemas";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TGroupDALFactory } from "../group/group-dal";
import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { TGithubOrgSyncDALFactory } from "./github-org-sync-dal";
import { TCreateGithubOrgSyncDTO, TDeleteGithubOrgSyncDTO, TUpdateGithubOrgSyncDTO } from "./github-org-sync-types";
const OctokitWithPlugin = Octokit.plugin(paginateGraphQL);
type TGithubOrgSyncServiceFactoryDep = {
githubOrgSyncDAL: TGithubOrgSyncDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"findGroupMembershipsByUserIdInOrg" | "insertMany" | "delete"
>;
groupDAL: Pick<TGroupDALFactory, "insertMany" | "transaction" | "find">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TGithubOrgSyncServiceFactory = ReturnType<typeof githubOrgSyncServiceFactory>;
export const githubOrgSyncServiceFactory = ({
githubOrgSyncDAL,
permissionService,
kmsService,
userGroupMembershipDAL,
groupDAL,
licenseService
}: TGithubOrgSyncServiceFactoryDep) => {
const createGithubOrgSync = async ({
githubOrgName,
orgPermission,
githubOrgAccessToken,
isActive
}: TCreateGithubOrgSyncDTO) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
orgPermission.orgId,
orgPermission.authMethod,
orgPermission.orgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSync);
const plan = await licenseService.getPlan(orgPermission.orgId);
if (!plan.githubOrgSync) {
throw new BadRequestError({
message:
"Failed to create github organization team sync due to plan restriction. Upgrade plan to create github organization sync."
});
}
const existingConfig = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId });
if (existingConfig)
throw new BadRequestError({
message: `Organization ${orgPermission.orgId} already has GitHub Organization sync config.`
});
const octokit = new OctokitRest({
auth: githubOrgAccessToken,
request: {
signal: AbortSignal.timeout(5000)
}
});
const { data } = await octokit.rest.orgs.get({
org: githubOrgName
});
if (data.login.toLowerCase() !== githubOrgName)
throw new BadRequestError({ message: "Invalid GitHub organisation" });
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: orgPermission.orgId
});
const config = await githubOrgSyncDAL.create({
orgId: orgPermission.orgId,
githubOrgName,
isActive,
encryptedGithubOrgAccessToken: githubOrgAccessToken
? encryptor({ plainText: Buffer.from(githubOrgAccessToken) }).cipherTextBlob
: null
});
return config;
};
const updateGithubOrgSync = async ({
githubOrgName,
orgPermission,
githubOrgAccessToken,
isActive
}: TUpdateGithubOrgSyncDTO) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
orgPermission.orgId,
orgPermission.authMethod,
orgPermission.orgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync);
const plan = await licenseService.getPlan(orgPermission.orgId);
if (!plan.githubOrgSync) {
throw new BadRequestError({
message:
"Failed to update github organization team sync due to plan restriction. Upgrade plan to update github organization sync."
});
}
const existingConfig = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId });
if (!existingConfig)
throw new BadRequestError({
message: `Organization ${orgPermission.orgId} GitHub organization sync config missing.`
});
const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: orgPermission.orgId
});
const newData = {
githubOrgName: githubOrgName || existingConfig.githubOrgName,
githubOrgAccessToken:
githubOrgAccessToken ||
(existingConfig.encryptedGithubOrgAccessToken
? decryptor({ cipherTextBlob: existingConfig.encryptedGithubOrgAccessToken }).toString()
: null)
};
if (githubOrgName || githubOrgAccessToken) {
const octokit = new OctokitRest({
auth: newData.githubOrgAccessToken,
request: {
signal: AbortSignal.timeout(5000)
}
});
const { data } = await octokit.rest.orgs.get({
org: newData.githubOrgName
});
if (data.login.toLowerCase() !== newData.githubOrgName)
throw new BadRequestError({ message: "Invalid GitHub organisation" });
}
const config = await githubOrgSyncDAL.updateById(existingConfig.id, {
orgId: orgPermission.orgId,
githubOrgName: newData.githubOrgName,
isActive,
encryptedGithubOrgAccessToken: newData.githubOrgAccessToken
? encryptor({ plainText: Buffer.from(newData.githubOrgAccessToken) }).cipherTextBlob
: null
});
return config;
};
const deleteGithubOrgSync = async ({ orgPermission }: TDeleteGithubOrgSyncDTO) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
orgPermission.orgId,
orgPermission.authMethod,
orgPermission.orgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync);
const plan = await licenseService.getPlan(orgPermission.orgId);
if (!plan.githubOrgSync) {
throw new BadRequestError({
message:
"Failed to delete github organization team sync due to plan restriction. Upgrade plan to delete github organization sync."
});
}
const existingConfig = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId });
if (!existingConfig)
throw new BadRequestError({
message: `Organization ${orgPermission.orgId} GitHub organization sync config missing.`
});
const config = await githubOrgSyncDAL.deleteById(existingConfig.id);
return config;
};
const getGithubOrgSync = async ({ orgPermission }: TDeleteGithubOrgSyncDTO) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
orgPermission.orgId,
orgPermission.authMethod,
orgPermission.orgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync);
const existingConfig = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId });
if (!existingConfig)
throw new NotFoundError({
message: `Organization ${orgPermission.orgId} GitHub organization sync config missing.`
});
return existingConfig;
};
const syncUserGroups = async (orgId: string, userId: string, accessToken: string) => {
const config = await githubOrgSyncDAL.findOne({ orgId });
if (!config || !config?.isActive) return;
const infisicalUserGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(userId, orgId);
const infisicalUserGroupSet = new Set(infisicalUserGroups.map((el) => el.groupName));
const octoRest = new OctokitRest({
auth: accessToken,
request: {
signal: AbortSignal.timeout(5000)
}
});
const { data: userDetails } = await octoRest.rest.users.getAuthenticated();
const octokit = new OctokitWithPlugin({
auth: accessToken,
request: {
signal: AbortSignal.timeout(5000)
}
});
const data = await octokit.graphql
.paginate<{
organization: { teams: { totalCount: number; edges: { node: { name: string; description: string } }[] } };
}>(
`
query orgTeams($cursor: String,$org: String!, $username: String!){
organization(login: $org) {
teams(first: 100, userLogins: [$username], after: $cursor) {
totalCount
edges {
node {
name
description
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`,
{
org: config.githubOrgName,
username: userDetails.login
}
)
.catch((err) => {
if ((err as Error)?.message?.includes("Although you appear to have the correct authorization credential")) {
throw new BadRequestError({
message:
"Please check your organization have approved Infisical Oauth application. For more info: https://infisical.com/docs/documentation/platform/github-org-sync#troubleshooting"
});
}
throw new BadRequestError({ message: (err as Error)?.message });
});
const {
organization: { teams }
} = data;
const githubUserTeams = teams?.edges?.map((el) => el.node.name.toLowerCase());
const githubUserTeamSet = new Set(githubUserTeams);
const githubUserTeamOnInfisical = await groupDAL.find({ orgId, $in: { name: githubUserTeams } });
const githubUserTeamOnInfisicalGroupByName = groupBy(githubUserTeamOnInfisical, (i) => i.name);
const newTeams = githubUserTeams.filter(
(el) => !infisicalUserGroupSet.has(el) && !Object.hasOwn(githubUserTeamOnInfisicalGroupByName, el)
);
const updateTeams = githubUserTeams.filter(
(el) => !infisicalUserGroupSet.has(el) && Object.hasOwn(githubUserTeamOnInfisicalGroupByName, el)
);
const removeFromTeams = infisicalUserGroups.filter((el) => !githubUserTeamSet.has(el.groupName));
if (newTeams.length || updateTeams.length || removeFromTeams.length) {
await groupDAL.transaction(async (tx) => {
if (newTeams.length) {
const newGroups = await groupDAL.insertMany(
newTeams.map((newGroupName) => ({
name: newGroupName,
role: OrgMembershipRole.Member,
slug: newGroupName,
orgId
})),
tx
);
await userGroupMembershipDAL.insertMany(
newGroups.map((el) => ({
groupId: el.id,
userId
})),
tx
);
}
if (updateTeams.length) {
await userGroupMembershipDAL.insertMany(
updateTeams.map((el) => ({
groupId: githubUserTeamOnInfisicalGroupByName[el][0].id,
userId
})),
tx
);
}
if (removeFromTeams.length) {
await userGroupMembershipDAL.delete(
{ userId, $in: { groupId: removeFromTeams.map((el) => el.groupId) } },
tx
);
}
});
}
};
return {
createGithubOrgSync,
updateGithubOrgSync,
deleteGithubOrgSync,
getGithubOrgSync,
syncUserGroups
};
};
@@ -0,0 +1,23 @@
import { OrgServiceActor } from "@app/lib/types";
export interface TCreateGithubOrgSyncDTO {
orgPermission: OrgServiceActor;
githubOrgName: string;
githubOrgAccessToken?: string;
isActive?: boolean;
}
export interface TUpdateGithubOrgSyncDTO {
orgPermission: OrgServiceActor;
githubOrgName?: string;
githubOrgAccessToken?: string;
isActive?: boolean;
}
export interface TDeleteGithubOrgSyncDTO {
orgPermission: OrgServiceActor;
}
export interface TGetGithubOrgSyncDTO {
orgPermission: OrgServiceActor;
}
@@ -22,6 +22,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
pitRecovery: false,
ipAllowlisting: false,
rbac: false,
githubOrgSync: false,
customRateLimits: false,
customAlerts: false,
secretAccessInsights: false,
@@ -45,6 +45,7 @@ export type TFeatureSet = {
auditLogsRetentionDays: 0;
auditLogStreams: false;
auditLogStreamLimit: 3;
githubOrgSync: false;
samlSSO: false;
hsm: false;
oidcSSO: false;
@@ -74,6 +74,7 @@ export enum OrgPermissionSubjects {
IncidentAccount = "incident-contact",
Sso = "sso",
Scim = "scim",
GithubOrgSync = "github-org-sync",
Ldap = "ldap",
Groups = "groups",
Billing = "billing",
@@ -101,6 +102,7 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
| [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync]
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
| [OrgPermissionGroupActions, OrgPermissionSubjects.Groups]
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
@@ -165,6 +167,10 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [
subject: z.literal(OrgPermissionSubjects.Scim).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.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.Ldap).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
@@ -273,6 +279,11 @@ const buildAdminPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim);
can(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync);
can(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSync);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap);