mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 11:27:47 +00:00
feat: added secrets deletion feature on integration removal
This commit is contained in:
@@ -337,6 +337,7 @@ interface DeleteIntegrationEvent {
|
|||||||
targetServiceId?: string;
|
targetServiceId?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
region?: string;
|
region?: string;
|
||||||
|
shouldDeleteIntegrationSecrets?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -885,8 +885,15 @@ export const registerRoutes = async (
|
|||||||
folderDAL,
|
folderDAL,
|
||||||
integrationDAL,
|
integrationDAL,
|
||||||
integrationAuthDAL,
|
integrationAuthDAL,
|
||||||
secretQueueService
|
secretQueueService,
|
||||||
|
integrationAuthService,
|
||||||
|
projectBotService,
|
||||||
|
secretV2BridgeDAL,
|
||||||
|
secretImportDAL,
|
||||||
|
secretDAL,
|
||||||
|
kmsService
|
||||||
});
|
});
|
||||||
|
|
||||||
const serviceTokenService = serviceTokenServiceFactory({
|
const serviceTokenService = serviceTokenServiceFactory({
|
||||||
projectEnvDAL,
|
projectEnvDAL,
|
||||||
serviceTokenDAL,
|
serviceTokenDAL,
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
|
|||||||
params: z.object({
|
params: z.object({
|
||||||
integrationId: z.string().trim().describe(INTEGRATION.DELETE.integrationId)
|
integrationId: z.string().trim().describe(INTEGRATION.DELETE.integrationId)
|
||||||
}),
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
shouldDeleteIntegrationSecrets: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.optional()
|
||||||
|
.transform((val) => val === "true")
|
||||||
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
integration: IntegrationsSchema
|
integration: IntegrationsSchema
|
||||||
@@ -183,7 +189,8 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
|
|||||||
actorAuthMethod: req.permission.authMethod,
|
actorAuthMethod: req.permission.authMethod,
|
||||||
actor: req.permission.type,
|
actor: req.permission.type,
|
||||||
actorOrgId: req.permission.orgId,
|
actorOrgId: req.permission.orgId,
|
||||||
id: req.params.integrationId
|
id: req.params.integrationId,
|
||||||
|
shouldDeleteIntegrationSecrets: req.query.shouldDeleteIntegrationSecrets
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.services.auditLog.createAuditLog({
|
await server.services.auditLog.createAuditLog({
|
||||||
@@ -205,7 +212,8 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
|
|||||||
targetService: integration.targetService,
|
targetService: integration.targetService,
|
||||||
targetServiceId: integration.targetServiceId,
|
targetServiceId: integration.targetServiceId,
|
||||||
path: integration.path,
|
path: integration.path,
|
||||||
region: integration.region
|
region: integration.region,
|
||||||
|
shouldDeleteIntegrationSecrets: req.query.shouldDeleteIntegrationSecrets
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}) as any
|
}) as any
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
|
|
||||||
|
import { TIntegrationAuths, TIntegrations } from "@app/db/schemas";
|
||||||
|
import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||||
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
|
import { logger } from "@app/lib/logger";
|
||||||
|
|
||||||
|
import { IntegrationMetadataSchema } from "../integration/integration-schema";
|
||||||
|
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||||
|
import { KmsDataKey } from "../kms/kms-types";
|
||||||
|
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
|
||||||
|
import { TSecretDALFactory } from "../secret/secret-dal";
|
||||||
|
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 { TIntegrationAuthServiceFactory } from "./integration-auth-service";
|
||||||
|
import { Integrations } from "./integration-list";
|
||||||
|
|
||||||
|
const MAX_SYNC_SECRET_DEPTH = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the secrets in a given [folderId] including secrets from
|
||||||
|
* nested imported folders recursively.
|
||||||
|
*/
|
||||||
|
const getIntegrationSecretsV2 = async (
|
||||||
|
dto: {
|
||||||
|
projectId: string;
|
||||||
|
environment: string;
|
||||||
|
folderId: string;
|
||||||
|
depth: number;
|
||||||
|
decryptor: (value: Buffer | null | undefined) => string;
|
||||||
|
},
|
||||||
|
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find" | "findByFolderId">,
|
||||||
|
folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath">,
|
||||||
|
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">
|
||||||
|
) => {
|
||||||
|
const content: Record<string, boolean> = {};
|
||||||
|
if (dto.depth > MAX_SYNC_SECRET_DEPTH) {
|
||||||
|
logger.info(
|
||||||
|
`getIntegrationSecrets: secret depth exceeded for [projectId=${dto.projectId}] [folderId=${dto.folderId}] [depth=${dto.depth}]`
|
||||||
|
);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// process secrets in current folder
|
||||||
|
const secrets = await secretV2BridgeDAL.findByFolderId(dto.folderId);
|
||||||
|
|
||||||
|
secrets.forEach((secret) => {
|
||||||
|
const secretKey = secret.key;
|
||||||
|
content[secretKey] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if current folder has any imports from other folders
|
||||||
|
const secretImports = await secretImportDAL.find({ folderId: dto.folderId, isReplication: false });
|
||||||
|
|
||||||
|
// if no imports then return secrets in the current folder
|
||||||
|
if (!secretImports.length) return content;
|
||||||
|
const importedSecrets = await fnSecretsV2FromImports({
|
||||||
|
decryptor: dto.decryptor,
|
||||||
|
folderDAL,
|
||||||
|
secretDAL: secretV2BridgeDAL,
|
||||||
|
secretImportDAL,
|
||||||
|
allowedImports: secretImports
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
|
||||||
|
for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) {
|
||||||
|
const importedSecret = importedSecrets[i].secrets[j];
|
||||||
|
if (!content[importedSecret.key]) {
|
||||||
|
content[importedSecret.key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the secrets in a given [folderId] including secrets from
|
||||||
|
* nested imported folders recursively.
|
||||||
|
*/
|
||||||
|
const getIntegrationSecrets = async (
|
||||||
|
dto: {
|
||||||
|
projectId: string;
|
||||||
|
environment: string;
|
||||||
|
folderId: string;
|
||||||
|
key: string;
|
||||||
|
depth: number;
|
||||||
|
},
|
||||||
|
secretDAL: Pick<TSecretDALFactory, "findByFolderId">,
|
||||||
|
folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath">,
|
||||||
|
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">
|
||||||
|
) => {
|
||||||
|
let content: Record<string, boolean> = {};
|
||||||
|
if (dto.depth > MAX_SYNC_SECRET_DEPTH) {
|
||||||
|
logger.info(
|
||||||
|
`getIntegrationSecrets: secret depth exceeded for [projectId=${dto.projectId}] [folderId=${dto.folderId}] [depth=${dto.depth}]`
|
||||||
|
);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// process secrets in current folder
|
||||||
|
const secrets = await secretDAL.findByFolderId(dto.folderId);
|
||||||
|
secrets.forEach((secret) => {
|
||||||
|
const secretKey = decryptSymmetric128BitHexKeyUTF8({
|
||||||
|
ciphertext: secret.secretKeyCiphertext,
|
||||||
|
iv: secret.secretKeyIV,
|
||||||
|
tag: secret.secretKeyTag,
|
||||||
|
key: dto.key
|
||||||
|
});
|
||||||
|
|
||||||
|
content[secretKey] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if current folder has any imports from other folders
|
||||||
|
const secretImport = await secretImportDAL.find({ folderId: dto.folderId, isReplication: false });
|
||||||
|
|
||||||
|
// if no imports then return secrets in the current folder
|
||||||
|
if (!secretImport) return content;
|
||||||
|
|
||||||
|
const importedFolders = await folderDAL.findByManySecretPath(
|
||||||
|
secretImport.map(({ importEnv, importPath }) => ({
|
||||||
|
envId: importEnv.id,
|
||||||
|
secretPath: importPath
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const folder of importedFolders) {
|
||||||
|
if (folder) {
|
||||||
|
// get secrets contained in each imported folder by recursively calling
|
||||||
|
// this function against the imported folder
|
||||||
|
const importedSecrets = await getIntegrationSecrets(
|
||||||
|
{
|
||||||
|
environment: dto.environment,
|
||||||
|
projectId: dto.projectId,
|
||||||
|
folderId: folder.id,
|
||||||
|
key: dto.key,
|
||||||
|
depth: dto.depth + 1
|
||||||
|
},
|
||||||
|
secretDAL,
|
||||||
|
folderDAL,
|
||||||
|
secretImportDAL
|
||||||
|
);
|
||||||
|
|
||||||
|
// add the imported secrets to the current folder secrets
|
||||||
|
content = { ...importedSecrets, ...content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteGithubSecrets = async ({
|
||||||
|
integration,
|
||||||
|
secrets,
|
||||||
|
accessToken
|
||||||
|
}: {
|
||||||
|
integration: Omit<TIntegrations, "envId">;
|
||||||
|
secrets: Record<string, boolean>;
|
||||||
|
accessToken: string;
|
||||||
|
}) => {
|
||||||
|
interface GitHubSecret {
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
visibility?: "all" | "private" | "selected";
|
||||||
|
selected_repositories_url?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const octokit = new Octokit({
|
||||||
|
auth: accessToken
|
||||||
|
});
|
||||||
|
|
||||||
|
enum GithubScope {
|
||||||
|
Repo = "github-repo",
|
||||||
|
Org = "github-org",
|
||||||
|
Env = "github-env"
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryptedSecrets: GitHubSecret[];
|
||||||
|
|
||||||
|
switch (integration.scope) {
|
||||||
|
case GithubScope.Org: {
|
||||||
|
encryptedSecrets = (
|
||||||
|
await octokit.request("GET /orgs/{org}/actions/secrets", {
|
||||||
|
org: integration.owner as string
|
||||||
|
})
|
||||||
|
).data.secrets;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GithubScope.Env: {
|
||||||
|
encryptedSecrets = (
|
||||||
|
await octokit.request("GET /repositories/{repository_id}/environments/{environment_name}/secrets", {
|
||||||
|
repository_id: Number(integration.appId),
|
||||||
|
environment_name: integration.targetEnvironmentId as string
|
||||||
|
})
|
||||||
|
).data.secrets;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
encryptedSecrets = (
|
||||||
|
await octokit.request("GET /repos/{owner}/{repo}/actions/secrets", {
|
||||||
|
owner: integration.owner as string,
|
||||||
|
repo: integration.app as string
|
||||||
|
})
|
||||||
|
).data.secrets;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const encryptedSecret of encryptedSecrets) {
|
||||||
|
if (encryptedSecret.name in secrets) {
|
||||||
|
switch (integration.scope) {
|
||||||
|
case GithubScope.Org: {
|
||||||
|
await octokit.request("DELETE /orgs/{org}/actions/secrets/{secret_name}", {
|
||||||
|
org: integration.owner as string,
|
||||||
|
secret_name: encryptedSecret.name
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GithubScope.Env: {
|
||||||
|
await octokit.request(
|
||||||
|
"DELETE /repositories/{repository_id}/environments/{environment_name}/secrets/{secret_name}",
|
||||||
|
{
|
||||||
|
repository_id: Number(integration.appId),
|
||||||
|
environment_name: integration.targetEnvironmentId as string,
|
||||||
|
secret_name: encryptedSecret.name
|
||||||
|
}
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
await octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", {
|
||||||
|
owner: integration.owner as string,
|
||||||
|
repo: integration.app as string,
|
||||||
|
secret_name: encryptedSecret.name
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteIntegrationSecrets = async ({
|
||||||
|
integration,
|
||||||
|
integrationAuth,
|
||||||
|
integrationAuthService,
|
||||||
|
projectBotService,
|
||||||
|
secretV2BridgeDAL,
|
||||||
|
folderDAL,
|
||||||
|
secretDAL,
|
||||||
|
secretImportDAL,
|
||||||
|
kmsService
|
||||||
|
}: {
|
||||||
|
integration: Omit<TIntegrations, "envId"> & {
|
||||||
|
projectId: string;
|
||||||
|
environment: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
secretPath: string;
|
||||||
|
};
|
||||||
|
integrationAuth: TIntegrationAuths;
|
||||||
|
integrationAuthService: Pick<TIntegrationAuthServiceFactory, "getIntegrationAccessToken" | "getIntegrationAuth">;
|
||||||
|
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||||
|
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find" | "findByFolderId">;
|
||||||
|
folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath" | "findBySecretPath">;
|
||||||
|
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
|
||||||
|
secretDAL: Pick<TSecretDALFactory, "findByFolderId">;
|
||||||
|
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||||
|
}) => {
|
||||||
|
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integration.projectId);
|
||||||
|
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||||
|
type: KmsDataKey.SecretManager,
|
||||||
|
projectId: integration.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(
|
||||||
|
integration.projectId,
|
||||||
|
integration.environment.slug,
|
||||||
|
integration.secretPath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: "Folder not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken } = await integrationAuthService.getIntegrationAccessToken(
|
||||||
|
integrationAuth,
|
||||||
|
shouldUseSecretV2Bridge,
|
||||||
|
botKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const secrets = shouldUseSecretV2Bridge
|
||||||
|
? await getIntegrationSecretsV2(
|
||||||
|
{
|
||||||
|
environment: integration.environment.id,
|
||||||
|
projectId: integration.projectId,
|
||||||
|
folderId: folder.id,
|
||||||
|
depth: 1,
|
||||||
|
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "")
|
||||||
|
},
|
||||||
|
secretV2BridgeDAL,
|
||||||
|
folderDAL,
|
||||||
|
secretImportDAL
|
||||||
|
)
|
||||||
|
: await getIntegrationSecrets(
|
||||||
|
{
|
||||||
|
environment: integration.environment.id,
|
||||||
|
projectId: integration.projectId,
|
||||||
|
folderId: folder.id,
|
||||||
|
key: botKey as string,
|
||||||
|
depth: 1
|
||||||
|
},
|
||||||
|
secretDAL,
|
||||||
|
folderDAL,
|
||||||
|
secretImportDAL
|
||||||
|
);
|
||||||
|
|
||||||
|
const suffixedSecrets: typeof secrets = {};
|
||||||
|
const metadata = IntegrationMetadataSchema.parse(integration.metadata);
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
Object.keys(secrets).forEach((key) => {
|
||||||
|
const prefix = metadata?.secretPrefix || "";
|
||||||
|
const suffix = metadata?.secretSuffix || "";
|
||||||
|
const newKey = prefix + key + suffix;
|
||||||
|
suffixedSecrets[newKey] = secrets[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (integration.integration) {
|
||||||
|
case Integrations.GITHUB: {
|
||||||
|
await deleteGithubSecrets({
|
||||||
|
integration,
|
||||||
|
accessToken,
|
||||||
|
secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Invalid integration"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,8 +6,15 @@ import { BadRequestError } from "@app/lib/errors";
|
|||||||
import { TProjectPermission } from "@app/lib/types";
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal";
|
import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal";
|
||||||
|
import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service";
|
||||||
|
import { deleteIntegrationSecrets } from "../integration-auth/integration-delete-secret";
|
||||||
|
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||||
|
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
|
||||||
|
import { TSecretDALFactory } from "../secret/secret-dal";
|
||||||
import { TSecretQueueFactory } from "../secret/secret-queue";
|
import { TSecretQueueFactory } from "../secret/secret-queue";
|
||||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||||
|
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
|
||||||
|
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||||
import { TIntegrationDALFactory } from "./integration-dal";
|
import { TIntegrationDALFactory } from "./integration-dal";
|
||||||
import {
|
import {
|
||||||
TCreateIntegrationDTO,
|
TCreateIntegrationDTO,
|
||||||
@@ -19,9 +26,15 @@ import {
|
|||||||
type TIntegrationServiceFactoryDep = {
|
type TIntegrationServiceFactoryDep = {
|
||||||
integrationDAL: TIntegrationDALFactory;
|
integrationDAL: TIntegrationDALFactory;
|
||||||
integrationAuthDAL: TIntegrationAuthDALFactory;
|
integrationAuthDAL: TIntegrationAuthDALFactory;
|
||||||
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
|
integrationAuthService: TIntegrationAuthServiceFactory;
|
||||||
|
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findByManySecretPath">;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
|
projectBotService: TProjectBotServiceFactory;
|
||||||
secretQueueService: Pick<TSecretQueueFactory, "syncIntegrations">;
|
secretQueueService: Pick<TSecretQueueFactory, "syncIntegrations">;
|
||||||
|
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find" | "findByFolderId">;
|
||||||
|
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
|
||||||
|
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||||
|
secretDAL: Pick<TSecretDALFactory, "findByFolderId">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TIntegrationServiceFactory = ReturnType<typeof integrationServiceFactory>;
|
export type TIntegrationServiceFactory = ReturnType<typeof integrationServiceFactory>;
|
||||||
@@ -31,7 +44,13 @@ export const integrationServiceFactory = ({
|
|||||||
integrationAuthDAL,
|
integrationAuthDAL,
|
||||||
folderDAL,
|
folderDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
secretQueueService
|
secretQueueService,
|
||||||
|
integrationAuthService,
|
||||||
|
projectBotService,
|
||||||
|
secretV2BridgeDAL,
|
||||||
|
secretImportDAL,
|
||||||
|
kmsService,
|
||||||
|
secretDAL
|
||||||
}: TIntegrationServiceFactoryDep) => {
|
}: TIntegrationServiceFactoryDep) => {
|
||||||
const createIntegration = async ({
|
const createIntegration = async ({
|
||||||
app,
|
app,
|
||||||
@@ -161,7 +180,14 @@ export const integrationServiceFactory = ({
|
|||||||
return updatedIntegration;
|
return updatedIntegration;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteIntegration = async ({ actorId, id, actor, actorAuthMethod, actorOrgId }: TDeleteIntegrationDTO) => {
|
const deleteIntegration = async ({
|
||||||
|
actorId,
|
||||||
|
id,
|
||||||
|
actor,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
shouldDeleteIntegrationSecrets
|
||||||
|
}: TDeleteIntegrationDTO) => {
|
||||||
const integration = await integrationDAL.findById(id);
|
const integration = await integrationDAL.findById(id);
|
||||||
if (!integration) throw new BadRequestError({ message: "Integration auth not found" });
|
if (!integration) throw new BadRequestError({ message: "Integration auth not found" });
|
||||||
|
|
||||||
@@ -174,6 +200,22 @@ export const integrationServiceFactory = ({
|
|||||||
);
|
);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations);
|
||||||
|
|
||||||
|
const integrationAuth = await integrationAuthDAL.findById(integration.integrationAuthId);
|
||||||
|
|
||||||
|
if (shouldDeleteIntegrationSecrets) {
|
||||||
|
await deleteIntegrationSecrets({
|
||||||
|
integration,
|
||||||
|
integrationAuth,
|
||||||
|
projectBotService,
|
||||||
|
integrationAuthService,
|
||||||
|
secretV2BridgeDAL,
|
||||||
|
folderDAL,
|
||||||
|
secretImportDAL,
|
||||||
|
secretDAL,
|
||||||
|
kmsService
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const deletedIntegration = await integrationDAL.transaction(async (tx) => {
|
const deletedIntegration = await integrationDAL.transaction(async (tx) => {
|
||||||
// delete integration
|
// delete integration
|
||||||
const deletedIntegrationResult = await integrationDAL.deleteById(id, tx);
|
const deletedIntegrationResult = await integrationDAL.deleteById(id, tx);
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export type TUpdateIntegrationDTO = {
|
|||||||
|
|
||||||
export type TDeleteIntegrationDTO = {
|
export type TDeleteIntegrationDTO = {
|
||||||
id: string;
|
id: string;
|
||||||
|
shouldDeleteIntegrationSecrets?: boolean;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
export type TSyncIntegrationDTO = {
|
export type TSyncIntegrationDTO = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useToggle } from "@app/hooks";
|
import { useToggle } from "@app/hooks";
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ type Props = {
|
|||||||
subTitle?: string;
|
subTitle?: string;
|
||||||
onDeleteApproved: () => Promise<void>;
|
onDeleteApproved: () => Promise<void>;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteActionModal = ({
|
export const DeleteActionModal = ({
|
||||||
@@ -26,7 +27,8 @@ export const DeleteActionModal = ({
|
|||||||
onDeleteApproved,
|
onDeleteApproved,
|
||||||
title,
|
title,
|
||||||
subTitle = "This action is irreversible.",
|
subTitle = "This action is irreversible.",
|
||||||
buttonText = "Delete"
|
buttonText = "Delete",
|
||||||
|
children
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const [inputData, setInputData] = useState("");
|
const [inputData, setInputData] = useState("");
|
||||||
const [isLoading, setIsLoading] = useToggle();
|
const [isLoading, setIsLoading] = useToggle();
|
||||||
@@ -97,6 +99,7 @@ export const DeleteActionModal = ({
|
|||||||
placeholder={`Type ${deleteKey} here`}
|
placeholder={`Type ${deleteKey} here`}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
{children}
|
||||||
</form>
|
</form>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -110,8 +110,15 @@ export const useCreateIntegration = () => {
|
|||||||
export const useDeleteIntegration = () => {
|
export const useDeleteIntegration = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<{}, {}, { id: string; workspaceId: string }>({
|
return useMutation<
|
||||||
mutationFn: ({ id }) => apiRequest.delete(`/api/v1/integration/${id}`),
|
{},
|
||||||
|
{},
|
||||||
|
{ id: string; workspaceId: string; shouldDeleteIntegrationSecrets: boolean }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ id, shouldDeleteIntegrationSecrets }) =>
|
||||||
|
apiRequest.delete(
|
||||||
|
`/api/v1/integration/${id}?shouldDeleteIntegrationSecrets=${shouldDeleteIntegrationSecrets}`
|
||||||
|
),
|
||||||
onSuccess: (_, { workspaceId }) => {
|
onSuccess: (_, { workspaceId }) => {
|
||||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceIntegrations(workspaceId));
|
queryClient.invalidateQueries(workspaceKeys.getWorkspaceIntegrations(workspaceId));
|
||||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceAuthorization(workspaceId));
|
queryClient.invalidateQueries(workspaceKeys.getWorkspaceAuthorization(workspaceId));
|
||||||
|
|||||||
@@ -106,9 +106,13 @@ export const IntegrationsPage = withProjectPermission(
|
|||||||
handleProviderIntegration(provider);
|
handleProviderIntegration(provider);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIntegrationDelete = async (integrationId: string, cb: () => void) => {
|
const handleIntegrationDelete = async (
|
||||||
|
integrationId: string,
|
||||||
|
shouldDeleteIntegrationSecrets: boolean,
|
||||||
|
cb: () => void
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
await deleteIntegration({ id: integrationId, workspaceId });
|
await deleteIntegration({ id: integrationId, workspaceId, shouldDeleteIntegrationSecrets });
|
||||||
if (cb) cb();
|
if (cb) cb();
|
||||||
createNotification({
|
createNotification({
|
||||||
type: "success",
|
type: "success",
|
||||||
@@ -152,7 +156,7 @@ export const IntegrationsPage = withProjectPermission(
|
|||||||
isLoading={isIntegrationLoading}
|
isLoading={isIntegrationLoading}
|
||||||
integrations={integrations}
|
integrations={integrations}
|
||||||
environments={environments}
|
environments={environments}
|
||||||
onIntegrationDelete={({ id }, cb) => handleIntegrationDelete(id, cb)}
|
onIntegrationDelete={handleIntegrationDelete}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
/>
|
/>
|
||||||
<CloudIntegrationSection
|
<CloudIntegrationSection
|
||||||
|
|||||||
+57
-9
@@ -7,6 +7,7 @@ import { integrationSlugNameMapping } from "public/data/frequentConstants";
|
|||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
DeleteActionModal,
|
DeleteActionModal,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
Tooltip
|
Tooltip
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp, useToggle } from "@app/hooks";
|
||||||
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
|
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
|
||||||
import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types";
|
import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types";
|
||||||
import { TIntegration } from "@app/hooks/api/types";
|
import { TIntegration } from "@app/hooks/api/types";
|
||||||
@@ -25,7 +26,11 @@ type Props = {
|
|||||||
environments: Array<{ name: string; slug: string; id: string }>;
|
environments: Array<{ name: string; slug: string; id: string }>;
|
||||||
integrations?: TIntegration[];
|
integrations?: TIntegration[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onIntegrationDelete: (integration: TIntegration, cb: () => void) => void;
|
onIntegrationDelete: (
|
||||||
|
integrationId: string,
|
||||||
|
shouldDeleteIntegrationSecrets: boolean,
|
||||||
|
cb: () => void
|
||||||
|
) => Promise<void>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,10 +42,12 @@ export const IntegrationsSection = ({
|
|||||||
workspaceId
|
workspaceId
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
"deleteConfirmation"
|
"deleteConfirmation",
|
||||||
|
"deleteSecretsConfirmation"
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
const { mutate: syncIntegration } = useSyncIntegration();
|
const { mutate: syncIntegration } = useSyncIntegration();
|
||||||
|
const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -249,7 +256,10 @@ export const IntegrationsSection = ({
|
|||||||
<div className="flex items-end opacity-80 duration-200 hover:opacity-100">
|
<div className="flex items-end opacity-80 duration-200 hover:opacity-100">
|
||||||
<Tooltip content="Remove Integration">
|
<Tooltip content="Remove Integration">
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => handlePopUpOpen("deleteConfirmation", integration)}
|
onClick={() => {
|
||||||
|
setShouldDeleteSecrets.off();
|
||||||
|
handlePopUpOpen("deleteConfirmation", integration);
|
||||||
|
}}
|
||||||
ariaLabel="delete"
|
ariaLabel="delete"
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
colorSchema="danger"
|
colorSchema="danger"
|
||||||
@@ -281,11 +291,49 @@ export const IntegrationsSection = ({
|
|||||||
(popUp?.deleteConfirmation?.data as TIntegration)?.integration ||
|
(popUp?.deleteConfirmation?.data as TIntegration)?.integration ||
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
onDeleteApproved={async () =>
|
onDeleteApproved={async () => {
|
||||||
onIntegrationDelete(popUp?.deleteConfirmation.data as TIntegration, () =>
|
if (shouldDeleteSecrets) {
|
||||||
handlePopUpClose("deleteConfirmation")
|
handlePopUpOpen("deleteSecretsConfirmation");
|
||||||
)
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await onIntegrationDelete(
|
||||||
|
(popUp?.deleteConfirmation.data as TIntegration).id,
|
||||||
|
false,
|
||||||
|
() => handlePopUpClose("deleteConfirmation")
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(popUp?.deleteConfirmation?.data as TIntegration)?.integration === "github" && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Checkbox
|
||||||
|
id="delete-integration-secrets"
|
||||||
|
checkIndicatorBg="text-white"
|
||||||
|
onCheckedChange={() => setShouldDeleteSecrets.toggle()}
|
||||||
|
>
|
||||||
|
Delete secrets in destination
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DeleteActionModal>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteSecretsConfirmation.isOpen}
|
||||||
|
title={`Are you sure you also want to delete secrets on ${
|
||||||
|
(popUp?.deleteConfirmation.data as TIntegration)?.integration
|
||||||
|
}?`}
|
||||||
|
subTitle="By confirming, all secrets managed by this integration will be deleted in the destination. This action is irreversible."
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteSecretsConfirmation", isOpen)}
|
||||||
|
deleteKey="confirm"
|
||||||
|
onDeleteApproved={async () => {
|
||||||
|
await onIntegrationDelete(
|
||||||
|
(popUp?.deleteConfirmation.data as TIntegration).id,
|
||||||
|
true,
|
||||||
|
() => {
|
||||||
|
handlePopUpClose("deleteSecretsConfirmation");
|
||||||
|
handlePopUpClose("deleteConfirmation");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user