Merge pull request #4535 from Infisical/expand-notification-coverage

feat(notifications): expand notification coverage
This commit is contained in:
x032205
2025-09-16 13:46:13 -04:00
committed by GitHub
19 changed files with 323 additions and 52 deletions
@@ -14,6 +14,8 @@ import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TOrgDALFactory } from "../org/org-dal";
import { getDefaultOrgMembershipRole } from "../org/org-role-fns";
import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal";
@@ -47,6 +49,7 @@ type TAuthLoginServiceFactoryDep = {
totpService: Pick<TTotpServiceFactory, "verifyUserTotp" | "verifyWithUserRecoveryCode">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
orgMembershipDAL: TOrgMembershipDALFactory;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>;
@@ -57,7 +60,8 @@ export const authLoginServiceFactory = ({
orgDAL,
orgMembershipDAL,
totpService,
auditLogService
auditLogService,
notificationService
}: TAuthLoginServiceFactoryDep) => {
/*
* Private
@@ -71,6 +75,16 @@ export const authLoginServiceFactory = ({
if (!isDeviceSeen) {
const newDeviceList = devices.concat([{ ip, userAgent }]);
await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) }, tx);
await notificationService.createUserNotifications([
{
userId: user.id,
type: NotificationType.LOGIN_FROM_NEW_DEVICE,
title: "Login From New Device",
body: `A new device with IP **${ip}** and User Agent **${userAgent}** has logged into your account.`
}
]);
if (user.email) {
await smtpService.sendMail({
template: SmtpTemplates.NewDeviceJoin,
@@ -563,6 +577,18 @@ export const authLoginServiceFactory = ({
.filter(Boolean) as string[];
if (adminEmails.length > 0) {
await notificationService.createUserNotifications(
orgAdmins
.filter((admin) => admin.user.id !== user.id)
.map((admin) => ({
userId: admin.user.id,
orgId: organizationId,
type: NotificationType.ADMIN_SSO_BYPASS,
title: "Security Alert: Admin SSO Bypass",
body: `The org admin **${user.email}** has bypassed enforced SSO login.`
}))
);
await smtpService.sendMail({
recipients: adminEmails,
subjectLine: "Security Alert: Admin SSO Bypass",
@@ -5,6 +5,8 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectServiceFactory } from "../project/project-service";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
@@ -42,6 +44,7 @@ export type TExternalMigrationQueueFactoryDep = {
folderVersionDAL: Pick<TSecretFolderVersionDALFactory, "create">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TExternalMigrationQueueFactory = ReturnType<typeof externalMigrationQueueFactory>;
@@ -62,9 +65,12 @@ export const externalMigrationQueueFactory = ({
folderDAL,
folderCommitService,
folderVersionDAL,
resourceMetadataDAL
resourceMetadataDAL,
notificationService
}: TExternalMigrationQueueFactoryDep) => {
const startImport = async (dto: {
orgId: string;
actorId: string;
actorEmail: string;
importType: ExternalPlatforms;
data: {
@@ -87,9 +93,19 @@ export const externalMigrationQueueFactory = ({
};
queueService.start(QueueName.ImportSecretsFromExternalSource, async (job) => {
const { data, actorEmail, importType } = job.data;
const { data, actorEmail, importType, actorId, orgId } = job.data;
try {
await notificationService.createUserNotifications([
{
userId: actorId,
orgId,
type: NotificationType.IMPORT_STARTED,
title: "Import Started",
body: `An import from **${importType}** to Infisical has been started.`
}
]);
await smtpService.sendMail({
recipients: [actorEmail],
subjectLine: "Infisical import started",
@@ -137,6 +153,16 @@ export const externalMigrationQueueFactory = ({
);
}
await notificationService.createUserNotifications([
{
userId: actorId,
orgId,
type: NotificationType.IMPORT_SUCCESSFUL,
title: "Import Successful",
body: `An import from **${importType}** to Infisical has successfully completed.`
}
]);
await smtpService.sendMail({
recipients: [actorEmail],
subjectLine: "Infisical import successful",
@@ -146,6 +172,17 @@ export const externalMigrationQueueFactory = ({
template: SmtpTemplates.ExternalImportSuccessful
});
} catch (err) {
await notificationService.createUserNotifications([
{
userId: actorId,
orgId,
type: NotificationType.IMPORT_FAILED,
title: "Import Failed",
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
body: `An import from **${importType}** to Infisical has failed: ${(err as any)?.message || "Unknown error"}.`
}
]);
await smtpService.sendMail({
recipients: [job.data.actorEmail],
subjectLine: "Infisical import failed",
@@ -73,6 +73,8 @@ export const externalMigrationServiceFactory = ({
const encrypted = crypto.encryption().symmetric().encryptWithRootEncryptionKey(stringifiedJson);
await externalMigrationQueue.startImport({
actorId: user.id,
orgId: actorOrgId,
actorEmail: user.email!,
importType: ExternalPlatforms.EnvKey,
data: {
@@ -131,6 +133,8 @@ export const externalMigrationServiceFactory = ({
const encrypted = crypto.encryption().symmetric().encryptWithRootEncryptionKey(stringifiedJson);
await externalMigrationQueue.startImport({
actorId: user.id,
orgId: actorOrgId,
actorEmail: user.email!,
importType: ExternalPlatforms.Vault,
data: {
@@ -1,6 +1,21 @@
export enum NotificationType {
ACCESS_APPROVAL_REQUEST = "access-approval-request",
ACCESS_APPROVAL_REQUEST_UPDATED = "access-approval-request-updated"
ACCESS_APPROVAL_REQUEST_UPDATED = "access-approval-request-updated",
ACCESS_POLICY_BYPASSED = "access-policy-bypassed",
SECRET_CHANGE_REQUEST = "secret-change-request",
SECRET_CHANGE_POLICY_BYPASSED = "secret-change-policy-bypassed",
SECRET_ROTATION_FAILED = "secret-rotation-failed",
SECRET_SCANNING_SECRETS_DETECTED = "secret-scanning-secrets-detected",
SECRET_SCANNING_SCAN_FAILED = "secret-scanning-scan-failed",
LOGIN_FROM_NEW_DEVICE = "login-from-new-device",
ADMIN_SSO_BYPASS = "admin-sso-bypass",
IMPORT_STARTED = "import-started",
IMPORT_SUCCESSFUL = "import-successful",
IMPORT_FAILED = "import-failed",
DIRECT_PROJECT_ACCESS_ISSUED_TO_ADMIN = "direct-project-access-issued-to-admin",
PROJECT_ACCESS_REQUEST = "project-access-request",
PROJECT_INVITATION = "project-invitation",
SECRET_SYNC_FAILED = "secret-sync-failed"
}
export interface TCreateUserNotificationDTO {
@@ -5,6 +5,8 @@ import { OrgPermissionAdminConsoleAction, OrgPermissionSubjects } from "@app/ee/
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
@@ -20,6 +22,7 @@ type TOrgAdminServiceFactoryDep = {
>;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create" | "delete">;
smtpService: Pick<TSmtpService, "sendMail">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TOrgAdminServiceFactory = ReturnType<typeof orgAdminServiceFactory>;
@@ -29,7 +32,8 @@ export const orgAdminServiceFactory = ({
projectDAL,
projectMembershipDAL,
projectUserMembershipRoleDAL,
smtpService
smtpService,
notificationService
}: TOrgAdminServiceFactoryDep) => {
const listOrgProjects = async ({
actor,
@@ -130,23 +134,34 @@ export const orgAdminServiceFactory = ({
});
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
const filteredProjectMembers = projectMembers
.filter(
(member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin) && member.userId !== actorId
)
.map((el) => el.user.email!)
.filter(Boolean);
const projectAdmins = projectMembers.filter(
(member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin) && member.userId !== actorId
);
const mappedProjectAdmins = projectAdmins.map((el) => el.user.email!).filter(Boolean);
const actorEmail = projectMembers.find((el) => el.userId === actorId)?.user?.username;
if (filteredProjectMembers.length) {
await smtpService.sendMail({
template: SmtpTemplates.OrgAdminProjectDirectAccess,
recipients: filteredProjectMembers,
subjectLine: "Organization Admin Project Direct Access Issued",
substitutions: {
projectName: project.name,
email: projectMembers.find((el) => el.userId === actorId)?.user?.username
}
});
if (actorEmail) {
await notificationService.createUserNotifications(
projectAdmins.map((member) => ({
userId: member.userId,
orgId: project.orgId,
type: NotificationType.DIRECT_PROJECT_ACCESS_ISSUED_TO_ADMIN,
title: "Direct Project Access Issued",
body: `The organization admin **${actorEmail}** has self-issued direct access to the project **${project.name}**.`
}))
);
if (mappedProjectAdmins.length) {
await smtpService.sendMail({
template: SmtpTemplates.OrgAdminProjectDirectAccess,
recipients: mappedProjectAdmins,
subjectLine: "Organization Admin Project Direct Access Issued",
substitutions: {
projectName: project.name,
email: actorEmail
}
});
}
}
return { isExistingMember: false, membership: updatedMembership };
};
@@ -18,6 +18,8 @@ import { ms } from "@app/lib/ms";
import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal";
import { ActorType } from "../auth/auth-type";
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TOrgDALFactory } from "../org/org-dal";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
@@ -56,6 +58,7 @@ type TProjectMembershipServiceFactoryDep = {
projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
secretReminderRecipientsDAL: Pick<TSecretReminderRecipientsDALFactory, "delete">;
groupProjectDAL: TGroupProjectDALFactory;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TProjectMembershipServiceFactory = ReturnType<typeof projectMembershipServiceFactory>;
@@ -74,7 +77,8 @@ export const projectMembershipServiceFactory = ({
projectDAL,
projectKeyDAL,
secretReminderRecipientsDAL,
licenseService
licenseService,
notificationService
}: TProjectMembershipServiceFactoryDep) => {
const getProjectMemberships = async ({
actorId,
@@ -236,6 +240,16 @@ export const projectMembershipServiceFactory = ({
});
if (sendEmails) {
await notificationService.createUserNotifications(
orgMembers.map((member) => ({
userId: member.userId,
orgId: project.orgId,
type: NotificationType.PROJECT_INVITATION,
title: "Project Invitation",
body: `You've been invited to join the project **${project.name}**.`
}))
);
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
@@ -57,6 +57,8 @@ import { TKmsServiceFactory } from "../kms/kms-service";
import { validateMicrosoftTeamsChannelsSchema } from "../microsoft-teams/microsoft-teams-fns";
import { TMicrosoftTeamsIntegrationDALFactory } from "../microsoft-teams/microsoft-teams-integration-dal";
import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TOrgDALFactory } from "../org/org-dal";
import { TPkiAlertDALFactory } from "../pki-alert/pki-alert-dal";
import { TPkiCollectionDALFactory } from "../pki-collection/pki-collection-dal";
@@ -183,6 +185,7 @@ type TProjectServiceFactoryDep = {
>;
projectTemplateService: TProjectTemplateServiceFactory;
reminderService: Pick<TReminderServiceFactory, "deleteReminderBySecretId">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
@@ -227,7 +230,8 @@ export const projectServiceFactory = ({
projectTemplateService,
groupProjectDAL,
smtpService,
reminderService
reminderService,
notificationService
}: TProjectServiceFactoryDep) => {
/*
* Create workspace. Make user the admin
@@ -1924,6 +1928,21 @@ export const projectServiceFactory = ({
projectTypeUrl = "cert-management";
}
const callbackPath = `/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}`;
await notificationService.createUserNotifications(
projectMembers
.filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin))
.map((member) => ({
userId: member.userId,
orgId: project.orgId,
type: NotificationType.PROJECT_ACCESS_REQUEST,
title: "Project Access Request",
body: `**${userDetails.firstName} ${userDetails.lastName}** (${userDetails.email}) has requested access to the project **${project.name}**.`,
link: callbackPath
}))
);
await smtpService.sendMail({
template: SmtpTemplates.ProjectAccessRequest,
recipients: filteredProjectMembers,
@@ -1934,7 +1953,7 @@ export const projectServiceFactory = ({
projectName: project?.name,
orgName: org?.name,
note: comment,
callback_url: `${appCfg.SITE_URL}/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}`
callback_url: `${appCfg.SITE_URL}${callbackPath}`
}
});
};
@@ -61,6 +61,8 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
export type TSecretSyncQueueFactory = ReturnType<typeof secretSyncQueueFactory>;
@@ -100,6 +102,7 @@ type TSecretSyncQueueFactoryDep = {
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
type SecretSyncActionJob = Job<
@@ -142,7 +145,8 @@ export const secretSyncQueueFactory = ({
folderCommitService,
licenseService,
gatewayService,
gatewayV2Service
gatewayV2Service,
notificationService
}: TSecretSyncQueueFactoryDep) => {
const appCfg = getConfig();
@@ -898,6 +902,19 @@ export const secretSyncQueueFactory = ({
break;
}
const syncPath = `/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}`;
await notificationService.createUserNotifications(
projectAdmins.map((admin) => ({
userId: admin.userId,
orgId: project.orgId,
type: NotificationType.SECRET_SYNC_FAILED,
title: `Secret Sync Failed to ${actionLabel} Secrets`,
body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`,
link: syncPath
}))
);
await smtpService.sendMail({
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
template: SmtpTemplates.SecretSyncFailed,
@@ -910,7 +927,7 @@ export const secretSyncQueueFactory = ({
secretPath: folder?.path,
environment: environment?.name,
projectName: project.name,
syncUrl: `${appCfg.SITE_URL}/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}`
syncUrl: `${appCfg.SITE_URL}${syncPath}`
}
});
};
@@ -32,7 +32,8 @@ export enum PostHogEventTypes {
IssueSshHostHostCert = "Issue SSH Host Host Certificate",
SignCert = "Sign PKI Certificate",
IssueCert = "Issue PKI Certificate",
InvalidateCache = "Invalidate Cache"
InvalidateCache = "Invalidate Cache",
NotificationUpdated = "Notification Updated"
}
export type TSecretModifiedEvent = {
@@ -232,6 +233,14 @@ export type TInvalidateCacheEvent = {
};
};
export type TNotificationUpdatedEvent = {
event: PostHogEventTypes.NotificationUpdated;
properties: {
notificationId: string;
isRead?: boolean;
};
};
export type TPostHogEvent = { distinctId: string; organizationId?: string } & (
| TSecretModifiedEvent
| TAdminInitEvent
@@ -251,4 +260,5 @@ export type TPostHogEvent = { distinctId: string; organizationId?: string } & (
| TSignCertificateEvent
| TIssueCertificateEvent
| TInvalidateCacheEvent
| TNotificationUpdatedEvent
);