mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-09 15:38:03 -05:00
Merge pull request #4953 from Infisical/feat/adds-expiring-scim-token-notification
feat: adds expiring scim token notification
This commit is contained in:
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -1913,6 +1913,7 @@ export const registerRoutes = async (
|
||||
|
||||
// DAILY
|
||||
const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({
|
||||
scimService,
|
||||
auditLogDAL,
|
||||
queueService,
|
||||
secretVersionDAL,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user