Merge branch 'main' of https://github.com/Infisical/infisical into feat/suborg-scope-support

This commit is contained in:
Piyush Gupta
2025-11-28 18:02:33 +05:30
90 changed files with 720 additions and 2643 deletions
@@ -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");
});
}
}
+2 -1
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>;
@@ -72,7 +72,6 @@ const ProjectTemplateEnvironmentsSchema = z
position: z.number().min(1)
})
.array()
.min(1)
.superRefine((environments, ctx) => {
if (Buffer.byteLength(JSON.stringify(environments)) > MAX_JSON_SIZE_LIMIT_IN_BYTES)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Size limit exceeded" });
@@ -198,7 +197,7 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider)
description: z.string().max(256).trim().optional().describe(ProjectTemplates.CREATE.description),
roles: ProjectTemplateRolesSchema.default([]).describe(ProjectTemplates.CREATE.roles),
type: z.nativeEnum(ProjectType).describe(ProjectTemplates.CREATE.type),
environments: ProjectTemplateEnvironmentsSchema.describe(ProjectTemplates.CREATE.environments).optional()
environments: ProjectTemplateEnvironmentsSchema.nullish().describe(ProjectTemplates.CREATE.environments)
}),
response: {
200: z.object({
@@ -243,7 +242,7 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider)
.describe(ProjectTemplates.UPDATE.name),
description: z.string().max(256).trim().optional().describe(ProjectTemplates.UPDATE.description),
roles: ProjectTemplateRolesSchema.optional().describe(ProjectTemplates.UPDATE.roles),
environments: ProjectTemplateEnvironmentsSchema.optional().describe(ProjectTemplates.UPDATE.environments)
environments: ProjectTemplateEnvironmentsSchema.nullish().describe(ProjectTemplates.UPDATE.environments)
}),
response: {
200: z.object({
@@ -189,11 +189,15 @@ export const projectTemplateServiceFactory = ({
message: `A project template with the name "${params.name}" already exists.`
});
const projectTemplateEnvironments =
type === ProjectType.SecretManager && environments === undefined
? ProjectTemplateDefaultEnvironments
: environments;
const projectTemplate = await projectTemplateDAL.create({
...params,
roles: JSON.stringify(roles.map((role) => ({ ...role, permissions: packRules(role.permissions) }))),
environments:
type === ProjectType.SecretManager ? JSON.stringify(environments ?? ProjectTemplateDefaultEnvironments) : null,
environments: JSON.stringify(projectTemplateEnvironments),
orgId: actor.orgId,
type
});
+51 -5
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 };
};
+68 -2
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
};
};
@@ -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>;
};
+3 -1
View File
@@ -1329,7 +1329,8 @@ export const registerRoutes = async (
eventBusService,
licenseService,
membershipRoleDAL,
membershipUserDAL
membershipUserDAL,
telemetryService
});
const projectService = projectServiceFactory({
@@ -1912,6 +1913,7 @@ export const registerRoutes = async (
// DAILY
const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({
scimService,
auditLogDAL,
queueService,
secretVersionDAL,
@@ -10,7 +10,12 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { IntegrationMetadataSchema } from "@app/services/integration/integration-schema";
import { Integrations } from "@app/services/integration-auth/integration-list";
import { PostHogEventTypes, TIntegrationCreatedEvent } from "@app/services/telemetry/telemetry-types";
import {
PostHogEventTypes,
TIntegrationCreatedEvent,
TIntegrationDeletedEvent,
TIntegrationSyncedEvent
} from "@app/services/telemetry/telemetry-types";
import {} from "../sanitizedSchemas";
@@ -288,31 +293,47 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
shouldDeleteIntegrationSecrets: req.query.shouldDeleteIntegrationSecrets
});
const deleteIntegrationEventProperty = shake({
integrationId: integration.id,
integration: integration.integration,
environment: integration.environment.slug,
secretPath: integration.secretPath,
url: integration.url,
app: integration.app,
appId: integration.appId,
targetEnvironment: integration.targetEnvironment,
targetEnvironmentId: integration.targetEnvironmentId,
targetService: integration.targetService,
targetServiceId: integration.targetServiceId,
path: integration.path,
region: integration.region
}) as TIntegrationDeletedEvent["properties"];
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: integration.projectId,
event: {
type: EventType.DELETE_INTEGRATION,
// eslint-disable-next-line
metadata: shake({
integrationId: integration.id,
integration: integration.integration,
environment: integration.environment.slug,
secretPath: integration.secretPath,
url: integration.url,
app: integration.app,
appId: integration.appId,
targetEnvironment: integration.targetEnvironment,
targetEnvironmentId: integration.targetEnvironmentId,
targetService: integration.targetService,
targetServiceId: integration.targetServiceId,
path: integration.path,
region: integration.region,
metadata: {
...deleteIntegrationEventProperty,
shouldDeleteIntegrationSecrets: req.query.shouldDeleteIntegrationSecrets
// eslint-disable-next-line
}) as any
} as any
}
});
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IntegrationDeleted,
organizationId: req.permission.orgId,
distinctId: getTelemetryDistinctId(req),
properties: {
...deleteIntegrationEventProperty,
projectId: integration.projectId,
...req.auditLogInfo
}
});
return { integration };
}
});
@@ -351,28 +372,41 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
id: req.params.integrationId
});
const syncIntegrationEventProperty = shake({
integrationId: integration.id,
integration: integration.integration,
environment: integration.environment.slug,
secretPath: integration.secretPath,
url: integration.url,
app: integration.app,
appId: integration.appId,
targetEnvironment: integration.targetEnvironment,
targetEnvironmentId: integration.targetEnvironmentId,
targetService: integration.targetService,
targetServiceId: integration.targetServiceId,
path: integration.path,
region: integration.region
}) as TIntegrationSyncedEvent["properties"];
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: integration.projectId,
event: {
type: EventType.MANUAL_SYNC_INTEGRATION,
// eslint-disable-next-line
metadata: shake({
integrationId: integration.id,
integration: integration.integration,
environment: integration.environment.slug,
secretPath: integration.secretPath,
url: integration.url,
app: integration.app,
appId: integration.appId,
targetEnvironment: integration.targetEnvironment,
targetEnvironmentId: integration.targetEnvironmentId,
targetService: integration.targetService,
targetServiceId: integration.targetServiceId,
path: integration.path,
region: integration.region
// eslint-disable-next-line
}) as any
metadata: syncIntegrationEventProperty as any
}
});
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IntegrationSynced,
organizationId: req.permission.orgId,
distinctId: getTelemetryDistinctId(req),
properties: {
...syncIntegrationEventProperty,
projectId: integration.projectId,
isManualSync: true,
...req.auditLogInfo
}
});
@@ -123,13 +123,28 @@ export const identityOidcAuthServiceFactory = ({
}
const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert });
const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>(
`${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`,
{
httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined
}
);
let discoveryDoc: { jwks_uri: string };
try {
const response = await axios.get<{ jwks_uri: string }>(
`${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`,
{
httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined
}
);
discoveryDoc = response.data;
} catch (error) {
throw new UnauthorizedError({
message: `Access denied: Failed to fetch OIDC discovery document from ${identityOidcAuth.oidcDiscoveryUrl}. ${error instanceof Error ? error.message : String(error)}`
});
}
const jwksUri = discoveryDoc.jwks_uri;
if (!jwksUri) {
throw new UnauthorizedError({
message: `Access denied: OIDC discovery document does not contain a jwks_uri. The identity provider may be misconfigured.`
});
}
const decodedToken = crypto.jwt().decode(oidcJwt, { complete: true });
if (!decodedToken) {
@@ -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();
@@ -421,11 +421,12 @@ export const fnSecretBulkDelete = async ({
);
const changes = deletedSecrets
.filter(({ type }) => type === SecretType.Shared)
.filter(({ type, id }) => type === SecretType.Shared && secretVersions[id])
.map(({ id }) => ({
type: CommitType.DELETE,
secretVersionId: secretVersions[id].id
secretVersionId: secretVersions[id]?.id
}));
if (changes.length > 0) {
if (commitChanges) {
commitChanges.push(...changes);
@@ -2254,7 +2254,8 @@ export const secretV2BridgeServiceFactory = ({
]
}
});
if (secretsToDelete.length !== inputSecrets.length)
const secretsToDeleteSet = new Set(secretsToDelete.map((el) => el.key));
if (secretsToDeleteSet.size !== inputSecrets.length)
throw new NotFoundError({
message: `One or more secrets does not exist: ${secretsToDelete.map((el) => el.key).join(", ")}`
});
+28 -1
View File
@@ -64,6 +64,8 @@ import { expandSecretReferencesFactory, getAllSecretReferences } from "../secret
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TTelemetryServiceFactory } from "../telemetry/telemetry-service";
import { PostHogEventTypes } from "../telemetry/telemetry-types";
import { TUserDALFactory } from "../user/user-dal";
import { TWebhookDALFactory } from "../webhook/webhook-dal";
import { fnTriggerWebhook } from "../webhook/webhook-fns";
@@ -120,6 +122,7 @@ type TSecretQueueFactoryDep = {
reminderService: Pick<TReminderServiceFactory, "createReminderInternal" | "deleteReminderBySecretId">;
eventBusService: TEventBusService;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
telemetryService: Pick<TTelemetryServiceFactory, "sendPostHogEvents">;
};
export type TGetSecrets = {
@@ -184,7 +187,8 @@ export const secretQueueFactory = ({
eventBusService,
licenseService,
membershipUserDAL,
membershipRoleDAL
membershipRoleDAL,
telemetryService
}: TSecretQueueFactoryDep) => {
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
@@ -1029,6 +1033,29 @@ export const secretQueueFactory = ({
isSynced: response?.isSynced ?? true
});
await telemetryService.sendPostHogEvents({
event: PostHogEventTypes.IntegrationSynced,
distinctId: `project/${projectId}`,
organizationId: project.orgId,
properties: {
integrationId: integration.id,
integration: integration.integration,
environment,
secretPath,
projectId,
url: integration.url ?? undefined,
app: integration.app ?? undefined,
appId: integration.appId ?? undefined,
targetEnvironment: integration.targetEnvironment ?? undefined,
targetEnvironmentId: integration.targetEnvironmentId ?? undefined,
targetService: integration.targetService ?? undefined,
targetServiceId: integration.targetServiceId ?? undefined,
path: integration.path ?? undefined,
region: integration.region ?? undefined,
isManualSync: isManual ?? false
}
});
// May be undefined, if it's undefined we assume the sync was successful, hence the strict equality type check.
if (response?.isSynced === false) {
integrationsFailedToSync.push({
@@ -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,
@@ -21,6 +21,8 @@ export enum PostHogEventTypes {
SecretScannerPush = "cloud secret scan",
ProjectCreated = "Project Created",
IntegrationCreated = "Integration Created",
IntegrationSynced = "Integration Synced",
IntegrationDeleted = "Integration Deleted",
MachineIdentityCreated = "Machine Identity Created",
UserOrgInvitation = "User Org Invitation",
TelemetryInstanceStats = "Self Hosted Instance Stats",
@@ -126,6 +128,47 @@ export type TIntegrationCreatedEvent = {
};
};
export type TIntegrationSyncedEvent = {
event: PostHogEventTypes.IntegrationSynced;
properties: {
projectId: string;
integrationId: string;
integration: string;
environment: string;
secretPath: string;
isManualSync: boolean;
url?: string;
app?: string;
appId?: string;
targetEnvironment?: string;
targetEnvironmentId?: string;
targetService?: string;
targetServiceId?: string;
path?: string;
region?: string;
};
};
export type TIntegrationDeletedEvent = {
event: PostHogEventTypes.IntegrationDeleted;
properties: {
projectId: string;
integrationId: string;
integration: string;
environment: string;
secretPath: string;
url?: string;
app?: string;
appId?: string;
targetEnvironment?: string;
targetEnvironmentId?: string;
targetService?: string;
targetServiceId?: string;
path?: string;
region?: string;
};
};
export type TUserOrgInvitedEvent = {
event: PostHogEventTypes.UserOrgInvitation;
properties: {
@@ -249,6 +292,8 @@ export type TPostHogEvent = { distinctId: string; organizationId?: string } & (
| TUserOrgInvitedEvent
| TMachineIdentityCreatedEvent
| TIntegrationCreatedEvent
| TIntegrationSyncedEvent
| TIntegrationDeletedEvent
| TProjectCreateEvent
| TTelemetryInstanceStatsEvent
| TSecretRequestCreatedEvent