Merge remote-tracking branch 'origin/main' into feat/moved-mfa-to-org-level

This commit is contained in:
Sheen Capadngan
2024-10-18 20:07:59 +08:00
94 changed files with 2353 additions and 265 deletions
@@ -0,0 +1,46 @@
import { Tables } from "knex/types/tables";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { TExternalGroupOrgRoleMappings } from "@app/db/schemas/external-group-org-role-mappings";
import { ormify } from "@app/lib/knex";
export type TExternalGroupOrgRoleMappingDALFactory = ReturnType<typeof externalGroupOrgRoleMappingDALFactory>;
export const externalGroupOrgRoleMappingDALFactory = (db: TDbClient) => {
const externalGroupOrgRoleMappingOrm = ormify(db, TableName.ExternalGroupOrgRoleMapping);
const updateExternalGroupOrgRoleMappingForOrg = async (
orgId: string,
newMappings: readonly Tables[TableName.ExternalGroupOrgRoleMapping]["insert"][]
) => {
const currentMappings = await externalGroupOrgRoleMappingOrm.find({ orgId });
const newMap = new Map(newMappings.map((mapping) => [mapping.groupName, mapping]));
const currentMap = new Map(currentMappings.map((mapping) => [mapping.groupName, mapping]));
const mappingsToDelete = currentMappings.filter((mapping) => !newMap.has(mapping.groupName));
const mappingsToUpdate = currentMappings
.filter((mapping) => newMap.has(mapping.groupName))
.map((mapping) => ({ id: mapping.id, ...newMap.get(mapping.groupName) }));
const mappingsToInsert = newMappings.filter((mapping) => !currentMap.has(mapping.groupName));
const mappings = await externalGroupOrgRoleMappingOrm.transaction(async (tx) => {
await externalGroupOrgRoleMappingOrm.delete({ $in: { id: mappingsToDelete.map((mapping) => mapping.id) } }, tx);
const updatedMappings: TExternalGroupOrgRoleMappings[] = [];
for await (const { id, ...mappingData } of mappingsToUpdate) {
const updatedMapping = await externalGroupOrgRoleMappingOrm.update({ id }, mappingData, tx);
updatedMappings.push(updatedMapping[0]);
}
const insertedMappings = await externalGroupOrgRoleMappingOrm.insertMany(mappingsToInsert, tx);
return [...updatedMappings, ...insertedMappings];
});
return mappings;
};
return { ...externalGroupOrgRoleMappingOrm, updateExternalGroupOrgRoleMappingForOrg };
};
@@ -0,0 +1,67 @@
import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal";
import { isCustomOrgRole } from "@app/services/org/org-role-fns";
import { TExternalGroupOrgMembershipRoleMappingDTO } from "./external-group-org-role-mapping-types";
export const constructGroupOrgMembershipRoleMappings = async ({
mappingsDTO,
orgId,
orgRoleDAL,
licenseService
}: {
mappingsDTO: TExternalGroupOrgMembershipRoleMappingDTO[];
orgRoleDAL: TOrgRoleDALFactory;
licenseService: TLicenseServiceFactory;
orgId: string;
}) => {
const plan = await licenseService.getPlan(orgId);
// prevent setting custom values if not in plan
if (mappingsDTO.some((map) => isCustomOrgRole(map.roleSlug)) && !plan?.rbac)
throw new BadRequestError({
message:
"Failed to set group organization role mapping due to plan RBAC restriction. Upgrade plan to set custom role mapping."
});
const customRoleSlugs = mappingsDTO
.filter((mapping) => isCustomOrgRole(mapping.roleSlug))
.map((mapping) => mapping.roleSlug);
let customRolesMap: Map<string, TOrgRoles> = new Map();
if (customRoleSlugs.length > 0) {
const customRoles = await orgRoleDAL.find({
$in: {
slug: customRoleSlugs
}
});
customRolesMap = new Map(customRoles.map((role) => [role.slug, role]));
}
const mappings = mappingsDTO.map(({ roleSlug, groupName }) => {
if (isCustomOrgRole(roleSlug)) {
const customRole = customRolesMap.get(roleSlug);
if (!customRole) throw new NotFoundError({ message: `Custom role ${roleSlug} not found.` });
return {
groupName,
role: OrgMembershipRole.Custom,
roleId: customRole.id,
orgId
};
}
return {
groupName,
role: roleSlug,
roleId: null, // need to set explicitly null for updates
orgId
};
});
return mappings;
};
@@ -0,0 +1,78 @@
import { ForbiddenError } from "@casl/ability";
import { FastifyRequest } from "fastify";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { constructGroupOrgMembershipRoleMappings } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-fns";
import { TSyncExternalGroupOrgMembershipRoleMappingsDTO } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-types";
import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal";
import { TExternalGroupOrgRoleMappingDALFactory } from "./external-group-org-role-mapping-dal";
type TExternalGroupOrgRoleMappingServiceFactoryDep = {
externalGroupOrgRoleMappingDAL: TExternalGroupOrgRoleMappingDALFactory;
permissionService: TPermissionServiceFactory;
licenseService: TLicenseServiceFactory;
orgRoleDAL: TOrgRoleDALFactory;
};
export type TExternalGroupOrgRoleMappingServiceFactory = ReturnType<typeof externalGroupOrgRoleMappingServiceFactory>;
export const externalGroupOrgRoleMappingServiceFactory = ({
externalGroupOrgRoleMappingDAL,
licenseService,
permissionService,
orgRoleDAL
}: TExternalGroupOrgRoleMappingServiceFactoryDep) => {
const listExternalGroupOrgRoleMappings = async (actor: FastifyRequest["permission"]) => {
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
// TODO: will need to change if we add support for ldap, oidc, etc.
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim);
const mappings = await externalGroupOrgRoleMappingDAL.find({
orgId: actor.orgId
});
return mappings;
};
const updateExternalGroupOrgRoleMappings = async (
dto: TSyncExternalGroupOrgMembershipRoleMappingsDTO,
actor: FastifyRequest["permission"]
) => {
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
// TODO: will need to change if we add support for ldap, oidc, etc.
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
const mappings = await constructGroupOrgMembershipRoleMappings({
mappingsDTO: dto.mappings,
orgRoleDAL,
licenseService,
orgId: actor.orgId
});
const data = await externalGroupOrgRoleMappingDAL.updateExternalGroupOrgRoleMappingForOrg(actor.orgId, mappings);
return data;
};
return {
updateExternalGroupOrgRoleMappings,
listExternalGroupOrgRoleMappings
};
};
@@ -0,0 +1,8 @@
export type TExternalGroupOrgMembershipRoleMappingDTO = {
groupName: string;
roleSlug: string;
};
export type TSyncExternalGroupOrgMembershipRoleMappingsDTO = {
mappings: TExternalGroupOrgMembershipRoleMappingDTO[];
};
@@ -1,9 +1,13 @@
/* eslint-disable no-await-in-loop */
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/rest";
import { TIntegrationAuths } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { NotFoundError } from "@app/lib/errors";
import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema";
import { Integrations, IntegrationUrls } from "./integration-list";
// akhilmhdh: check this part later. Copied from old base
@@ -230,7 +234,13 @@ const getAppsNetlify = async ({ accessToken }: { accessToken: string }) => {
/**
* Return list of repositories for Github integration
*/
const getAppsGithub = async ({ accessToken }: { accessToken: string }) => {
const getAppsGithub = async ({
accessToken,
authMetadata
}: {
accessToken: string;
authMetadata?: TIntegrationAuthMetadata;
}) => {
interface GitHubApp {
id: string;
name: string;
@@ -242,6 +252,29 @@ const getAppsGithub = async ({ accessToken }: { accessToken: string }) => {
};
}
if (authMetadata?.installationId) {
const appCfg = getConfig();
const octokit = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: appCfg.CLIENT_APP_ID_GITHUB_APP,
privateKey: appCfg.CLIENT_PRIVATE_KEY_GITHUB_APP,
installationId: authMetadata.installationId
}
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const repos = await octokit.paginate("GET /installation/repositories", {
per_page: 100
});
return repos.map((a) => ({
appId: String(a.id),
name: a.name,
owner: a.owner.login
}));
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const repos = (await new Octokit({
auth: accessToken
@@ -1056,6 +1089,7 @@ const getAppsAzureDevOps = async ({ accessToken, orgName }: { accessToken: strin
export const getApps = async ({
integration,
integrationAuth,
accessToken,
accessId,
teamId,
@@ -1066,6 +1100,7 @@ export const getApps = async ({
integration: string;
accessToken: string;
accessId?: string;
integrationAuth: TIntegrationAuths;
teamId?: string | null;
azureDevOpsOrgName?: string | null;
workspaceSlug?: string;
@@ -1099,7 +1134,8 @@ export const getApps = async ({
case Integrations.GITHUB:
return getAppsGithub({
accessToken
accessToken,
authMetadata: IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {})
});
case Integrations.GITLAB:
@@ -3,7 +3,7 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TIntegrationAuths, TIntegrationAuthsUpdate } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TIntegrationAuthDALFactory = ReturnType<typeof integrationAuthDALFactory>;
@@ -28,8 +28,23 @@ export const integrationAuthDALFactory = (db: TDbClient) => {
}
};
const getByOrg = async (orgId: string, tx?: Knex) => {
try {
const integrationAuths = await (tx || db)(TableName.IntegrationAuth)
.join(TableName.Project, `${TableName.Project}.id`, `${TableName.IntegrationAuth}.projectId`)
.join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Project}.orgId`)
.where(`${TableName.Organization}.id`, "=", orgId)
.select(selectAllTableCols(TableName.IntegrationAuth));
return integrationAuths;
} catch (error) {
throw new DatabaseError({ error, name: "get by org" });
}
};
return {
...integrationAuthOrm,
bulkUpdate
bulkUpdate,
getByOrg
};
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const IntegrationAuthMetadataSchema = z.object({
installationId: z.string().optional()
});
export type TIntegrationAuthMetadata = z.infer<typeof IntegrationAuthMetadataSchema>;
@@ -1,14 +1,16 @@
import { ForbiddenError } from "@casl/ability";
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/rest";
import AWS from "aws-sdk";
import { SecretEncryptionAlgo, SecretKeyEncoding, TIntegrationAuths, TIntegrationAuthsInsert } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TProjectPermission } from "@app/lib/types";
import { TGenericPermission, TProjectPermission } from "@app/lib/types";
import { TIntegrationDALFactory } from "../integration/integration-dal";
import { TKmsServiceFactory } from "../kms/kms-service";
@@ -16,11 +18,13 @@ import { KmsDataKey } from "../kms/kms-types";
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
import { getApps } from "./integration-app-list";
import { TIntegrationAuthDALFactory } from "./integration-auth-dal";
import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema";
import {
TBitbucketWorkspace,
TChecklyGroups,
TDeleteIntegrationAuthByIdDTO,
TDeleteIntegrationAuthsDTO,
TDuplicateGithubIntegrationAuthDTO,
TGetIntegrationAuthDTO,
TGetIntegrationAuthTeamCityBuildConfigDTO,
THerokuPipelineCoupling,
@@ -86,6 +90,24 @@ export const integrationAuthServiceFactory = ({
return authorizations;
};
const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => {
const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string);
return Promise.all(
authorizations.filter(async (auth) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
auth.projectId,
actorAuthMethod,
actorOrgId
);
return permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
})
);
};
const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => {
const integrationAuth = await integrationAuthDAL.findById(id);
if (!integrationAuth) throw new NotFoundError({ message: "Failed to find integration" });
@@ -109,7 +131,8 @@ export const integrationAuthServiceFactory = ({
actorAuthMethod,
integration,
url,
code
code,
installationId
}: TOauthExchangeDTO) => {
if (!Object.values(Integrations).includes(integration as Integrations))
throw new BadRequestError({ message: "Invalid integration" });
@@ -123,7 +146,7 @@ export const integrationAuthServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations);
const tokenExchange = await exchangeCode({ integration, code, url });
const tokenExchange = await exchangeCode({ integration, code, url, installationId });
const updateDoc: TIntegrationAuthsInsert = {
projectId,
integration,
@@ -141,6 +164,16 @@ export const integrationAuthServiceFactory = ({
updateDoc.metadata = {
authMethod: "oauth2"
};
} else if (integration === Integrations.GITHUB && installationId) {
updateDoc.metadata = {
installationId,
installationName: tokenExchange.installationName,
authMethod: "app"
};
}
if (installationId && integration === Integrations.GITHUB) {
return integrationAuthDAL.create(updateDoc);
}
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId);
@@ -176,12 +209,23 @@ export const integrationAuthServiceFactory = ({
updateDoc.accessCiphertext = accessEncToken.ciphertext;
}
}
return integrationAuthDAL.transaction(async (tx) => {
const doc = await integrationAuthDAL.findOne({ projectId, integration }, tx);
if (!doc) {
const integrationAuths = await integrationAuthDAL.find({ projectId, integration }, { tx });
let existingIntegrationAuth: TIntegrationAuths | undefined;
// we need to ensure that the integration auth that we use for Github is actually Oauth
if (integration === Integrations.GITHUB) {
existingIntegrationAuth = integrationAuths.find((integAuth) => !integAuth.metadata);
} else {
[existingIntegrationAuth] = integrationAuths;
}
if (!existingIntegrationAuth) {
return integrationAuthDAL.create(updateDoc, tx);
}
return integrationAuthDAL.updateById(doc.id, updateDoc, tx);
return integrationAuthDAL.updateById(existingIntegrationAuth.id, updateDoc, tx);
});
};
@@ -334,6 +378,13 @@ export const integrationAuthServiceFactory = ({
) {
return { accessToken: "", accessId: "" };
}
if (
integrationAuth.integration === Integrations.GITHUB &&
IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {}).installationId
) {
return { accessToken: "", accessId: "" };
}
if (shouldUseSecretV2Bridge) {
const { decryptor: secretManagerDecryptor, encryptor: secretManagerEncryptor } =
await kmsService.createCipherPairWithDataKey({
@@ -460,6 +511,7 @@ export const integrationAuthServiceFactory = ({
const { accessToken, accessId } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey);
const apps = await getApps({
integration: integrationAuth.integration,
integrationAuth,
accessToken,
accessId,
teamId,
@@ -575,6 +627,7 @@ export const integrationAuthServiceFactory = ({
};
const getGithubOrgs = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TIntegrationAuthGithubOrgsDTO) => {
const appCfg = getConfig();
const integrationAuth = await integrationAuthDAL.findById(id);
if (!integrationAuth) throw new NotFoundError({ message: "Failed to find integration" });
@@ -587,9 +640,44 @@ export const integrationAuthServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId);
const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey);
const octokit = new Octokit({
let octokit: Octokit;
const { installationId } = (integrationAuth.metadata as TIntegrationAuthMetadata) || {};
if (installationId) {
octokit = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: appCfg.CLIENT_APP_ID_GITHUB_APP,
privateKey: appCfg.CLIENT_PRIVATE_KEY_GITHUB_APP,
installationId
}
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const repos = await octokit.paginate("GET /installation/repositories", {
per_page: 100
});
const orgSet: Set<string> = new Set();
return repos
.filter((repo) => repo.owner.type === "Organization")
.map((repo) => ({
name: repo.owner.login,
orgId: String(repo.owner.id)
}))
.filter((org) => {
const isOrgProcessed = orgSet.has(org.orgId);
if (!isOrgProcessed) {
orgSet.add(org.orgId);
}
return !isOrgProcessed;
});
}
const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey);
octokit = new Octokit({
auth: accessToken
});
@@ -598,7 +686,9 @@ export const integrationAuthServiceFactory = ({
"X-GitHub-Api-Version": "2022-11-28"
}
});
if (!data) return [];
if (!data) {
return [];
}
return data.map(({ login: name, id: orgId }) => ({ name, orgId: String(orgId) }));
};
@@ -626,9 +716,24 @@ export const integrationAuthServiceFactory = ({
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId);
const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey);
const octokit = new Octokit({
auth: accessToken
});
let octokit: Octokit;
const appCfg = getConfig();
const authMetadata = IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {});
if (authMetadata.installationId) {
octokit = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: appCfg.CLIENT_APP_ID_GITHUB_APP,
privateKey: appCfg.CLIENT_PRIVATE_KEY_GITHUB_APP,
installationId: authMetadata.installationId
}
});
} else {
octokit = new Octokit({
auth: accessToken
});
}
const {
data: { environments }
@@ -1315,8 +1420,58 @@ export const integrationAuthServiceFactory = ({
return delIntegrationAuth;
};
// At the moment, we only use this for Github App integration as it's a special case
const duplicateIntegrationAuth = async ({
id,
actorId,
actor,
actorAuthMethod,
actorOrgId,
projectId
}: TDuplicateGithubIntegrationAuthDTO) => {
const integrationAuth = await integrationAuthDAL.findById(id);
if (!integrationAuth) {
throw new NotFoundError({ message: "Failed to find integration" });
}
const { permission: sourcePermission } = await permissionService.getProjectPermission(
actor,
actorId,
integrationAuth.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(sourcePermission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.Integrations
);
const { permission: targetPermission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(targetPermission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.Integrations
);
const newIntegrationAuth: Omit<typeof integrationAuth, "id"> & { id?: string } = {
...integrationAuth,
id: undefined,
projectId
};
return integrationAuthDAL.create(newIntegrationAuth);
};
return {
listIntegrationAuthByProjectId,
listOrgIntegrationAuth,
getIntegrationOptions,
getIntegrationAuth,
oauthExchange,
@@ -1343,6 +1498,7 @@ export const integrationAuthServiceFactory = ({
getNorthFlankSecretGroups,
getTeamcityBuildConfigs,
getBitbucketWorkspaces,
getIntegrationAccessToken
getIntegrationAccessToken,
duplicateIntegrationAuth
};
};
@@ -9,6 +9,7 @@ export type TOauthExchangeDTO = {
integration: string;
code: string;
url?: string;
installationId?: string;
} & TProjectPermission;
export type TSaveIntegrationAccessTokenDTO = {
@@ -107,6 +108,10 @@ export type TDeleteIntegrationAuthByIdDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TDuplicateGithubIntegrationAuthDTO = {
id: string;
} & TProjectPermission;
export type TGetIntegrationAuthTeamCityBuildConfigDTO = {
id: string;
appId: string;
@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { createAppAuth } from "@octokit/auth-app";
import { retry } from "@octokit/plugin-retry";
import { Octokit } from "@octokit/rest";
import { TIntegrationAuths, TIntegrations } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
@@ -15,6 +18,7 @@ import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema";
import { TIntegrationAuthServiceFactory } from "./integration-auth-service";
import { Integrations } from "./integration-list";
@@ -154,10 +158,12 @@ const getIntegrationSecretsV1 = async (
export const deleteGithubSecrets = async ({
integration,
authMetadata,
secrets,
accessToken
}: {
integration: Omit<TIntegrations, "envId">;
authMetadata: TIntegrationAuthMetadata;
secrets: Record<string, boolean>;
accessToken: string;
}) => {
@@ -170,9 +176,23 @@ export const deleteGithubSecrets = async ({
}
const OctokitWithRetry = Octokit.plugin(retry);
const octokit = new OctokitWithRetry({
auth: accessToken
});
let octokit: Octokit;
const appCfg = getConfig();
if (authMetadata.installationId) {
octokit = new OctokitWithRetry({
authStrategy: createAppAuth,
auth: {
appId: appCfg.CLIENT_APP_ID_GITHUB_APP,
privateKey: appCfg.CLIENT_PRIVATE_KEY_GITHUB_APP,
installationId: authMetadata.installationId
}
});
} else {
octokit = new OctokitWithRetry({
auth: accessToken
});
}
enum GithubScope {
Repo = "github-repo",
@@ -192,6 +212,7 @@ export const deleteGithubSecrets = async ({
break;
}
case GithubScope.Env: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
encryptedGithubSecrets = (
await octokit.request("GET /repositories/{repository_id}/environments/{environment_name}/secrets", {
repository_id: Number(integration.appId),
@@ -346,6 +367,7 @@ export const deleteIntegrationSecrets = async ({
case Integrations.GITHUB: {
await deleteGithubSecrets({
integration,
authMetadata: IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {}),
accessToken,
secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets
});
@@ -96,7 +96,9 @@ export enum IntegrationUrls {
GCP_SECRET_MANAGER_SERVICE_NAME = "secretmanager.googleapis.com",
GCP_SECRET_MANAGER_URL = `https://${GCP_SECRET_MANAGER_SERVICE_NAME}`,
GCP_SERVICE_USAGE_URL = "https://serviceusage.googleapis.com",
GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform",
GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations"
}
export const getIntegrationOptions = async () => {
@@ -138,6 +140,7 @@ export const getIntegrationOptions = async () => {
isAvailable: true,
type: "oauth",
clientId: appCfg.CLIENT_ID_GITHUB,
clientSlug: appCfg.CLIENT_SLUG_GITHUB_APP,
docsLink: ""
},
{
@@ -19,6 +19,7 @@ import {
UpdateSecretCommand
} from "@aws-sdk/client-secrets-manager";
import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts";
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/rest";
import AWS, { AWSError } from "aws-sdk";
import { AxiosError } from "axios";
@@ -36,6 +37,7 @@ import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/
import { TIntegrationDALFactory } from "../integration/integration-dal";
import { IntegrationMetadataSchema } from "../integration/integration-schema";
import { IntegrationAuthMetadataSchema } from "./integration-auth-schema";
import { TIntegrationsWithEnvironment } from "./integration-auth-types";
import {
IntegrationInitialSyncBehavior,
@@ -1542,11 +1544,13 @@ const syncSecretsNetlify = async ({
*/
const syncSecretsGitHub = async ({
integration,
integrationAuth,
secrets,
accessToken,
appendices
}: {
integration: TIntegrations;
integrationAuth: TIntegrationAuths;
secrets: Record<string, { value: string; comment?: string }>;
accessToken: string;
appendices?: { prefix: string; suffix: string };
@@ -1568,9 +1572,24 @@ const syncSecretsGitHub = async ({
selected_repositories_url?: string | undefined;
}
const octokit = new Octokit({
auth: accessToken
});
const authMetadata = IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {});
let octokit: Octokit;
const appCfg = getConfig();
if (authMetadata.installationId) {
octokit = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: appCfg.CLIENT_APP_ID_GITHUB_APP,
privateKey: appCfg.CLIENT_PRIVATE_KEY_GITHUB_APP,
installationId: authMetadata.installationId
}
});
} else {
octokit = new Octokit({
auth: accessToken
});
}
enum GithubScope {
Repo = "github-repo",
@@ -4069,6 +4088,7 @@ export const syncIntegrationSecrets = async ({
case Integrations.GITHUB:
await syncSecretsGitHub({
integration,
integrationAuth,
secrets,
accessToken,
appendices
@@ -2,7 +2,7 @@ import jwt from "jsonwebtoken";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { BadRequestError, ForbiddenRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
import { Integrations, IntegrationUrls } from "./integration-list";
@@ -234,12 +234,73 @@ const exchangeCodeNetlify = async ({ code }: { code: string }) => {
};
};
const exchangeCodeGithub = async ({ code }: { code: string }) => {
const exchangeCodeGithub = async ({ code, installationId }: { code: string; installationId?: string }) => {
const appCfg = getConfig();
if (!appCfg.CLIENT_ID_GITHUB || !appCfg.CLIENT_SECRET_GITHUB) {
throw new BadRequestError({ message: "Missing client id and client secret" });
if (!installationId && (!appCfg.CLIENT_ID_GITHUB || !appCfg.CLIENT_SECRET_GITHUB)) {
throw new InternalServerError({ message: "Missing client id and client secret" });
}
if (installationId && (!appCfg.CLIENT_ID_GITHUB_APP || !appCfg.CLIENT_SECRET_GITHUB_APP)) {
throw new InternalServerError({
message: "Missing Github app client ID and client secret"
});
}
if (installationId) {
// handle app installations
const oauthRes = (
await request.get<ExchangeCodeGithubResponse>(IntegrationUrls.GITHUB_TOKEN_URL, {
params: {
client_id: appCfg.CLIENT_ID_GITHUB_APP,
client_secret: appCfg.CLIENT_SECRET_GITHUB_APP,
code,
redirect_uri: `${appCfg.SITE_URL}/integrations/github/oauth2/callback`
},
headers: {
Accept: "application/json",
"Accept-Encoding": "application/json"
}
})
).data;
// use access token to validate installation ID
const installationsRes = (
await request.get<{
installations: {
id: number;
account: {
login: string;
};
}[];
}>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${oauthRes.access_token}`,
"Accept-Encoding": "application/json"
}
})
).data;
const matchingInstallation = installationsRes.installations.find(
(installation) => installation.id === +installationId
);
if (!matchingInstallation) {
throw new ForbiddenRequestError({
message: "User has no access to the provided installation"
});
}
return {
accessToken: "", // for github app integrations, we only need the installationID from the metadata
refreshToken: null,
accessExpiresAt: null,
installationName: matchingInstallation.account.login
};
}
// handle oauth github integration
const res = (
await request.get<ExchangeCodeGithubResponse>(IntegrationUrls.GITHUB_TOKEN_URL, {
params: {
@@ -346,6 +407,7 @@ type TExchangeReturn = {
url?: string;
teamId?: string;
accountId?: string;
installationName?: string;
};
/**
@@ -355,11 +417,13 @@ type TExchangeReturn = {
export const exchangeCode = async ({
integration,
code,
url
url,
installationId
}: {
integration: string;
code: string;
url?: string;
installationId?: string;
}): Promise<TExchangeReturn> => {
switch (integration) {
case Integrations.GCP_SECRET_MANAGER:
@@ -384,7 +448,8 @@ export const exchangeCode = async ({
});
case Integrations.GITHUB:
return exchangeCodeGithub({
code
code,
installationId
});
case Integrations.GITLAB:
return exchangeCodeGitlab({
@@ -258,27 +258,7 @@ export const integrationServiceFactory = ({
});
}
const deletedIntegration = await integrationDAL.transaction(async (tx) => {
// delete integration
const deletedIntegrationResult = await integrationDAL.deleteById(id, tx);
// check if there are other integrations that share the same integration auth
const integrations = await integrationDAL.find(
{
integrationAuthId: integration.integrationAuthId
},
tx
);
if (integrations.length === 0) {
// no other integration shares the same integration auth
// -> delete the integration auth
await integrationAuthDAL.deleteById(integration.integrationAuthId, tx);
}
return deletedIntegrationResult;
});
const deletedIntegration = await integrationDAL.deleteById(id);
return { ...integration, ...deletedIntegration };
};
+7 -4
View File
@@ -106,16 +106,19 @@ export const orgDALFactory = (db: TDbClient) => {
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("superAdmin").withSchema(TableName.Users),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
)
.where({ isGhost: false }) // MAKE SURE USER IS NOT A GHOST USER
.orderBy("firstName")
.orderBy("lastName");
return members.map(({ email, isEmailVerified, username, firstName, lastName, userId, publicKey, ...data }) => ({
...data,
user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey }
}));
return members.map(
({ email, isEmailVerified, username, firstName, lastName, userId, publicKey, superAdmin, ...data }) => ({
...data,
user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey, superAdmin }
})
);
} catch (error) {
throw new DatabaseError({ error, name: "Find all org members" });
}
+4 -6
View File
@@ -5,6 +5,8 @@ import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal";
const RESERVED_ORG_ROLE_SLUGS = Object.values(OrgMembershipRole).filter((role) => role !== "custom");
export const isCustomOrgRole = (roleSlug: string) => !RESERVED_ORG_ROLE_SLUGS.includes(roleSlug as OrgMembershipRole);
// this is only for updating an org
export const getDefaultOrgMembershipRoleForUpdateOrg = async ({
membershipRoleSlug,
@@ -17,9 +19,7 @@ export const getDefaultOrgMembershipRoleForUpdateOrg = async ({
orgRoleDAL: TOrgRoleDALFactory;
plan: TFeatureSet;
}) => {
const isCustomRole = !RESERVED_ORG_ROLE_SLUGS.includes(membershipRoleSlug as OrgMembershipRole);
if (isCustomRole) {
if (isCustomOrgRole(membershipRoleSlug)) {
if (!plan?.rbac)
throw new BadRequestError({
message:
@@ -41,9 +41,7 @@ export const getDefaultOrgMembershipRoleForUpdateOrg = async ({
export const getDefaultOrgMembershipRole = async (
defaultOrgMembershipRole: string // can either be ID or reserved slug
) => {
const isCustomRole = !RESERVED_ORG_ROLE_SLUGS.includes(defaultOrgMembershipRole as OrgMembershipRole);
if (isCustomRole)
if (isCustomOrgRole(defaultOrgMembershipRole))
return {
roleId: defaultOrgMembershipRole,
role: OrgMembershipRole.Custom
+19 -1
View File
@@ -11,6 +11,7 @@ import {
} from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TExternalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { ActorAuthMethod } from "../auth/auth-type";
@@ -20,11 +21,17 @@ type TOrgRoleServiceFactoryDep = {
orgRoleDAL: TOrgRoleDALFactory;
permissionService: TPermissionServiceFactory;
orgDAL: TOrgDALFactory;
externalGroupOrgRoleMappingDAL: TExternalGroupOrgRoleMappingDALFactory;
};
export type TOrgRoleServiceFactory = ReturnType<typeof orgRoleServiceFactory>;
export const orgRoleServiceFactory = ({ orgRoleDAL, orgDAL, permissionService }: TOrgRoleServiceFactoryDep) => {
export const orgRoleServiceFactory = ({
orgRoleDAL,
orgDAL,
permissionService,
externalGroupOrgRoleMappingDAL
}: TOrgRoleServiceFactoryDep) => {
const createRole = async (
userId: string,
orgId: string,
@@ -144,6 +151,17 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, orgDAL, permissionService }:
message: "Cannot delete default org membership role. Please re-assign and try again."
});
const externalGroupMapping = await externalGroupOrgRoleMappingDAL.findOne({
orgId,
roleId
});
if (externalGroupMapping)
throw new BadRequestError({
message:
"Cannot delete role assigned to external group organization role mapping. Please re-assign external mapping and try again."
});
const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId });
if (!deletedRole) throw new NotFoundError({ message: "Organization role not found", name: "Update role" });
@@ -1,7 +1,7 @@
import path from "node:path";
import { TableName, TSecretFolders, TSecretsV2 } from "@app/db/schemas";
import { ForbiddenRequestError } from "@app/lib/errors";
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
@@ -342,7 +342,7 @@ export const recursivelyGetSecretPaths = async ({
});
if (!env) {
throw new Error(`'${environment}' environment not found in project with ID ${projectId}`);
throw new NotFoundError({ message: `'${environment}' environment not found in project with ID ${projectId}` });
}
// Fetch all folders in env once with a single query
+1 -1
View File
@@ -152,7 +152,7 @@ export const recursivelyGetSecretPaths = ({
});
if (!env) {
throw new Error(`'${environment}' environment not found in project with ID ${projectId}`);
throw new NotFoundError({ message: `'${environment}' environment not found in project with ID ${projectId}` });
}
// Fetch all folders in env once with a single query