diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index dc0e5ee67..dbb302da1 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -423,6 +423,11 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TSecretReminderRecipients, + TSecretReminderRecipientsInsert, + TSecretReminderRecipientsUpdate +} from "@app/db/schemas/secret-reminder-recipients"; declare module "knex" { namespace Knex { @@ -994,5 +999,10 @@ declare module "knex/types/tables" { TSecretRotationV2SecretMappingsInsert, TSecretRotationV2SecretMappingsUpdate >; + [TableName.SecretReminderRecipients]: KnexOriginal.CompositeTableType< + TSecretReminderRecipients, + TSecretReminderRecipientsInsert, + TSecretReminderRecipientsUpdate + >; } } diff --git a/backend/src/db/migrations/20250419004044_secret-reminder-recipients.ts b/backend/src/db/migrations/20250419004044_secret-reminder-recipients.ts new file mode 100644 index 000000000..8bf5af5c7 --- /dev/null +++ b/backend/src/db/migrations/20250419004044_secret-reminder-recipients.ts @@ -0,0 +1,34 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasSecretReminderRecipientsTable = await knex.schema.hasTable(TableName.SecretReminderRecipients); + + if (!hasSecretReminderRecipientsTable) { + await knex.schema.createTable(TableName.SecretReminderRecipients, (table) => { + table.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + table.timestamps(true, true, true); + table.uuid("secretId").notNullable(); + table.uuid("userId").notNullable(); + table.string("projectId").notNullable(); + + // Based on userId rather than project membership ID so we can easily extend group support in the future if need be. + // This does however mean we need to manually clean up once a user is removed from a project. + table.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + table.foreign("secretId").references("id").inTable(TableName.SecretV2).onDelete("CASCADE"); + table.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + + table.index("secretId"); + table.unique(["secretId", "userId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasSecretReminderRecipientsTable = await knex.schema.hasTable(TableName.SecretReminderRecipients); + + if (hasSecretReminderRecipientsTable) { + await knex.schema.dropTableIfExists(TableName.SecretReminderRecipients); + } +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index bde35002f..533f9b898 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -20,7 +20,7 @@ export const CertificatesSchema = z.object({ notAfter: z.date(), revokedAt: z.date().nullable().optional(), revocationReason: z.number().nullable().optional(), - altNames: z.string().default("").nullable().optional(), + altNames: z.string().nullable().optional(), caCertId: z.string().uuid(), certificateTemplateId: z.string().uuid().nullable().optional(), keyUsages: z.string().array().nullable().optional(), diff --git a/backend/src/db/schemas/kmip-org-server-certificates.ts b/backend/src/db/schemas/kmip-org-server-certificates.ts index 66e5dcbd6..c23da626b 100644 --- a/backend/src/db/schemas/kmip-org-server-certificates.ts +++ b/backend/src/db/schemas/kmip-org-server-certificates.ts @@ -13,7 +13,7 @@ export const KmipOrgServerCertificatesSchema = z.object({ id: z.string().uuid(), orgId: z.string().uuid(), commonName: z.string(), - altNames: z.string(), + altNames: z.string().nullable().optional(), serialNumber: z.string(), keyAlgorithm: z.string(), issuedAt: z.date(), diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 95561c14a..be80d5ca4 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -146,7 +146,8 @@ export enum TableName { KmipOrgServerCertificates = "kmip_org_server_certificates", KmipClientCertificates = "kmip_client_certificates", SecretRotationV2 = "secret_rotations_v2", - SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings" + SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings", + SecretReminderRecipients = "secret_reminder_recipients" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index 181df25f0..216b50847 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -30,9 +30,9 @@ export const OidcConfigsSchema = z.object({ updatedAt: z.date(), orgId: z.string().uuid(), lastUsed: z.date().nullable().optional(), + manageGroupMemberships: z.boolean().default(false), encryptedOidcClientId: zodBuffer, encryptedOidcClientSecret: zodBuffer, - manageGroupMemberships: z.boolean().default(false), jwtSignatureAlgorithm: z.string().default("RS256") }); diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index eea1808e0..902c564a7 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,6 +23,7 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), + secretShareSendToAnyone: z.boolean().default(true).nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), diff --git a/backend/src/db/schemas/secret-reminder-recipients.ts b/backend/src/db/schemas/secret-reminder-recipients.ts new file mode 100644 index 000000000..3a132b367 --- /dev/null +++ b/backend/src/db/schemas/secret-reminder-recipients.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretReminderRecipientsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + secretId: z.string().uuid(), + userId: z.string().uuid(), + projectId: z.string() +}); + +export type TSecretReminderRecipients = z.infer; +export type TSecretReminderRecipientsInsert = Omit, TImmutableDBKeys>; +export type TSecretReminderRecipientsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts index 5d6358072..839833a9c 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts @@ -33,6 +33,7 @@ export type TApprovalCreateSecretV2Bridge = { secretComment?: string; reminderNote?: string | null; reminderRepeatDays?: number | null; + secretReminderRecipients?: string[] | null; skipMultilineEncoding?: boolean; metadata?: Record; secretMetadata?: ResourceMetadataDTO; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 731288bda..19ee7e331 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -807,6 +807,8 @@ export const RAW_SECRETS = { tagIds: "The ID of the tags to be attached to the updated secret.", secretReminderRepeatDays: "Interval for secret rotation notifications, measured in days.", secretReminderNote: "Note to be attached in notification email.", + secretReminderRecipients: + "An array of user IDs that will receive the reminder email. If not specified, all project members will receive the reminder email.", newSecretName: "The new name for the secret." }, DELETE: { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3d95a81eb..c0aa5641d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -215,6 +215,7 @@ import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-f import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; import { secretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; +import { secretReminderRecipientsDALFactory } from "@app/services/secret-reminder-recipients/secret-reminder-recipients-dal"; import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal"; import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { secretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal"; @@ -419,6 +420,7 @@ export const registerRoutes = async ( const orgGatewayConfigDAL = orgGatewayConfigDALFactory(db); const gatewayDAL = gatewayDALFactory(db); const projectGatewayDAL = projectGatewayDALFactory(db); + const secretReminderRecipientsDAL = secretReminderRecipientsDALFactory(db); const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); @@ -735,6 +737,7 @@ export const registerRoutes = async ( projectKeyDAL, projectRoleDAL, groupProjectDAL, + secretReminderRecipientsDAL, licenseService }); const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ @@ -968,6 +971,7 @@ export const registerRoutes = async ( secretApprovalRequestDAL, projectKeyDAL, projectUserMembershipRoleDAL, + secretReminderRecipientsDAL, orgService, resourceMetadataDAL, secretSyncQueue diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 5a52d4748..54da97682 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { z } from "zod"; -import { SecretFoldersSchema, SecretImportsSchema } from "@app/db/schemas"; +import { SecretFoldersSchema, SecretImportsSchema, UsersSchema } from "@app/db/schemas"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; @@ -594,6 +594,12 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .optional(), secrets: secretRawSchema .extend({ + secretReminderRecipients: z + .object({ + user: UsersSchema.pick({ id: true, email: true, username: true }), + id: z.string() + }) + .array(), secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 40aed624b..c986e40b4 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -662,6 +662,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { .optional() .nullable() .describe(RAW_SECRETS.UPDATE.secretReminderRepeatDays), + secretReminderRecipients: z.string().array().optional().describe(RAW_SECRETS.UPDATE.secretReminderRecipients), newSecretName: SecretNameSchema.optional().describe(RAW_SECRETS.UPDATE.newSecretName), secretComment: z.string().optional().describe(RAW_SECRETS.UPDATE.secretComment) }), @@ -692,6 +693,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { skipMultilineEncoding: req.body.skipMultilineEncoding, tagIds: req.body.tagIds, secretReminderRepeatDays: req.body.secretReminderRepeatDays, + secretReminderRecipients: req.body.secretReminderRecipients, secretReminderNote: req.body.secretReminderNote, metadata: req.body.metadata, newSecretName: req.body.newSecretName, diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 1fe4961d2..a68a690d3 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -23,6 +23,7 @@ import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; @@ -53,6 +54,7 @@ type TProjectMembershipServiceFactoryDep = { projectKeyDAL: Pick; licenseService: Pick; projectUserAdditionalPrivilegeDAL: Pick; + secretReminderRecipientsDAL: Pick; groupProjectDAL: TGroupProjectDALFactory; }; @@ -71,6 +73,7 @@ export const projectMembershipServiceFactory = ({ groupProjectDAL, projectDAL, projectKeyDAL, + secretReminderRecipientsDAL, licenseService }: TProjectMembershipServiceFactoryDep) => { const getProjectMemberships = async ({ @@ -389,6 +392,13 @@ export const projectMembershipServiceFactory = ({ const membership = await projectMembershipDAL.transaction(async (tx) => { const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx); await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx); + await secretReminderRecipientsDAL.delete( + { + projectId, + userId: deletedMembership.userId + }, + tx + ); return deletedMembership; }); return membership; @@ -466,6 +476,16 @@ export const projectMembershipServiceFactory = ({ tx ); + await secretReminderRecipientsDAL.delete( + { + projectId, + $in: { + userId: projectMembers.map(({ user }) => user.id) + } + }, + tx + ); + // delete project keys belonging to users that are not part of any other groups in the project await projectKeyDAL.delete( { @@ -526,6 +546,15 @@ export const projectMembershipServiceFactory = ({ }, tx ); + + await secretReminderRecipientsDAL.delete( + { + projectId, + userId: actorId + }, + tx + ); + const membership = ( await projectMembershipDAL.delete( { diff --git a/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts new file mode 100644 index 000000000..ec4a3f807 --- /dev/null +++ b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts @@ -0,0 +1,36 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSecretReminderRecipientsDALFactory = ReturnType; + +export const secretReminderRecipientsDALFactory = (db: TDbClient) => { + const secretReminderRecipientsOrm = ormify(db, TableName.SecretReminderRecipients); + + const findUsersBySecretId = async (secretId: string, tx?: Knex) => { + const res = await (tx || db.replicaNode())(TableName.SecretReminderRecipients) + .where({ secretId }) + .leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Project, `${TableName.SecretReminderRecipients}.projectId`, `${TableName.Project}.id`) + .leftJoin(TableName.OrgMembership, (bd) => { + void bd + .on(`${TableName.OrgMembership}.userId`, "=", `${TableName.SecretReminderRecipients}.userId`) + .andOn(`${TableName.OrgMembership}.orgId`, "=", `${TableName.Project}.orgId`); + }) + + .where(`${TableName.OrgMembership}.isActive`, true) + .select(selectAllTableCols(TableName.SecretReminderRecipients)) + .select( + db.ref("email").withSchema(TableName.Users).as("email"), + db.ref("username").withSchema(TableName.Users).as("username"), + db.ref("firstName").withSchema(TableName.Users).as("firstName"), + db.ref("lastName").withSchema(TableName.Users).as("lastName") + ); + + return res; + }; + + return { ...secretReminderRecipientsOrm, findUsersBySecretId }; +}; diff --git a/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-types.ts b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-types.ts new file mode 100644 index 000000000..49eb9bf0f --- /dev/null +++ b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-types.ts @@ -0,0 +1,8 @@ +export type TSecretReminderRecipient = { + user: { + id: string; + username: string; + email?: string | null; + }; + id: string; +}; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 05fc7cd35..cf389ac2a 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -79,7 +79,17 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId` ) + .leftJoin( + TableName.SecretReminderRecipients, + `${TableName.SecretV2}.id`, + `${TableName.SecretReminderRecipients}.secretId` + ) + .leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`) .select(selectAllTableCols(TableName.SecretV2)) + .select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId")) + .select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername")) + .select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail")) + .select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId")) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) @@ -103,6 +113,23 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { slug, name: slug }) + }, + { + key: "reminderRecipientId", + label: "secretReminderRecipients" as const, + mapper: ({ + reminderRecipientId, + reminderRecipientUsername, + reminderRecipientEmail, + reminderRecipientUserId + }) => ({ + user: { + id: reminderRecipientUserId, + username: reminderRecipientUsername, + email: reminderRecipientEmail + }, + id: reminderRecipientId + }) } ] }); @@ -484,6 +511,12 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) + .leftJoin( + TableName.SecretReminderRecipients, + `${TableName.SecretV2}.id`, + `${TableName.SecretReminderRecipients}.secretId` + ) + .leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .leftJoin( TableName.SecretRotationV2SecretMapping, @@ -512,6 +545,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { }) as rank` ) ) + .select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId")) + .select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername")) + .select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail")) + .select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId")) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) @@ -556,6 +593,23 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { isRotatedSecret: Boolean(el.rotationId) }), childrenMapper: [ + { + key: "reminderRecipientId", + label: "secretReminderRecipients" as const, + mapper: ({ + reminderRecipientId, + reminderRecipientUsername, + reminderRecipientEmail, + reminderRecipientUserId + }) => ({ + user: { + id: reminderRecipientUserId, + username: reminderRecipientUsername, + email: reminderRecipientEmail + }, + id: reminderRecipientId + }) + }, { key: "tagId", label: "tags" as const, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index f42deb8ff..5c2f6a2f0 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -12,6 +12,7 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "../secret/secret-fns"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretReminderRecipient } from "../secret-reminder-recipients/secret-reminder-recipients-types"; import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; import { TFnSecretBulkDelete, TFnSecretBulkInsert, TFnSecretBulkUpdate } from "./secret-v2-bridge-types"; @@ -353,7 +354,7 @@ export const fnSecretBulkDelete = async ({ deletedSecrets .filter(({ reminderRepeatDays }) => Boolean(reminderRepeatDays)) .map(({ id, reminderRepeatDays }) => - secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }) + secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }, tx) ) ); @@ -684,6 +685,7 @@ export const reshapeBridgeSecret = ( secretMetadata?: ResourceMetadataDTO; isRotatedSecret?: boolean; rotationId?: string; + secretReminderRecipients?: TSecretReminderRecipient[]; }, secretValueHidden: boolean ) => ({ @@ -715,6 +717,7 @@ export const reshapeBridgeSecret = ( updatedAt: secret.updatedAt, isRotatedSecret: secret.isRotatedSecret, rotationId: secret.rotationId, + secretReminderRecipients: secret.secretReminderRecipients || [], ...(secretValueHidden ? { secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index ca815c6e1..010f5beb1 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -544,7 +544,12 @@ export const secretV2BridgeServiceFactory = ({ id: updatedSecret[0].id, ...inputSecret }, - oldSecret: secret, + oldSecret: { + id: secret.id, + secretReminderNote: secret.reminderNote, + secretReminderRepeatDays: secret.reminderRepeatDays, + secretReminderRecipients: secret.secretReminderRecipients?.map((el) => el.user.id) + }, projectId }); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 11149c605..f4a27d4c5 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -94,6 +94,7 @@ export type TUpdateSecretDTO = TProjectPermission & { skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; + secretReminderRecipients?: string[] | null; metadata?: { source?: string; }; @@ -220,7 +221,7 @@ export type TFnSecretBulkDelete = { tx?: Knex; secretDAL: Pick; secretQueueService: { - removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise; + removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise; }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index f08a5a04c..e5f3acdea 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -407,6 +407,7 @@ export const decryptSecretRaw = ( id: secret.id, user: secret.userId, tags: secret.tags?.map((el) => ({ ...el, name: el.slug })), + secretReminderRecipients: [], skipMultilineEncoding: secret.skipMultilineEncoding, secretReminderRepeatDays: secret.secretReminderRepeatDays, secretReminderNote: secret.secretReminderNote, @@ -758,7 +759,7 @@ export const fnSecretBulkDelete = async ({ deletedSecrets .filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays)) .map(({ id, secretReminderRepeatDays }) => - secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }) + secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }, tx) ) ); diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 5791c415d..6a0868741 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -1,11 +1,13 @@ /* eslint-disable no-await-in-loop */ import opentelemetry from "@opentelemetry/api"; import { AxiosError } from "axios"; +import { Knex } from "knex"; import { ProjectMembershipRole, ProjectUpgradeStatus, ProjectVersion, + SecretType, TSecretSnapshotSecretsV2, TSecretVersionsV2 } from "@app/db/schemas"; @@ -53,6 +55,7 @@ import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-sche 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 { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { expandSecretReferencesFactory, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; @@ -109,6 +112,10 @@ type TSecretQueueFactoryDep = { orgService: Pick; projectUserMembershipRoleDAL: Pick; resourceMetadataDAL: Pick; + secretReminderRecipientsDAL: Pick< + TSecretReminderRecipientsDALFactory, + "delete" | "findUsersBySecretId" | "insertMany" | "transaction" + >; secretSyncQueue: Pick; }; @@ -170,6 +177,7 @@ export const secretQueueFactory = ({ projectUserMembershipRoleDAL, projectKeyDAL, resourceMetadataDAL, + secretReminderRecipientsDAL, secretSyncQueue }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); @@ -178,7 +186,11 @@ export const secretQueueFactory = ({ unit: "1" }); - const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { + const removeSecretReminder = async ({ deleteRecipients = true, ...dto }: TRemoveSecretReminderDTO, tx?: Knex) => { + if (deleteRecipients) { + await secretReminderRecipientsDAL.delete({ secretId: dto.secretId }, tx); + } + const appCfg = getConfig(); await queueService.stopRepeatableJob( QueueName.SecretReminder, @@ -224,7 +236,12 @@ export const secretQueueFactory = ({ .replace(":", "-"); }; - const addSecretReminder = async ({ oldSecret, newSecret, projectId }: TCreateSecretReminderDTO) => { + const addSecretReminder = async ({ + oldSecret, + newSecret, + projectId, + deleteRecipients = true + }: TCreateSecretReminderDTO) => { try { const appCfg = getConfig(); @@ -246,7 +263,8 @@ export const secretQueueFactory = ({ if (oldSecret.secretReminderRepeatDays) { await removeSecretReminder({ repeatDays: oldSecret.secretReminderRepeatDays, - secretId: oldSecret.id + secretId: oldSecret.id, + deleteRecipients }); } @@ -283,29 +301,57 @@ export const secretQueueFactory = ({ }; const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => { - const { secretReminderRepeatDays, secretReminderNote } = newSecret; + const { secretReminderRepeatDays, secretReminderNote, secretReminderRecipients } = newSecret; - if (newSecret.type !== "personal" && secretReminderRepeatDays !== undefined) { - if ( - (secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) || - (secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote) - ) { - await addSecretReminder({ - oldSecret, - newSecret, - projectId - }); - } else if ( - secretReminderRepeatDays === null && - secretReminderNote === null && - oldSecret.secretReminderRepeatDays - ) { - await removeSecretReminder({ - secretId: oldSecret.id, - repeatDays: oldSecret.secretReminderRepeatDays - }); + const recipientsUpdated = + secretReminderRecipients?.some( + (newId) => !oldSecret.secretReminderRecipients?.find((oldId) => newId === oldId) + ) || secretReminderRecipients?.length !== oldSecret.secretReminderRecipients?.length; + + await secretReminderRecipientsDAL.transaction(async (tx) => { + if (newSecret.type !== SecretType.Personal && secretReminderRepeatDays !== undefined) { + if ( + (secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) || + (secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote) + ) { + await addSecretReminder({ + oldSecret, + newSecret, + projectId, + deleteRecipients: false + }); + } else if ( + secretReminderRepeatDays === null && + secretReminderNote === null && + oldSecret.secretReminderRepeatDays + ) { + await removeSecretReminder({ + secretId: oldSecret.id, + repeatDays: oldSecret.secretReminderRepeatDays + }); + } } - } + + if (recipientsUpdated) { + // if no recipients, delete all existing recipients + if (!secretReminderRecipients?.length) { + const existingRecipients = await secretReminderRecipientsDAL.findUsersBySecretId(newSecret.id, tx); + if (existingRecipients) { + await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx); + } + } else { + await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx); + await secretReminderRecipientsDAL.insertMany( + secretReminderRecipients.map((r) => ({ + secretId: newSecret.id, + userId: r, + projectId + })), + tx + ); + } + } + }); }; const createManySecretsRawFn = createManySecretsRawFnFactory({ projectDAL, @@ -1071,6 +1117,8 @@ export const secretQueueFactory = ({ const secret = await secretV2BridgeDAL.findById(data.secretId); const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]); + const recipients = await secretReminderRecipientsDAL.findUsersBySecretId(data.secretId); + if (!organization) { logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`); return; @@ -1088,10 +1136,14 @@ export const secretQueueFactory = ({ return; } + const selectedRecipients = recipients?.length + ? recipients.map((r) => r.email as string) + : projectMembers.map((m) => m.user.email as string); + await smtpService.sendMail({ template: SmtpTemplates.SecretReminder, subjectLine: "Infisical secret reminder", - recipients: [...projectMembers.map((m) => m.user.email)].filter((email) => email).map((email) => email as string), + recipients: selectedRecipients, substitutions: { reminderNote: data.note, // May not be present. projectName: project.name, diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index a82b04833..46ed7ef83 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -546,10 +546,13 @@ export const secretServiceFactory = ({ for await (const secret of secrets) { if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { - await secretQueueService.removeSecretReminder({ - repeatDays: secret.secretReminderRepeatDays, - secretId: secret.id - }); + await secretQueueService.removeSecretReminder( + { + repeatDays: secret.secretReminderRepeatDays, + secretId: secret.id + }, + tx + ); } } @@ -685,6 +688,7 @@ export const secretServiceFactory = ({ ...secret, workspace: projectId, environment, + secretReminderRecipients: [], secretPath: groupedPaths[secret.folderId][0].path })) }; @@ -1073,10 +1077,13 @@ export const secretServiceFactory = ({ for await (const secret of secrets) { if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { - await secretQueueService.removeSecretReminder({ - repeatDays: secret.secretReminderRepeatDays, - secretId: secret.id - }); + await secretQueueService.removeSecretReminder( + { + repeatDays: secret.secretReminderRepeatDays, + secretId: secret.id + }, + tx + ); } } const secretValueHidden = !hasSecretReadValueOrDescribePermission( @@ -1786,6 +1793,7 @@ export const secretServiceFactory = ({ tagIds, secretReminderNote, secretReminderRepeatDays, + secretReminderRecipients, metadata, secretComment, newSecretName, @@ -1828,6 +1836,7 @@ export const secretServiceFactory = ({ tagIds, reminderNote: secretReminderNote, reminderRepeatDays: secretReminderRepeatDays, + secretReminderRecipients, secretMetadata } ] @@ -1837,8 +1846,9 @@ export const secretServiceFactory = ({ } const secret = await secretV2BridgeService.updateSecret({ secretReminderRepeatDays, - skipMultilineEncoding, secretReminderNote, + secretReminderRecipients, + skipMultilineEncoding, tagIds, secretComment, secretPath, diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index be036cab8..30e3dfafa 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -22,9 +22,13 @@ import { SecretUpdateMode } from "../secret-v2-bridge/secret-v2-bridge-types"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; -type TPartialSecret = Pick; +type TPartialSecret = Pick & { + secretReminderRecipients?: string[] | null; +}; -type TPartialInputSecret = Pick; +type TPartialInputSecret = Pick & { + secretReminderRecipients?: string[] | null; +}; export const FailedIntegrationSyncEmailsPayloadSchema = z.object({ projectId: z.string(), @@ -258,6 +262,7 @@ export type TUpdateSecretRawDTO = TProjectPermission & { skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; + secretReminderRecipients?: string[] | null; metadata?: { source?: string; }; @@ -374,7 +379,7 @@ export type TFnSecretBulkDelete = { tx?: Knex; secretDAL: Pick; secretQueueService: { - removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise; + removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise; }; }; @@ -405,11 +410,14 @@ export type TCreateSecretReminderDTO = { oldSecret: TPartialSecret; newSecret: TPartialSecret; projectId: string; + + deleteRecipients?: boolean; }; export type TRemoveSecretReminderDTO = { secretId: string; repeatDays: number; + deleteRecipients?: boolean; }; export type TBackFillSecretReferencesDTO = TProjectPermission; diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 68453ecdd..3862d1f8d 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -83,6 +83,7 @@ export const useUpdateSecretV3 = ({ secretComment, secretReminderRepeatDays, secretReminderNote, + secretReminderRecipients, newSecretName, skipMultilineEncoding, secretMetadata @@ -93,6 +94,7 @@ export const useUpdateSecretV3 = ({ type, secretReminderNote, secretReminderRepeatDays, + secretReminderRecipients, secretPath, skipMultilineEncoding, newSecretName, diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index b7370a29a..10796c6e2 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -80,6 +80,7 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => { comment: el.secretComment || "", reminderRepeatDays: el.secretReminderRepeatDays, reminderNote: el.secretReminderNote, + secretReminderRecipients: el.secretReminderRecipients, createdAt: el.createdAt, updatedAt: el.updatedAt, version: el.version, diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 187ff684c..fa597169f 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -7,6 +7,14 @@ export enum SecretType { Personal = "personal" } +export type SecretReminderRecipient = { + user: { + id: string; + username: string; + email: string; + }; + id: string; +}; export type EncryptedSecret = { id: string; version: number; @@ -42,6 +50,7 @@ export type SecretV3RawSanitized = { comment?: string; reminderRepeatDays?: number | null; reminderNote?: string | null; + reminderRecipients?: string[]; tags?: WsTag[]; createdAt: string; updatedAt: string; @@ -55,6 +64,7 @@ export type SecretV3RawSanitized = { secretMetadata?: { key: string; value: string }[]; isReminderEvent?: boolean; isRotatedSecret?: boolean; + secretReminderRecipients?: SecretReminderRecipient[]; rotationId?: string; }; @@ -80,6 +90,7 @@ export type SecretV3Raw = { updatedAt: string; isRotatedSecret?: boolean; rotationId?: string; + secretReminderRecipients?: SecretReminderRecipient[]; }; export type SecretV3RawResponse = { @@ -177,6 +188,7 @@ export type TUpdateSecretsV3DTO = { secretReminderNote?: string | null; tagIds?: string[]; secretMetadata?: { key: string; value: string }[]; + secretReminderRecipients?: string[] | null; }; export type TDeleteSecretsV3DTO = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx index 26cc20ab4..d5f1e146c 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx @@ -6,10 +6,28 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { twMerge } from "tailwind-merge"; import { z } from "zod"; -import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2"; +import { + Button, + FilterableSelect, + FormControl, + Input, + Modal, + ModalContent, + TextArea +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetWorkspaceUsers } from "@app/hooks/api"; const ReminderFormSchema = z.object({ note: z.string().optional().nullable(), + recipients: z + .array( + z.object({ + label: z.string(), + value: z.string().uuid() + }) + ) + .optional(), days: z .number() .min(1, { message: "Must be at least 1 day" }) @@ -22,6 +40,7 @@ interface ReminderFormProps { isOpen: boolean; repeatDays?: number | null; note?: string | null; + recipients?: string[] | null; onOpenChange: (isOpen: boolean, data?: TReminderFormSchema) => void; } @@ -29,12 +48,16 @@ export const CreateReminderForm = ({ isOpen, onOpenChange, repeatDays, - note + note, + recipients }: ReminderFormProps) => { + const { currentWorkspace } = useWorkspace(); + + const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace?.id); + const { register, control, - reset, setValue, handleSubmit, formState: { isSubmitting } @@ -51,13 +74,23 @@ export const CreateReminderForm = ({ }; useEffect(() => { - if (isOpen) { - reset({ - days: repeatDays || undefined, - note: note || "" - }); + // On initial load, filter the members to only include the recipients + if (members.length) { + const filteredMembers = members.filter((m) => recipients?.find((r) => r === m.user.id)); + setValue( + "recipients", + filteredMembers.map((m) => ({ + label: m.user.username || m.user.email, + value: m.user.id + })) + ); } - }, [isOpen]); + }, [members, isOpen, recipients]); + + useEffect(() => { + if (repeatDays) setValue("days", repeatDays); + if (note) setValue("note", note); + }, [repeatDays, note]); return ( @@ -74,6 +107,7 @@ export const CreateReminderForm = ({ render={({ field, fieldState }) => ( <> setValue("days", parseInt(el.target.value, 10))} type="number" placeholder="31" + defaultValue={repeatDays || undefined} value={field.value || undefined} /> @@ -105,11 +140,43 @@ export const CreateReminderForm = ({ placeholder="Remember to rotate the AWS secret every month." className="border border-mineshaft-600 text-sm" rows={8} + defaultValue={note || ""} reSize="none" cols={30} {...register("note")} /> + + ( + + Select users to receive reminders. +
+
If none are selected, all project members will receive the reminder. + + } + label="Recipients" + className="mb-0" + > + ({ + label: member.user.username || member.user.email, + value: member.user.id + }))} + value={field.value} + onChange={field.onChange} + /> +
+ )} + />