feat: email team admins when SAML certificates are nearing expiration - #1895
feat: email team admins when SAML certificates are nearing expiration#1895paustint wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds end-to-end SAML IdP certificate expiration notifications: it stores a per-team reminder schedule in the DB, surfaces an in-app admin banner via /heartbeat announcements, and runs a cron task to email team admins as thresholds are reached.
Changes:
- Add
nextCertNotificationDate/lastCertNotificationAttoSamlConfiguration, including migrations + a backfill to seed schedules for existing configs. - Introduce shared scheduling utilities + unit tests, and wire them into the SAML config save path (to restart the schedule when the expiration changes).
- Add a new cron entry + integration tests, plus a new email template/sender and new audit log actions.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| prisma/schema.prisma | Adds schedule tracking fields to SAML configuration model. |
| prisma/migrations/20260730153103_add_sso_cert_expiration_notifications/migration.sql | Adds DB columns for SSO cert notification scheduling. |
| prisma/migrations/20260730154407_backfill_sso_cert_notification_schedule/migration.sql | Seeds nextCertNotificationDate for pre-existing SAML configs. |
| package.json | Adds start scripts for the new SSO cert expiration cron. |
| libs/shared/utils/src/lib/sso-certificate-expiration.ts | Shared schedule + day-count utilities for API + cron. |
| libs/shared/utils/src/lib/tests/sso-certificate-expiration.spec.ts | Unit tests for shared schedule/day-count logic. |
| libs/shared/utils/src/index.ts | Re-exports the new shared SSO cert expiration utilities. |
| libs/email/src/lib/email.tsx | Adds an email sender for SSO cert expiration reminders. |
| libs/email/src/lib/email-templates/auth/SsoCertificateExpirationEmail.tsx | New react-email template for SSO cert expiration messaging. |
| libs/audit-logs/src/lib/audit-logs.ts | Adds audit log actions for warning/expired events. |
| apps/cron-tasks/src/utils/sso-certificate-expiration.utils.ts | Core cron logic: select due configs, email admins, advance schedule, audit log. |
| apps/cron-tasks/src/sso-certificate-expiration.ts | Cron entrypoint wiring for the new task. |
| apps/cron-tasks/src/tests/sso-certificate-expiration.spec.ts | Integration tests for cron behavior against a test DB. |
| apps/cron-tasks/project.json | Includes the new cron entrypoint in the build inputs. |
| apps/api/src/app/routes/api.routes.ts | Makes /heartbeat async to include dynamic announcements. |
| apps/api/src/app/db/team.db.ts | Seeds/resets notification schedule on SAML config save; adds minimal expiration lookup. |
| apps/api/src/app/controllers/team.controller.ts | Clears announcement cache when SSO settings/cert change. |
| apps/api/src/app/announcements.ts | Adds cached per-team “SSO cert expiring/expired” admin announcement. |
87fcdc4 to
118289f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
prisma/schema.prisma:745
- The cron queries
sso_saml_configurationsbynextCertNotificationDate <= nowandidpCertificateExpiresAt IS NOT NULL, butSamlConfigurationhas no index onnextCertNotificationDate. This can become a full-table scan as teams grow; there’s precedent for indexing similar notification schedule fields (e.g.SalesforceOrg.nextExpirationNotificationDateis indexed).
// Drives the certificate expiration notification cron. nextCertNotificationDate is the date the next
// reminder is due (null = nothing further to send) and is reset whenever the certificate changes so a
// renewed certificate restarts the reminder schedule.
nextCertNotificationDate DateTime?
lastCertNotificationAt DateTime?
libs/email/src/lib/email.tsx:327
- This treats
daysUntilExpiration === 0as "expired". Elsewhere (e.g.SsoCertificateExpirationEmailPropsandgetDaysUntilCertExpirationdocs) the negative range represents "already expired" and0is the expiration date itself, so the subject line is incorrect on the expiration day.
const { daysUntilExpiration } = props;
const subject =
daysUntilExpiration <= 0
? 'Action Required: Your SSO Certificate Has Expired'
: `Action Required: Your SSO Certificate Expires in ${daysUntilExpiration} ${pluralizeFromNumber('Day', daysUntilExpiration)}`;
libs/email/src/lib/email-templates/auth/SsoCertificateExpirationEmail.tsx:30
daysUntilExpirationis documented as negative only once the certificate has already expired;0represents the expiration date itself. Using<= 0marks certificates expiring today as already expired, which makes the heading/preview incorrect on the expiration date.
const hasExpired = daysUntilExpiration <= 0;
const heading = hasExpired ? 'SSO Certificate Expired' : 'SSO Certificate Expiring Soon';
const preview = hasExpired
? `The SAML signing certificate for ${teamName} has expired`
: `The SAML signing certificate for ${teamName} expires in ${daysUntilExpiration} ${pluralizeFromNumber('day', daysUntilExpiration)}`;
apps/api/src/app/announcements.ts:70
- Same off-by-one semantics as the email:
daysUntilExpirationis negative only once the cert is already expired, but<= 0marks "expires today" as expired. This will show the "Expired" banner on the expiration date even if the cert hasn’t actually expired yet.
const expirationDate = certificate.expiresAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
const hasExpired = daysUntilExpiration <= 0;
apps/cron-tasks/src/utils/sso-certificate-expiration.utils.ts:167
daysUntilExpiration === 0is the expiration date itself (negative is "already expired"), so using<= 0will log the "expired" audit action on the expiration date rather than only after it has passed.
await createAuditLog({
teamId: team.id,
action: daysUntilExpiration <= 0 ? AuditLogAction.SSO_CERT_EXPIRED : AuditLogAction.SSO_CERT_EXPIRATION_WARNING,
resource: AuditLogResource.TEAM_SSO_CONFIG,
118289f to
a03ac7e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (4)
apps/cron-tasks/src/utils/sso-certificate-expiration.utils.ts:97
- Team status is compared against the hard-coded string
'ACTIVE'. Prefer the sharedTEAM_STATUS_ACTIVEconstant (from@jetstream/types) so this stays consistent with the TeamStatus schema and avoids drift if the enum values ever change.
if (team.status !== 'ACTIVE' || !config.loginConfiguration.ssoEnabled) {
prisma/schema.prisma:746
- SamlConfiguration is queried by the new cron on
nextCertNotificationDate(lte: now), but the model doesn’t define an index for this column. Similar cron-driven fields (e.g. SalesforceOrg.nextExpirationNotificationDate) are indexed in the schema, and without an index this cron will require a full table scan as the table grows.
Consider adding @@index([nextCertNotificationDate]) (and running a Prisma migration) to keep the cron query efficient.
// Drives the certificate expiration notification cron. nextCertNotificationDate is the date the next
// reminder is due (null = nothing further to send) and is reset whenever the certificate changes so a
// renewed certificate restarts the reminder schedule.
nextCertNotificationDate DateTime?
lastCertNotificationAt DateTime?
apps/api/src/app/db/team.db.ts:1309
- The reminder schedule reset is currently driven only by
idpCertificateExpiresAtchanging. If the IdP signing certificate rotates but happens to parse to the same expiration timestamp, the notification schedule won’t restart (andlastCertNotificationAtwon’t clear), which contradicts the intent described in the comment (“rotating the certificate restarts the … schedule”). SincepreviousSamlConfigincludes the storedidpCertificate, you can also key off the certificate value changing so the schedule reliably resets on actual certificate rotation.
// Rotating the certificate restarts the expiration reminder schedule, so an admin who renews after
// the 7-day warning still gets the full 30/14/7/3 sequence against the new expiration date. An
// unchanged expiration leaves the existing schedule alone — otherwise any unrelated edit to the
// config (renaming it, tweaking attribute mapping) would replay reminders the team already got.
const certificateExpirationChanged = previousSamlConfig?.idpCertificateExpiresAt?.getTime() !== idpCertificateExpiresAt?.getTime();
apps/cron-tasks/src/utils/sso-certificate-expiration.utils.ts:5
- This file already uses
@jetstream/typesrole/status constants; importingTEAM_STATUS_ACTIVEas well will let the team-status check avoid hard-coded strings and match the shared TeamStatus schema constants.
This issue also appears on line 97 of the same file.
import { TEAM_MEMBER_ROLE_ADMIN, TEAM_MEMBER_STATUS_ACTIVE } from '@jetstream/types';
a03ac7e to
ae2b5c7
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (1)
apps/api/src/app/announcements.ts:100
- If
getSsoCertificateAnnouncement()(DB/cache) throws, the catch currently returns[], which drops all announcements (including the static ones). Since the SSO announcement is best-effort, it’s safer to fall back togetStaticAnnouncements()so the heartbeat still returns the baseline announcements even during transient DB/cache failures.
export async function getAnnouncements(user?: Maybe<AuthenticatedUser>): Promise<Announcement[]> {
try {
const announcements = getStaticAnnouncements();
if (user) {
const ssoCertAnnouncement = await getSsoCertificateAnnouncement(user);
if (ssoCertAnnouncement) {
announcements.push(ssoCertAnnouncement);
}
}
return announcements;
} catch (ex) {
logger.error({ ...getErrorMessageAndStackObj(ex) }, 'Failed to get announcements');
return [];
}
ae2b5c7 to
d0042b8
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (6)
libs/email/src/lib/email-templates/auth/SsoCertificateExpirationEmail.tsx:56
- When
daysUntilExpiration === 0(expiration date), this renders “Expires in 0 days”. Special-case0to display “Expires today” and keep “Expired” only for negative values.
<span style={hasExpired ? expiredText : expirationText}>
{hasExpired ? '⚠️ Expired' : `Expires in ${daysUntilExpiration} ${pluralizeFromNumber('day', daysUntilExpiration)}`}
</span>
apps/jetstream/src/app/components/core/AnnouncementAlerts.tsx:64
link.tocomes from the API and is currently/teams, but in the browser extension the app’s routes are*.html(viaAPP_ROUTES/__IS_BROWSER_EXTENSION__). This makes the announcement link navigate to a non-existent route in the extension. Resolve the route client-side when running in the extension so/teamsbecomes/teams.html.
{content.slice(0, placeholderIndex)}
<Link to={link.to}>{link.label}</Link>
{content.slice(placeholderIndex + link.key.length)}
apps/api/src/app/announcements.ts:73
daysUntilExpirationis documented as “negative once expired”, so0means “expires today”, not “already expired”. Using<= 0here will show an “expired” banner for the entire expiration date even if the cert expires later that day.
const hasExpired = daysUntilExpiration <= 0;
libs/email/src/lib/email.tsx:327
daysUntilExpirationis negative once expired, so0means “expires today”. The current subject treats0as expired, which is inconsistent with that meaning and also avoids a clearer “expires today” subject.
const subject =
daysUntilExpiration <= 0
? 'Action Required: Your SSO Certificate Has Expired'
: `Action Required: Your SSO Certificate Expires in ${daysUntilExpiration} ${pluralizeFromNumber('Day', daysUntilExpiration)}`;
apps/cron-tasks/src/utils/sso-certificate-expiration.utils.ts:188
daysUntilExpirationis negative once expired; on the expiration date itself (0) this should still be a warning, not an “expired” audit action.
await createAuditLog({
teamId: team.id,
action: daysUntilExpiration <= 0 ? AuditLogAction.SSO_CERT_EXPIRED : AuditLogAction.SSO_CERT_EXPIRATION_WARNING,
resource: AuditLogResource.TEAM_SSO_CONFIG,
libs/email/src/lib/email-templates/auth/SsoCertificateExpirationEmail.tsx:34
daysUntilExpirationis documented as negative once expired; treating0as expired makes the email read incorrectly on the expiration date. Handle the0case explicitly (“expires today”) and reserve “expired” for negative values.
This issue also appears on line 54 of the same file.
const hasExpired = daysUntilExpiration <= 0;
const heading = hasExpired ? 'SSO Certificate Expired' : 'SSO Certificate Expiring Soon';
const preview = hasExpired
? `The SAML signing certificate for ${teamName} has expired`
: `The SAML signing certificate for ${teamName} expires in ${daysUntilExpiration} ${pluralizeFromNumber('day', daysUntilExpiration)}`;
d0042b8 to
3bb7406
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (1)
prisma/schema.prisma:745
nextCertNotificationDatewill be used as the primary filter in the cron query (WHERE nextCertNotificationDate <= now AND nextCertNotificationDate IS NOT NULL). Unlike the analogoussalesforce_org.nextExpirationNotificationDate, this field is not indexed, which can force a full scan ofsso_saml_configurationsas the number of teams grows. Add an index onnextCertNotificationDate(and include the corresponding migration) to keep the cron query efficient.
// Drives the certificate expiration notification cron. nextCertNotificationDate is the date the next
// reminder is due (null = nothing further to send) and is reset whenever the certificate changes so a
// renewed certificate restarts the reminder schedule.
nextCertNotificationDate DateTime?
lastCertNotificationAt DateTime?
Notify customers of upcoming SAML certification expiration
In-app notification of upcoming expiration
Cron job for automated email reminders