feat: alerting for failed revocations

This commit is contained in:
Daniel Hougaard
2025-11-25 15:40:41 -08:00
parent 0b8e39713d
commit d85922c074
9 changed files with 254 additions and 68 deletions
@@ -1,4 +1,6 @@
import { ProjectMembershipRole } from "@app/db/schemas";
import { DisableRotationErrors } from "@app/ee/services/secret-rotation/secret-rotation-queue"; import { DisableRotationErrors } from "@app/ee/services/secret-rotation/secret-rotation-queue";
import { getConfig } from "@app/lib/config/env";
import { applyJitter } from "@app/lib/delay"; import { applyJitter } from "@app/lib/delay";
import { NotFoundError } from "@app/lib/errors"; import { NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
@@ -6,8 +8,10 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TSmtpService } from "@app/services/smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal";
@@ -22,7 +26,9 @@ type TDynamicSecretLeaseQueueServiceFactoryDep = {
smtpService: Pick<TSmtpService, "sendMail">; smtpService: Pick<TSmtpService, "sendMail">;
userDAL: Pick<TUserDALFactory, "findById">; userDAL: Pick<TUserDALFactory, "findById">;
identityDAL: TIdentityDALFactory; identityDAL: TIdentityDALFactory;
dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "findById" | "deleteById" | "updateById">; dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "findById" | "deleteById" | "updateById" | "findOne">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
projectDAL: Pick<TProjectDALFactory, "findById">;
dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>; dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
folderDAL: Pick<TSecretFolderDALFactory, "findById">; folderDAL: Pick<TSecretFolderDALFactory, "findById">;
@@ -30,9 +36,9 @@ type TDynamicSecretLeaseQueueServiceFactoryDep = {
export type TDynamicSecretLeaseQueueServiceFactory = { export type TDynamicSecretLeaseQueueServiceFactory = {
pruneDynamicSecret: (dynamicSecretCfgId: string) => Promise<void>; pruneDynamicSecret: (dynamicSecretCfgId: string) => Promise<void>;
setLeaseRevocation: (leaseId: string, expiryAt: Date) => Promise<void>; setLeaseRevocation: (leaseId: string, dynamicSecretId: string, expiryAt: Date) => Promise<void>;
unsetLeaseRevocation: (leaseId: string) => Promise<void>; unsetLeaseRevocation: (leaseId: string) => Promise<void>;
queueFailedRevocation: (leaseId: string) => Promise<void>; queueFailedRevocation: (leaseId: string, dynamicSecretId: string) => Promise<void>;
init: () => Promise<void>; init: () => Promise<void>;
}; };
@@ -42,7 +48,10 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
dynamicSecretProviders, dynamicSecretProviders,
dynamicSecretLeaseDAL, dynamicSecretLeaseDAL,
kmsService, kmsService,
folderDAL folderDAL,
projectMembershipDAL,
projectDAL,
smtpService
}: TDynamicSecretLeaseQueueServiceFactoryDep): TDynamicSecretLeaseQueueServiceFactory => { }: TDynamicSecretLeaseQueueServiceFactoryDep): TDynamicSecretLeaseQueueServiceFactory => {
const pruneDynamicSecret = async (dynamicSecretCfgId: string) => { const pruneDynamicSecret = async (dynamicSecretCfgId: string) => {
await queueService.queuePg<QueueName.DynamicSecretRevocation>( await queueService.queuePg<QueueName.DynamicSecretRevocation>(
@@ -56,10 +65,10 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
); );
}; };
const setLeaseRevocation = async (leaseId: string, expiryAt: Date) => { const setLeaseRevocation = async (leaseId: string, dynamicSecretId: string, expiryAt: Date) => {
await queueService.queuePg<QueueName.DynamicSecretRevocation>( await queueService.queuePg<QueueName.DynamicSecretRevocation>(
QueueJobs.DynamicSecretRevocation, QueueJobs.DynamicSecretRevocation,
{ leaseId }, { leaseId, dynamicSecretId },
{ {
id: leaseId, id: leaseId,
singletonKey: leaseId, singletonKey: leaseId,
@@ -76,16 +85,39 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, leaseId); await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, leaseId);
}; };
const queueFailedRevocation = async (leaseId: string) => { const queueFailedRevocation = async (leaseId: string, dynamicSecretId: string) => {
const appConfig = getConfig();
const retryDelaySeconds = appConfig.isDevelopmentMode ? 1 : Math.floor(applyJitter(3_600_000 * 4) / 1000); // retry every 4 hours with 20% +- jitter (convert ms to seconds for pgboss)
await queueService.queuePg<QueueName.DynamicSecretRevocation>( await queueService.queuePg<QueueName.DynamicSecretRevocation>(
QueueJobs.DynamicSecretRevocation, QueueJobs.DynamicSecretRevocation,
{ leaseId }, { leaseId, isRetry: true, dynamicSecretId },
{ {
singletonKey: `${leaseId}-retry`, // avoid conflicts with scheduled revocation singletonKey: `${leaseId}-retry`, // avoid conflicts with scheduled revocation
retryDelay: Math.floor(applyJitter(3_600_000 * 4) / 1000), // retry every 4 hours with 20% +- jitter (convert ms to seconds for pgboss) retryDelay: retryDelaySeconds,
retryLimit: 10, // we dont want it to ever hit the limit, we want the expireInHours to take effect. retryLimit: 10, // we dont want it to ever hit the limit, we want the expireInHours to take effect.
expireInHours: 23, // if we set it to 24 hours, pgboss will complain that the expireIn is too high expireInHours: 23 // if we set it to 24 hours, pgboss will complain that the expireIn is too high
deadLetter: QueueName.DynamicSecretRevocationFailedRetry // if all fails, we will send a notification to the user }
);
};
const $queueDynamicSecretLeaseRevocationFailedEmail = async (leaseId: string, dynamicSecretId: string) => {
await queueService.queue(
QueueName.DynamicSecretLeaseRevocationFailedEmail,
QueueJobs.DynamicSecretLeaseRevocationFailedEmail,
{
leaseId
},
{
jobId: `dynamic-secret-lease-revocation-failed-email-${dynamicSecretId}`,
delay: 1000 * 60, // 1 minute
backoff: {
type: "exponential",
delay: 1000 * 60 // 1 minute
},
removeOnComplete: true,
removeOnFail: true
} }
); );
}; };
@@ -93,7 +125,8 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
const $dynamicSecretQueueJob = async ( const $dynamicSecretQueueJob = async (
jobName: string, jobName: string,
jobId: string, jobId: string,
data: { leaseId: string } | { dynamicSecretCfgId: string } data: { leaseId: string; dynamicSecretId: string; isRetry?: boolean } | { dynamicSecretCfgId: string },
retryCount?: number
): Promise<void> => { ): Promise<void> => {
try { try {
if (jobName === QueueJobs.DynamicSecretRevocation) { if (jobName === QueueJobs.DynamicSecretRevocation) {
@@ -185,52 +218,92 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
} }
if (jobName === QueueJobs.DynamicSecretRevocation) { if (jobName === QueueJobs.DynamicSecretRevocation) {
const { leaseId } = data as { leaseId: string }; const { leaseId, isRetry, dynamicSecretId } = data as {
leaseId: string;
isRetry?: boolean;
dynamicSecretId: string;
};
await dynamicSecretLeaseDAL.updateById(leaseId, { await dynamicSecretLeaseDAL.updateById(leaseId, {
status: DynamicSecretStatus.FailedDeletion, status: DynamicSecretStatus.FailedDeletion,
statusDetails: (error as Error)?.message?.slice(0, 255) statusDetails: `${(error as Error)?.message?.slice(0, 255)} - Retrying automatically`
}); });
// if revocation fails, we should stop the job and queue a new job to retry the revocation at a later time. // only add to retry queue if this is not a retry, and if the error is not a DisableRotationErrors error
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId); if (!isRetry && !(error instanceof DisableRotationErrors)) {
await queueFailedRevocation(leaseId); // if revocation fails, we should stop the job and queue a new job to retry the revocation at a later time.
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId);
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId);
await queueFailedRevocation(leaseId, dynamicSecretId);
} else if (isRetry && !(error instanceof DisableRotationErrors)) {
if (retryCount && retryCount === 10) {
await $queueDynamicSecretLeaseRevocationFailedEmail(leaseId, dynamicSecretId);
}
}
} }
if (error instanceof DisableRotationErrors) { if (error instanceof DisableRotationErrors) {
if (jobId) { if (jobId) {
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId); await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId);
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId); await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId);
} }
} else {
// propagate to next part
throw error;
} }
} }
}; };
// TODO(daniel): add alerting. this is scaffolding for now, pending dashboard overview page for alerts. const $dynamicSecretLeaseRevocationFailedEmailJob = async (jobId: string, data: { leaseId: string }) => {
const $dynamicSecretRevocationFailedRetryJob = async (jobData: { leaseId: string }, jobId: string) => {
try { try {
const { leaseId } = jobData; const appCfg = getConfig();
logger.info({ leaseId, jobId }, "Dynamic secret revocation failed. Notifying root user about failed revocation.");
// const lease = await dynamicSecretLeaseDAL.findById(leaseId); const { leaseId } = data;
// if (!lease) { logger.info(
// throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); { leaseId, jobId },
// } "Dynamic secret revocation failed. Notifying project admins about failed revocation."
// const folder = await folderDAL.findById(lease.dynamicSecret.folderId); );
// if (!folder) throw new NotFoundError({ message: `Failed to find folder with ${lease.dynamicSecret.folderId}` });
// const lease = await dynamicSecretLeaseDAL.findById(leaseId);
// if (!lease) {
// this is where we would send a notification to the user who created the identity, that started the revocation process. throw new DisableRotationErrors({ message: "Dynamic secret lease not found" });
// currently we have no way of knowing which user created the identity, so we cannot send them a notification.
// we shouldn't send an email for EVERY failed revocation. we should have a delay in between, so we don't send spam emails.
// we should have a delay for 2 minutes so we only send out (at most) 1 email every 2 minutes for revocations.
// as an example if 100 failed revocations happen at the same time, we only send out 1 email.
} catch (error) {
if (error instanceof DisableRotationErrors) {
if (jobId) {
await queueService.stopJobById(QueueName.DynamicSecretRevocationFailedRetry, jobId);
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocationFailedRetry, jobId);
}
} }
throw error; const dynamicSecret = await dynamicSecretDAL.findOne({ id: lease.dynamicSecretId });
if (!dynamicSecret) {
throw new DisableRotationErrors({ message: "Dynamic secret not found" });
}
const folder = await folderDAL.findById(lease.dynamicSecret.folderId);
if (!folder) throw new NotFoundError({ message: `Failed to find folder with ${lease.dynamicSecret.folderId}` });
const project = await projectDAL.findById(folder.projectId);
const projectMembers = await projectMembershipDAL.findAllProjectMembers(project.id);
const projectAdmins = projectMembers.filter((member) =>
member.roles.some((role) => role.role === ProjectMembershipRole.Admin)
);
await smtpService.sendMail({
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
template: SmtpTemplates.DynamicSecretLeaseRevocationFailed,
subjectLine: "Dynamic Secret Lease Revocation Failed",
substitutions: {
dynamicSecretLeaseUrl: `${appCfg.SITE_URL}/organizations/${project.orgId}/projects/secret-management/${project.id}/secrets/${folder.environment.envSlug}?dynamicSecretId=${lease.dynamicSecret.id}&filterBy=dynamic&search=${dynamicSecret.name}`,
dynamicSecretName: lease.dynamicSecret.name,
projectName: project.name,
environmentSlug: folder.environment.envSlug,
errorMessage: lease.statusDetails || "An unknown error occurred"
}
});
} catch (error) {
logger.error(error, "Failed to send dynamic secret lease revocation failed email");
if (error instanceof DisableRotationErrors) {
if (jobId) {
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretLeaseRevocationFailedEmail, jobId);
await queueService.stopJobById(QueueName.DynamicSecretLeaseRevocationFailedEmail, jobId);
}
} else {
throw error;
}
} }
}; };
@@ -238,14 +311,21 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
await $dynamicSecretQueueJob(job.name, job.id as string, job.data); await $dynamicSecretQueueJob(job.name, job.id as string, job.data);
}); });
// we use redis for sending the email because:
// 1. we are insensitive to losing the jobs in queue in case of a disaster event
// 2. pgboss does not support exclusive job keys on v0.10.x, and upgrading to v0.11.x which supports exclusive jobs comes with a lot of breaking changes, and we would need to manually migrate our existing jobs to the new version
queueService.start(QueueName.DynamicSecretLeaseRevocationFailedEmail, async (job) => {
await $dynamicSecretLeaseRevocationFailedEmailJob(job.id as string, job.data);
});
const init = async () => { const init = async () => {
await queueService.startPg<QueueName.DynamicSecretRevocation>( await queueService.startPg<QueueName.DynamicSecretRevocation>(
QueueJobs.DynamicSecretRevocation, QueueJobs.DynamicSecretRevocation,
async ([job]) => { async ([job]) => {
await $dynamicSecretQueueJob(job.name, job.id, job.data); await $dynamicSecretQueueJob(job.name, job.id, job.data, job.retryCount);
}, },
{ {
workerCount: 5, workerCount: 10,
pollingIntervalSeconds: 1 pollingIntervalSeconds: 1
} }
); );
@@ -260,18 +340,6 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
pollingIntervalSeconds: 1 pollingIntervalSeconds: 1
} }
); );
// this job is triggered when the dead letter queue is triggered from retrying failed lease revocations.
await queueService.startPg<QueueName.DynamicSecretRevocationFailedRetry>(
QueueJobs.DynamicSecretRevocationFailedRetry,
async ([job]) => {
await $dynamicSecretRevocationFailedRetryJob(job.data, job.id);
},
{
workerCount: 5,
pollingIntervalSeconds: 1
}
);
}; };
return { return {
@@ -178,7 +178,7 @@ export const dynamicSecretLeaseServiceFactory = ({
config config
}); });
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt); await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, dynamicSecretCfg.id, expireAt);
return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data }; return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data };
}; };
@@ -272,7 +272,7 @@ export const dynamicSecretLeaseServiceFactory = ({
); );
await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id);
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt); await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, dynamicSecretCfg.id, expireAt);
const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, {
expireAt, expireAt,
externalEntityId: entityId externalEntityId: entityId
@@ -363,7 +363,7 @@ export const dynamicSecretLeaseServiceFactory = ({
statusDetails: error?.message?.slice(0, 255) statusDetails: error?.message?.slice(0, 255)
}); });
// queue a job to retry the revocation at a later time // queue a job to retry the revocation at a later time
await dynamicSecretQueueService.queueFailedRevocation(dynamicSecretLease.id); await dynamicSecretQueueService.queueFailedRevocation(dynamicSecretLease.id, dynamicSecretCfg.id);
return updatedDynamicSecretLease; return updatedDynamicSecretLease;
} }
+6 -4
View File
@@ -61,7 +61,7 @@ export enum QueueName {
SecretPushEventScan = "secret-push-event-scan", SecretPushEventScan = "secret-push-event-scan",
UpgradeProjectToGhost = "upgrade-project-to-ghost", UpgradeProjectToGhost = "upgrade-project-to-ghost",
DynamicSecretRevocation = "dynamic-secret-revocation", DynamicSecretRevocation = "dynamic-secret-revocation",
DynamicSecretRevocationFailedRetry = "dynamic-secret-revocation-failed-retry", DynamicSecretLeaseRevocationFailedEmail = "dynamic-secret-lease-revocation-failed-email",
CaCrlRotation = "ca-crl-rotation", CaCrlRotation = "ca-crl-rotation",
CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber
SecretReplication = "secret-replication", SecretReplication = "secret-replication",
@@ -102,7 +102,6 @@ export enum QueueJobs {
UpgradeProjectToGhost = "upgrade-project-to-ghost-job", UpgradeProjectToGhost = "upgrade-project-to-ghost-job",
DynamicSecretRevocation = "dynamic-secret-revocation", DynamicSecretRevocation = "dynamic-secret-revocation",
DynamicSecretPruning = "dynamic-secret-pruning", DynamicSecretPruning = "dynamic-secret-pruning",
DynamicSecretRevocationFailedRetry = "dynamic-secret-revocation-failed-retry",
CaCrlRotation = "ca-crl-rotation-job", CaCrlRotation = "ca-crl-rotation-job",
SecretReplication = "secret-replication", SecretReplication = "secret-replication",
SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication
@@ -122,6 +121,7 @@ export enum QueueJobs {
SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets",
SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", SecretRotationV2SendNotification = "secret-rotation-v2-send-notification",
CreateFolderTreeCheckpoint = "create-folder-tree-checkpoint", CreateFolderTreeCheckpoint = "create-folder-tree-checkpoint",
DynamicSecretLeaseRevocationFailedEmail = "dynamic-secret-lease-revocation-failed-email",
InvalidateCache = "invalidate-cache", InvalidateCache = "invalidate-cache",
SecretScanningV2FullScan = "secret-scanning-v2-full-scan", SecretScanningV2FullScan = "secret-scanning-v2-full-scan",
SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan",
@@ -221,8 +221,8 @@ export type TQueueJobTypes = {
name: QueueJobs.TelemetryInstanceStats; name: QueueJobs.TelemetryInstanceStats;
payload: undefined; payload: undefined;
}; };
[QueueName.DynamicSecretRevocationFailedRetry]: { [QueueName.DynamicSecretLeaseRevocationFailedEmail]: {
name: QueueJobs.DynamicSecretRevocationFailedRetry; name: QueueJobs.DynamicSecretLeaseRevocationFailedEmail;
payload: { payload: {
leaseId: string; leaseId: string;
}; };
@@ -231,7 +231,9 @@ export type TQueueJobTypes = {
| { | {
name: QueueJobs.DynamicSecretRevocation; name: QueueJobs.DynamicSecretRevocation;
payload: { payload: {
isRetry?: boolean;
leaseId: string; leaseId: string;
dynamicSecretId: string;
}; };
} }
| { | {
+3 -1
View File
@@ -1877,7 +1877,9 @@ export const registerRoutes = async (
kmsService, kmsService,
smtpService, smtpService,
userDAL, userDAL,
identityDAL identityDAL,
projectMembershipDAL,
projectDAL
}); });
const dynamicSecretService = dynamicSecretServiceFactory({ const dynamicSecretService = dynamicSecretServiceFactory({
projectDAL, projectDAL,
@@ -0,0 +1,68 @@
import { Heading, Section, Text } from "@react-email/components";
import { BaseButton } from "./BaseButton";
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
interface DynamicSecretLeaseRevocationFailedTemplateProps
extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
siteUrl: string;
dynamicSecretLeaseUrl: string;
dynamicSecretName: string;
projectName: string;
environmentSlug: string;
errorMessage: string;
}
export const DynamicSecretLeaseRevocationFailedTemplate = ({
siteUrl,
dynamicSecretLeaseUrl,
dynamicSecretName,
projectName,
environmentSlug,
errorMessage
}: DynamicSecretLeaseRevocationFailedTemplateProps) => {
return (
<BaseEmailWrapper
title="Dynamic Secret Lease Revocation Failed"
preview={`Dynamic secret lease revocation failed for dynamic secret ${dynamicSecretName}`}
siteUrl={siteUrl}
>
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
Dynamic Secret Lease Revocation Failed
</Heading>
<Section className="px-[24px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
<Text className="text-black text-[14px] leading-[24px]">
One or more leases for the dynamic secret <strong>{dynamicSecretName}</strong> in project{" "}
<strong>{projectName}</strong> and environment <strong>{environmentSlug}</strong> have failed to revoke after
multiple attempts.
</Text>
<Text className="text-black text-[14px] leading-[24px]">
Please review the dynamic secret lease and attempt to revoke it again.
</Text>
</Section>
<Section className="mt-[24px] bg-gray-50 pt-[2px] mb-[25px] pb-[16px] border border-solid border-gray-200 px-[24px] rounded-md text-gray-800">
<Text className="mb-[0px]">
<strong>Latest error message</strong>
</Text>
<Text className="leading-[24px] text-[14px] text-red-600 mt-[4px]">{errorMessage}</Text>
</Section>
<Section className="text-center">
<BaseButton href={dynamicSecretLeaseUrl}>View Dynamic Secret Leases</BaseButton>
</Section>
</BaseEmailWrapper>
);
};
export default DynamicSecretLeaseRevocationFailedTemplate;
DynamicSecretLeaseRevocationFailedTemplate.PreviewProps = {
errorMessage: 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "[REDACTED]" - tuple concurrently updated.',
dynamicSecretLeaseUrl: "https://infisical.com/test",
leaseId: "717d5013-7194-49d9-b6ac-6192328c2914",
dynamicSecretName: "postgres-prod-db",
projectName: "Development Team",
environmentSlug: "dev",
siteUrl: "https://infisical.com"
} as DynamicSecretLeaseRevocationFailedTemplateProps;
+5 -2
View File
@@ -43,6 +43,7 @@ import {
SubOrganizationInvitationTemplate, SubOrganizationInvitationTemplate,
UnlockAccountTemplate UnlockAccountTemplate
} from "./emails"; } from "./emails";
import DynamicSecretLeaseRevocationFailedTemplate from "./emails/DynamicSecretLeaseRevocationFailedTemplate";
export type TSmtpConfig = SMTPTransport.Options; export type TSmtpConfig = SMTPTransport.Options;
export type TSmtpSendMail = { export type TSmtpSendMail = {
@@ -89,7 +90,8 @@ export enum SmtpTemplates {
SecretScanningV2ScanFailed = "secretScanningV2ScanFailed", SecretScanningV2ScanFailed = "secretScanningV2ScanFailed",
SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected", SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected",
AccountDeletionConfirmation = "accountDeletionConfirmation", AccountDeletionConfirmation = "accountDeletionConfirmation",
HealthAlert = "healthAlert" HealthAlert = "healthAlert",
DynamicSecretLeaseRevocationFailed = "dynamicSecretLeaseRevocationFailed"
} }
export enum SmtpHost { export enum SmtpHost {
@@ -137,7 +139,8 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
[SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate, [SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate,
[SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate, [SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate,
[SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate, [SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate,
[SmtpTemplates.HealthAlert]: HealthAlertTemplate [SmtpTemplates.HealthAlert]: HealthAlertTemplate,
[SmtpTemplates.DynamicSecretLeaseRevocationFailed]: DynamicSecretLeaseRevocationFailedTemplate
}; };
export const smtpServiceFactory = (cfg: TSmtpConfig) => { export const smtpServiceFactory = (cfg: TSmtpConfig) => {
@@ -121,6 +121,9 @@ const Page = () => {
const tableRef = useRef<HTMLTableElement>(null); const tableRef = useRef<HTMLTableElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [selectedDynamicSecretId, setSelectedDynamicSecretId] = useState<string | null>(
routerQueryParams.dynamicSecretId || ""
);
const { isBatchMode, pendingChanges } = useBatchMode(); const { isBatchMode, pendingChanges } = useBatchMode();
const { loadPendingChanges, setExistingKeys } = useBatchModeActions(); const { loadPendingChanges, setExistingKeys } = useBatchModeActions();
@@ -165,6 +168,28 @@ const Page = () => {
if (isVisible) setIsVisible(false); if (isVisible) setIsVisible(false);
}, [environment]); }, [environment]);
useEffect(() => {
if (routerQueryParams.dynamicSecretId !== null) {
setSelectedDynamicSecretId(routerQueryParams.dynamicSecretId);
navigate({
search: (prev) => ({
...prev,
dynamicSecretId: undefined
})
});
// if any of the router query params are changed, we have to clear the selected dynamic secret id to avoid re-rendering the lease modal when it suddendly becomes available
} else {
setSelectedDynamicSecretId(null);
}
}, [
routerQueryParams.filterBy,
routerQueryParams.search,
routerQueryParams.secretPath,
routerQueryParams.tags
]);
const canReadSecret = hasSecretReadValueOrDescribePermission( const canReadSecret = hasSecretReadValueOrDescribePermission(
permission, permission,
ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.DescribeSecret,
@@ -1039,6 +1064,7 @@ const Page = () => {
)} )}
{canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && (
<DynamicSecretListView <DynamicSecretListView
selectedDynamicSecretId={selectedDynamicSecretId}
environment={environment} environment={environment}
projectSlug={projectSlug} projectSlug={projectSlug}
secretPath={secretPath} secretPath={secretPath}
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { faEdit, faFingerprint, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons"; import { faEdit, faFingerprint, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -13,6 +14,7 @@ import {
Tag, Tag,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteDynamicSecret } from "@app/hooks/api"; import { useDeleteDynamicSecret } from "@app/hooks/api";
@@ -36,9 +38,11 @@ type Props = {
environment: string; environment: string;
projectSlug: string; projectSlug: string;
secretPath?: string; secretPath?: string;
selectedDynamicSecretId: string | null;
}; };
export const DynamicSecretListView = ({ export const DynamicSecretListView = ({
selectedDynamicSecretId,
dynamicSecrets = [], dynamicSecrets = [],
environment, environment,
projectSlug, projectSlug,
@@ -71,6 +75,12 @@ export const DynamicSecretListView = ({
}); });
}; };
useEffect(() => {
if (selectedDynamicSecretId) {
handlePopUpOpen("dynamicSecretLeases", selectedDynamicSecretId);
}
}, [selectedDynamicSecretId]);
return ( return (
<> <>
{dynamicSecrets.map((secret) => { {dynamicSecrets.map((secret) => {
@@ -231,7 +241,12 @@ export const DynamicSecretListView = ({
</div> </div>
</div> </div>
<ModalContent <ModalContent
title="Dynamic secret leases" title={
<div className="flex items-center space-x-2">
<p>Dynamic secret leases</p>
<Badge variant="neutral">{secret.name}</Badge>
</div>
}
subTitle="Revoke or renew your secret leases" subTitle="Revoke or renew your secret leases"
className="max-w-3xl" className="max-w-3xl"
> >
@@ -12,6 +12,7 @@ const SecretDashboardPageQueryParamsSchema = z.object({
search: z.string().catch(""), search: z.string().catch(""),
tags: z.string().catch(""), tags: z.string().catch(""),
filterBy: z.string().catch(""), filterBy: z.string().catch(""),
dynamicSecretId: z.string().catch(""),
connectionId: z.string().optional(), connectionId: z.string().optional(),
connectionName: z.string().optional() connectionName: z.string().optional()
}); });
@@ -26,7 +27,8 @@ export const Route = createFileRoute(
secretPath: "/", secretPath: "/",
search: "", search: "",
tags: "", tags: "",
filterBy: "" filterBy: "",
dynamicSecretId: ""
}) })
] ]
}, },