Merge pull request #4953 from Infisical/feat/adds-expiring-scim-token-notification

feat: adds expiring scim token notification
This commit is contained in:
Piyush Gupta
2025-11-28 15:20:12 +05:30
committed by GitHub
11 changed files with 242 additions and 9 deletions

View File

@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
const hasCol = await knex.schema.hasColumn(TableName.ScimToken, "expiryNotificationSent");
if (hasCol) {
await knex.schema.alterTable(TableName.ScimToken, (t) => {
t.dropColumn("expiryNotificationSent");
});
}
}

View File

@@ -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<typeof ScimTokensSchema>;

View File

@@ -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<TableName.ScimToken>;
import { TExpiringScimToken } from "./scim-types";
export const scimDALFactory = (db: TDbClient): TScimDALFactory => {
export type TScimDALFactory = ReturnType<typeof scimDALFactory>;
export const scimDALFactory = (db: TDbClient) => {
const scimTokenOrm = ormify(db, TableName.ScimToken);
return scimTokenOrm;
const findExpiringTokens = async (tx?: Knex, batchSize = 500, offset = 0): Promise<TExpiringScimToken[]> => {
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<TExpiringScimToken[]>([
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 };
};

View File

@@ -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<TScimDALFactory, "create" | "find" | "findById" | "deleteById">;
scimDAL: Pick<TScimDALFactory, "create" | "find" | "findById" | "deleteById" | "findExpiringTokens" | "update">;
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
};
};

View File

@@ -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<number>;
};

View File

@@ -1913,6 +1913,7 @@ export const registerRoutes = async (
// DAILY
const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({
scimService,
auditLogDAL,
queueService,
secretVersionDAL,

View File

@@ -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<TUserNotificationDALFactory, "pruneNotifications">;
keyValueStoreDAL: Pick<TKeyValueStoreDALFactory, "pruneExpiredKeys">;
scimService: Pick<TScimServiceFactory, "notifyExpiringTokens">;
};
export type TDailyResourceCleanUpQueueServiceFactory = ReturnType<typeof dailyResourceCleanUpQueueServiceFactory>;
@@ -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();

View File

@@ -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;
}

View File

@@ -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<BaseEmailWrapperProps, "title" | "preview" | "children"> {
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 (
<BaseEmailWrapper title="SCIM Token Expiring Soon" preview="A SCIM token is about to expire." siteUrl={siteUrl}>
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
<strong>SCIM token expiry notice</strong>
</Heading>
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
<Text className="text-[14px]">
{tokenDescription ? (
<>
Your SCIM token <strong>{tokenDescription}</strong>
</>
) : (
"One of your SCIM tokens"
)}{" "}
for <strong>{orgName}</strong>, created on <strong>{createdOnDisplay}</strong>, is scheduled to expire on{" "}
<strong>{expiringOnDisplay}</strong>.
</Text>
<Text>
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.
</Text>
</Section>
<Section className="text-center">
<BaseButton href={url}>Manage SCIM Tokens</BaseButton>
</Section>
</BaseEmailWrapper>
);
};
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;

View File

@@ -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";

View File

@@ -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, React.FC<any>> = {
[SmtpTemplates.SecretLeakIncident]: SecretLeakIncidentTemplate,
[SmtpTemplates.WorkspaceInvite]: ProjectInvitationTemplate,
[SmtpTemplates.ScimUserProvisioned]: ScimUserProvisionedTemplate,
[SmtpTemplates.ScimTokenExpired]: ScimTokenExpiryNoticeTemplate,
[SmtpTemplates.SecretRequestCompleted]: SecretRequestCompletedTemplate,
[SmtpTemplates.UnlockAccount]: UnlockAccountTemplate,
[SmtpTemplates.ServiceTokenExpired]: ServiceTokenExpiryNoticeTemplate,