From 80352acc8a6c2c6d6e7d886810d017455a391b3d Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Mon, 14 Apr 2025 18:31:06 -0300 Subject: [PATCH] Add notification on Service Token expiration --- ...701_add-notification-flag-service-token.ts | 27 +++++++++++++++ backend/src/db/schemas/service-tokens.ts | 3 +- backend/src/server/routes/index.ts | 6 ++-- .../resource-cleanup-queue.ts | 6 +++- .../service-token/service-token-dal.ts | 26 +++++++++++++- .../service-token/service-token-service.ts | 34 +++++++++++++++++-- backend/src/services/smtp/smtp-service.ts | 3 +- .../templates/serviceTokenExpired.handlebars | 19 +++++++++++ 8 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 backend/src/db/migrations/20250414203701_add-notification-flag-service-token.ts create mode 100644 backend/src/services/smtp/templates/serviceTokenExpired.handlebars diff --git a/backend/src/db/migrations/20250414203701_add-notification-flag-service-token.ts b/backend/src/db/migrations/20250414203701_add-notification-flag-service-token.ts new file mode 100644 index 000000000..dbfda96d1 --- /dev/null +++ b/backend/src/db/migrations/20250414203701_add-notification-flag-service-token.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.ServiceToken, "notificationSent"); + if (!hasCol) { + await knex.schema.alterTable(TableName.ServiceToken, (t) => { + t.boolean("notificationSent").defaultTo(false); + }); + + // Update only tokens where expiresAt is before current time + await knex(TableName.ServiceToken) + .whereRaw(`${TableName.ServiceToken}."expiresAt" < NOW()`) + .whereNotNull("expiresAt") + .update({ notificationSent: true }); + } +} + +export async function down(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.ServiceToken, "notificationSent"); + if (hasCol) { + await knex.schema.alterTable(TableName.ServiceToken, (t) => { + t.dropColumn("notificationSent"); + }); + } +} diff --git a/backend/src/db/schemas/service-tokens.ts b/backend/src/db/schemas/service-tokens.ts index 720c8fd6f..40859fc15 100644 --- a/backend/src/db/schemas/service-tokens.ts +++ b/backend/src/db/schemas/service-tokens.ts @@ -21,7 +21,8 @@ export const ServiceTokensSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), createdBy: z.string(), - projectId: z.string() + projectId: z.string(), + notificationSent: z.boolean().default(false).nullable().optional() }); export type TServiceTokens = z.infer; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 743577d25..765a84b66 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1255,7 +1255,8 @@ export const registerRoutes = async ( userDAL, permissionService, projectDAL, - accessTokenQueue + accessTokenQueue, + smtpService }); const identityService = identityServiceFactory({ @@ -1415,7 +1416,8 @@ export const registerRoutes = async ( identityAccessTokenDAL, secretSharingDAL, secretVersionV2DAL: secretVersionV2BridgeDAL, - identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL + identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL, + serviceTokenService }); const dailyExpiringPkiItemAlert = dailyExpiringPkiItemAlertQueueServiceFactory({ diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index f0d579cf7..edd684783 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -10,6 +10,7 @@ import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; +import { TServiceTokenServiceFactory } from "../service-token/service-token-service"; type TDailyResourceCleanUpQueueServiceFactoryDep = { auditLogDAL: Pick; @@ -21,6 +22,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { secretFolderVersionDAL: Pick; snapshotDAL: Pick; secretSharingDAL: Pick; + serviceTokenService: Pick; queueService: TQueueServiceFactory; }; @@ -36,7 +38,8 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ identityAccessTokenDAL, secretSharingDAL, secretVersionV2DAL, - identityUniversalAuthClientSecretDAL + identityUniversalAuthClientSecretDAL, + serviceTokenService }: TDailyResourceCleanUpQueueServiceFactoryDep) => { queueService.start(QueueName.DailyResourceCleanUp, async () => { logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); @@ -50,6 +53,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await secretVersionDAL.pruneExcessVersions(); await secretVersionV2DAL.pruneExcessVersions(); await secretFolderVersionDAL.pruneExcessVersions(); + await serviceTokenService.notifyExpiredTokens(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); }); diff --git a/backend/src/services/service-token/service-token-dal.ts b/backend/src/services/service-token/service-token-dal.ts index ed9c5de7e..f9c457d32 100644 --- a/backend/src/services/service-token/service-token-dal.ts +++ b/backend/src/services/service-token/service-token-dal.ts @@ -28,5 +28,29 @@ export const serviceTokenDALFactory = (db: TDbClient) => { } }; - return { ...stOrm, findById }; + const findExpiredTokens = async (tx?: Knex) => { + try { + const docs: { name: string; projectName: string; createdByEmail: string; id: string; projectId: string }[] = + await (tx || db.replicaNode())(TableName.ServiceToken) + .leftJoin( + TableName.Users, + `${TableName.Users}.id`, + db.raw(`${TableName.ServiceToken}."createdBy"::uuid`) + ) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.ServiceToken}.projectId`) + .whereRaw( + `${TableName.ServiceToken}."expiresAt" < NOW() AND ${TableName.ServiceToken}."notificationSent" = false` + ) + .select(`${TableName.ServiceToken}.name`) + .select(`${TableName.ServiceToken}.id`) + .select(`${TableName.Project}.name as projectName`) + .select(`${TableName.ServiceToken}.projectId`) + .select(`${TableName.Users}.email as createdByEmail`); + + return docs; + } catch (err) { + throw new DatabaseError({ error: err, name: "FindById" }); + } + }; + return { ...stOrm, findById, findExpiredTokens }; }; diff --git a/backend/src/services/service-token/service-token-service.ts b/backend/src/services/service-token/service-token-service.ts index 9b87c29f8..dd2f834bb 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -17,6 +17,7 @@ import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-to import { ActorType } from "../auth/auth-type"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TServiceTokenDALFactory } from "./service-token-dal"; import { @@ -33,6 +34,7 @@ type TServiceTokenServiceFactoryDep = { projectEnvDAL: Pick; projectDAL: Pick; accessTokenQueue: Pick; + smtpService: Pick; }; export type TServiceTokenServiceFactory = ReturnType; @@ -43,7 +45,8 @@ export const serviceTokenServiceFactory = ({ permissionService, projectEnvDAL, projectDAL, - accessTokenQueue + accessTokenQueue, + smtpService }: TServiceTokenServiceFactoryDep) => { const createServiceToken = async ({ iv, @@ -185,11 +188,38 @@ export const serviceTokenServiceFactory = ({ return { ...serviceToken, lastUsed: new Date(), orgId: project.orgId }; }; + const notifyExpiredTokens = async () => { + const appCfg = getConfig(); + + const expiredTokens = await serviceTokenDAL.findExpiredTokens(); + if (expiredTokens.length === 0) return; + + await Promise.all( + expiredTokens.map(async (token) => { + await smtpService + .sendMail({ + recipients: [token.createdByEmail], + subjectLine: "Service Token Expired", + template: SmtpTemplates.ServiceTokenExpired, + substitutions: { + tokenName: token.name, + projectName: token.projectName, + url: `${appCfg.SITE_URL}/secret-manager/${token.projectId}/access-management?selectedTab=service-tokens` + } + }) + .then(async () => { + await serviceTokenDAL.update({ id: token.id }, { notificationSent: true }); + }); + }) + ); + }; + return { createServiceToken, deleteServiceToken, getServiceToken, getProjectServiceTokens, - fnValidateServiceToken + fnValidateServiceToken, + notifyExpiredTokens }; }; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 452283235..25f5f3949 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -43,7 +43,8 @@ export enum SmtpTemplates { SecretRequestCompleted = "secretRequestCompleted.handlebars", SecretRotationFailed = "secretRotationFailed.handlebars", ProjectAccessRequest = "projectAccess.handlebars", - OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars" + OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars", + ServiceTokenExpired = "serviceTokenExpired.handlebars" } export enum SmtpHost { diff --git a/backend/src/services/smtp/templates/serviceTokenExpired.handlebars b/backend/src/services/smtp/templates/serviceTokenExpired.handlebars new file mode 100644 index 000000000..c0d364430 --- /dev/null +++ b/backend/src/services/smtp/templates/serviceTokenExpired.handlebars @@ -0,0 +1,19 @@ + + + + + + Service Token Expired + + + +

Your Service Token has expired

+

Your service token "{{tokenName}}" has expired.

+ +

This token was being used to access the project "{{projectName}}". To ensure continued service, please create a new token as soon as possible.

+ + Create New Token + + {{emailFooter}} + + \ No newline at end of file