diff --git a/backend/src/db/migrations/20251126143442_add-notification-flag-scim-token.ts b/backend/src/db/migrations/20251126143442_add-notification-flag-scim-token.ts new file mode 100644 index 000000000..00dcf7902 --- /dev/null +++ b/backend/src/db/migrations/20251126143442_add-notification-flag-scim-token.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.ScimToken, "expiryNotificationSent"); + if (!hasCol) { + await knex.schema.alterTable(TableName.ScimToken, (t) => { + t.boolean("expiryNotificationSent").defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.ScimToken, "expiryNotificationSent"); + if (hasCol) { + await knex.schema.alterTable(TableName.ScimToken, (t) => { + t.dropColumn("expiryNotificationSent"); + }); + } +} diff --git a/backend/src/db/schemas/scim-tokens.ts b/backend/src/db/schemas/scim-tokens.ts index ab6e10d27..6774b6bfd 100644 --- a/backend/src/db/schemas/scim-tokens.ts +++ b/backend/src/db/schemas/scim-tokens.ts @@ -13,7 +13,8 @@ export const ScimTokensSchema = z.object({ description: z.string(), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + expiryNotificationSent: z.boolean().default(false).nullable().optional() }); export type TScimTokens = z.infer; diff --git a/backend/src/ee/services/scim/scim-dal.ts b/backend/src/ee/services/scim/scim-dal.ts index 77a19d4d2..e856070e1 100644 --- a/backend/src/ee/services/scim/scim-dal.ts +++ b/backend/src/ee/services/scim/scim-dal.ts @@ -1,10 +1,56 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify, TOrmify } from "@app/lib/knex"; +import { AccessScope, OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; -export type TScimDALFactory = TOrmify; +import { TExpiringScimToken } from "./scim-types"; -export const scimDALFactory = (db: TDbClient): TScimDALFactory => { +export type TScimDALFactory = ReturnType; + +export const scimDALFactory = (db: TDbClient) => { const scimTokenOrm = ormify(db, TableName.ScimToken); - return scimTokenOrm; + + const findExpiringTokens = async (tx?: Knex, batchSize = 500, offset = 0): Promise => { + try { + const batch = await (tx || db.replicaNode())(TableName.ScimToken) + .leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.ScimToken}.orgId`) + .leftJoin(TableName.Membership, `${TableName.Membership}.scopeOrgId`, `${TableName.ScimToken}.orgId`) + .leftJoin(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .whereRaw( + ` + (${TableName.ScimToken}."ttlDays" > 0 AND + (${TableName.ScimToken}."createdAt" + INTERVAL '1 day' * ${TableName.ScimToken}."ttlDays") < NOW() + INTERVAL '7 days' AND + (${TableName.ScimToken}."createdAt" + INTERVAL '1 day' * ${TableName.ScimToken}."ttlDays") > NOW()) + ` + ) + .where(`${TableName.ScimToken}.expiryNotificationSent`, false) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.MembershipRole}.role`, OrgMembershipRole.Admin) + .whereNot(`${TableName.Membership}.status`, OrgMembershipStatus.Invited) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .where(`${TableName.Users}.isGhost`, false) + .whereNotNull(`${TableName.Users}.email`) + .groupBy([`${TableName.ScimToken}.id`, `${TableName.Organization}.name`]) + .select([ + db.ref("id").withSchema(TableName.ScimToken), + db.ref("ttlDays").withSchema(TableName.ScimToken), + db.ref("description").withSchema(TableName.ScimToken), + db.ref("orgId").withSchema(TableName.ScimToken), + db.ref("createdAt").withSchema(TableName.ScimToken), + db.ref("name").withSchema(TableName.Organization).as("orgName"), + db.raw(`array_agg(${TableName.Users}."email") as "adminEmails"`) + ]) + .limit(batchSize) + .offset(offset); + + return batch; + } catch (err) { + throw new DatabaseError({ error: err, name: "FindExpiringTokens" }); + } + }; + + return { ...scimTokenOrm, findExpiringTokens }; }; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 8b9256023..0542bc911 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -19,6 +19,7 @@ import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TAdditionalPrivilegeDALFactory } from "@app/services/additional-privilege/additional-privilege-dal"; import { AuthTokenType } from "@app/services/auth/auth-type"; @@ -47,7 +48,7 @@ import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList, p import { TScimGroup, TScimServiceFactory } from "./scim-types"; type TScimServiceFactoryDep = { - scimDAL: Pick; + scimDAL: Pick; userDAL: Pick< TUserDALFactory, "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" | "updateById" @@ -1237,6 +1238,70 @@ export const scimServiceFactory = ({ return { scimTokenId: scimToken.id, orgId: scimToken.orgId }; }; + const notifyExpiringTokens: TScimServiceFactory["notifyExpiringTokens"] = async () => { + const appCfg = getConfig(); + let processedCount = 0; + let hasMoreRecords = true; + let offset = 0; + const batchSize = 500; + + while (hasMoreRecords) { + // eslint-disable-next-line no-await-in-loop + const expiringTokens = await scimDAL.findExpiringTokens(undefined, batchSize, offset); + + if (expiringTokens.length === 0) { + hasMoreRecords = false; + break; + } + + const successfullyNotifiedTokenIds: string[] = []; + + // eslint-disable-next-line no-await-in-loop + await Promise.all( + expiringTokens.map(async (token) => { + try { + if (token.adminEmails.length === 0) { + // Still mark as notified to avoid repeated checks + successfullyNotifiedTokenIds.push(token.id); + return; + } + + const createdOn = new Date(token.createdAt); + const expiringOn = new Date(createdOn.getTime() + Number(token.ttlDays) * 86400 * 1000); + + await smtpService.sendMail({ + recipients: token.adminEmails, + subjectLine: "SCIM Token Expiry Notice", + template: SmtpTemplates.ScimTokenExpired, + substitutions: { + tokenDescription: token.description, + orgName: token.orgName, + url: `${appCfg.SITE_URL}/organizations/${token.orgId}/settings?selectedTab=provisioning-settings`, + createdOn, + expiringOn + } + }); + + successfullyNotifiedTokenIds.push(token.id); + } catch (error) { + logger.error(error, `Failed to send expiration notification for SCIM token ${token.id}:`); + } + }) + ); + + // Batch update all successfully notified tokens in a single query + if (successfullyNotifiedTokenIds.length > 0) { + // eslint-disable-next-line no-await-in-loop + await scimDAL.update({ $in: { id: successfullyNotifiedTokenIds } }, { expiryNotificationSent: true }); + } + + processedCount += expiringTokens.length; + offset += batchSize; + } + + return processedCount; + }; + return { createScimToken, listScimTokens, @@ -1253,6 +1318,7 @@ export const scimServiceFactory = ({ deleteScimGroup, replaceScimGroup, updateScimGroup, - fnValidateScimToken + fnValidateScimToken, + notifyExpiringTokens }; }; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index 8bdea39e1..1275ef283 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -158,6 +158,16 @@ export type TScimGroup = { }; }; +export type TExpiringScimToken = { + id: string; + ttlDays: number; + description: string; + orgId: string; + createdAt: Date; + orgName: string; + adminEmails: string[]; +}; + export type TScimServiceFactory = { createScimToken: (arg: TCreateScimTokenDTO) => Promise<{ scimToken: string; @@ -200,4 +210,5 @@ export type TScimServiceFactory = { scimTokenId: string; orgId: string; }>; + notifyExpiringTokens: () => Promise; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 81cccc0df..ec9e56880 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1913,6 +1913,7 @@ export const registerRoutes = async ( // DAILY const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ + scimService, auditLogDAL, queueService, secretVersionDAL, diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index 185ab5e94..60310765b 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -1,4 +1,5 @@ import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; +import { TScimServiceFactory } from "@app/ee/services/scim/scim-types"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { TKeyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { getConfig } from "@app/lib/config/env"; @@ -29,6 +30,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { orgService: TOrgServiceFactory; userNotificationDAL: Pick; keyValueStoreDAL: Pick; + scimService: Pick; }; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; @@ -44,6 +46,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ secretVersionV2DAL, identityUniversalAuthClientSecretDAL, serviceTokenService, + scimService, orgService, userNotificationDAL, keyValueStoreDAL @@ -86,6 +89,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await secretVersionV2DAL.pruneExcessVersions(); await secretFolderVersionDAL.pruneExcessVersions(); await serviceTokenService.notifyExpiringTokens(); + await scimService.notifyExpiringTokens(); await orgService.notifyInvitedUsers(); await auditLogDAL.pruneAuditLog(); await userNotificationDAL.pruneNotifications(); diff --git a/backend/src/services/service-token/service-token-service.ts b/backend/src/services/service-token/service-token-service.ts index de759eb9a..8f7b0a1b7 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -214,6 +214,8 @@ export const serviceTokenServiceFactory = ({ break; } + const successfullyNotifiedTokenIds: string[] = []; + // eslint-disable-next-line no-await-in-loop await Promise.all( expiringTokens.map(async (token) => { @@ -228,13 +230,19 @@ export const serviceTokenServiceFactory = ({ url: `${appCfg.SITE_URL}/organizations/${token.orgId}/projects/secret-management/${token.projectId}/access-management?selectedTab=service-tokens` } }); - await serviceTokenDAL.update({ id: token.id }, { expiryNotificationSent: true }); + successfullyNotifiedTokenIds.push(token.id); } catch (error) { logger.error(error, `Failed to send expiration notification for token ${token.id}:`); } }) ); + // Batch update all successfully notified tokens in a single query + if (successfullyNotifiedTokenIds.length > 0) { + // eslint-disable-next-line no-await-in-loop + await serviceTokenDAL.update({ $in: { id: successfullyNotifiedTokenIds } }, { expiryNotificationSent: true }); + } + processedCount += expiringTokens.length; offset += batchSize; } diff --git a/backend/src/services/smtp/emails/ScimTokenExpiryNoticeTemplate.tsx b/backend/src/services/smtp/emails/ScimTokenExpiryNoticeTemplate.tsx new file mode 100644 index 000000000..84b9395b2 --- /dev/null +++ b/backend/src/services/smtp/emails/ScimTokenExpiryNoticeTemplate.tsx @@ -0,0 +1,71 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface ScimTokenExpiryNoticeTemplateProps extends Omit { + tokenDescription?: string; + orgName: string; + createdOn: Date; + expiringOn: Date; + url: string; +} + +export const ScimTokenExpiryNoticeTemplate = ({ + tokenDescription, + siteUrl, + orgName, + url, + createdOn, + expiringOn +}: ScimTokenExpiryNoticeTemplateProps) => { + const formatDate = (date: Date) => + date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + }); + + const createdOnDisplay = formatDate(createdOn); + const expiringOnDisplay = formatDate(expiringOn); + + return ( + + + SCIM token expiry notice + +
+ + {tokenDescription ? ( + <> + Your SCIM token {tokenDescription} + + ) : ( + "One of your SCIM tokens" + )}{" "} + for {orgName}, created on {createdOnDisplay}, is scheduled to expire on{" "} + {expiringOnDisplay}. + + + If this token is still needed for your external platform sync, please create a new one before it expires to + avoid disruption to your workflow. + +
+
+ Manage SCIM Tokens +
+
+ ); +}; + +export default ScimTokenExpiryNoticeTemplate; + +ScimTokenExpiryNoticeTemplate.PreviewProps = { + orgName: "Example Organization", + siteUrl: "https://infisical.com", + url: "https://infisical.com", + tokenDescription: "Example SCIM Token", + createdOn: new Date("2025-11-27T00:00:00Z"), + expiringOn: new Date("2025-12-27T00:00:00Z") +} as ScimTokenExpiryNoticeTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 692cacbaf..376f6780e 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -19,6 +19,7 @@ export * from "./PasswordSetupTemplate"; export * from "./PkiExpirationAlertTemplate"; export * from "./ProjectAccessRequestTemplate"; export * from "./ProjectInvitationTemplate"; +export * from "./ScimTokenExpiryNoticeTemplate"; export * from "./ScimUserProvisionedTemplate"; export * from "./SecretApprovalRequestBypassedTemplate"; export * from "./SecretApprovalRequestNeedsReviewTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 62906764f..e1e2e6041 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -28,6 +28,7 @@ import { PkiExpirationAlertTemplate, ProjectAccessRequestTemplate, ProjectInvitationTemplate, + ScimTokenExpiryNoticeTemplate, ScimUserProvisionedTemplate, SecretApprovalRequestBypassedTemplate, SecretApprovalRequestNeedsReviewTemplate, @@ -75,6 +76,7 @@ export enum SmtpTemplates { SecretLeakIncident = "secretLeakIncident", WorkspaceInvite = "workspaceInvitation", ScimUserProvisioned = "scimUserProvisioned", + ScimTokenExpired = "scimTokenExpired", PkiExpirationAlert = "pkiExpirationAlert", IntegrationSyncFailed = "integrationSyncFailed", SecretSyncFailed = "secretSyncFailed", @@ -123,6 +125,7 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SecretLeakIncident]: SecretLeakIncidentTemplate, [SmtpTemplates.WorkspaceInvite]: ProjectInvitationTemplate, [SmtpTemplates.ScimUserProvisioned]: ScimUserProvisionedTemplate, + [SmtpTemplates.ScimTokenExpired]: ScimTokenExpiryNoticeTemplate, [SmtpTemplates.SecretRequestCompleted]: SecretRequestCompletedTemplate, [SmtpTemplates.UnlockAccount]: UnlockAccountTemplate, [SmtpTemplates.ServiceTokenExpired]: ServiceTokenExpiryNoticeTemplate,