mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 21:27:10 +00:00
feat: alerting for failed revocations
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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}
|
||||||
|
|||||||
+16
-1
@@ -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: ""
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user