* Allow admin access to user PATs * Tests for new screen in admin panel * Adding error for invalid token and way to parse error for OAuth 2 * Git bridge handles expired PAT * Script for alerting on close to expiry and expired git tokens * Refactoring and simplifying * Updating email templates to match agreed docs * tweak to email subject to include Overleaf * Allowing dry run in scripts and general tidy up * removing redundant tests and dry running script * Fixing CI errors * Adding new tab to admin test expectation * Address PR feedback on oauth2-server changes - Replace ad-hoc overleafErrorCode prop with a TokenExpiredError subclass - Collapse listTokens/listTokensForAdmin into a single hook Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Adding cron definitions for alerting on expiring git pat --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> GitOrigin-RevId: 69b9fd901a201592a580c69abe7bd7d603e85d3a
139 lines
3.8 KiB
JavaScript
139 lines
3.8 KiB
JavaScript
// Recurring job. Sends two kinds of email about personal access token expiry:
|
|
//
|
|
// - "expiring soon": token has not expired yet and falls within the
|
|
// configured warning window
|
|
// (Settings.personalAccessTokens.expiry.warningWindowDays, default 2 days).
|
|
// Sent at most once per token.
|
|
//
|
|
// - "expired": token has expired and we have not yet emailed the owner.
|
|
// Sent at most once per token. Tokens that were already expired before
|
|
// the feature shipped have `notificationsSuppressedAt` set by the
|
|
// backfill_suppress_expired_token_notifications.mjs script and are
|
|
// deliberately excluded.
|
|
//
|
|
// Pass --dry-run to log who would be emailed without sending or marking
|
|
// `lastNotifiedAt`.
|
|
|
|
import settings from '@overleaf/settings'
|
|
import logger from '@overleaf/logger'
|
|
import {
|
|
db,
|
|
READ_PREFERENCE_SECONDARY,
|
|
} from '../../app/src/infrastructure/mongodb.mjs'
|
|
import { User } from '../../app/src/models/User.mjs'
|
|
import EmailHandler from '../../app/src/Features/Email/EmailHandler.mjs'
|
|
import { scriptRunner } from '../lib/ScriptRunner.mjs'
|
|
|
|
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
|
|
export async function main({ dryRun = false } = {}) {
|
|
const now = new Date()
|
|
const warningWindowDays =
|
|
settings.personalAccessTokens.expiry.warningWindowDays
|
|
const warningHorizon = new Date(
|
|
now.getTime() + warningWindowDays * MS_PER_DAY
|
|
)
|
|
|
|
logger.info(
|
|
{ warningWindowDays, warningHorizon, dryRun },
|
|
'starting notify_expiring_tokens'
|
|
)
|
|
|
|
const warningCount = await processBucket({
|
|
kind: 'warning',
|
|
template: 'gitTokenExpiringSoon',
|
|
dryRun,
|
|
query: {
|
|
type: 'pat',
|
|
accessTokenExpiresAt: { $gt: now, $lte: warningHorizon },
|
|
'lastNotifiedAt.warning': { $exists: false },
|
|
},
|
|
})
|
|
|
|
const expiredCount = await processBucket({
|
|
kind: 'expired',
|
|
template: 'gitTokenExpired',
|
|
dryRun,
|
|
query: {
|
|
type: 'pat',
|
|
accessTokenExpiresAt: { $lt: now },
|
|
'lastNotifiedAt.expired': { $exists: false },
|
|
notificationsSuppressedAt: { $exists: false },
|
|
},
|
|
})
|
|
|
|
logger.info(
|
|
{ warningCount, expiredCount, dryRun },
|
|
'finished notify_expiring_tokens'
|
|
)
|
|
}
|
|
|
|
export async function processBucket({ kind, template, query, dryRun = false }) {
|
|
const cursor = db.oauthAccessTokens.find(query, {
|
|
projection: {
|
|
_id: 1,
|
|
user_id: 1,
|
|
},
|
|
readPreference: READ_PREFERENCE_SECONDARY,
|
|
})
|
|
|
|
let sent = 0
|
|
for await (const token of cursor) {
|
|
const ok = await notifyOwner({ token, kind, template, dryRun })
|
|
if (ok) sent++
|
|
}
|
|
return sent
|
|
}
|
|
|
|
export async function notifyOwner({ token, kind, template, dryRun = false }) {
|
|
const user = await User.findOne(
|
|
{ _id: token.user_id },
|
|
{ email: 1, first_name: 1 }
|
|
).exec()
|
|
if (!user?.email) {
|
|
logger.warn(
|
|
{ tokenId: token._id, userId: token.user_id },
|
|
'skipping token notification: user not found or has no email'
|
|
)
|
|
return false
|
|
}
|
|
|
|
if (dryRun) {
|
|
logger.info(
|
|
{ tokenId: token._id, userId: token.user_id, kind, template },
|
|
'dry run: would send git token expiry notification'
|
|
)
|
|
return true
|
|
}
|
|
|
|
try {
|
|
await EmailHandler.promises.sendEmail(template, {
|
|
to: user.email,
|
|
firstName: user.first_name,
|
|
})
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, tokenId: token._id, userId: token.user_id, kind },
|
|
'failed to send git token expiry notification; will retry next run'
|
|
)
|
|
return false
|
|
}
|
|
|
|
await db.oauthAccessTokens.updateOne(
|
|
{ _id: token._id },
|
|
{ $set: { [`lastNotifiedAt.${kind}`]: new Date() } }
|
|
)
|
|
return true
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const dryRun = process.argv.includes('--dry-run')
|
|
try {
|
|
await scriptRunner(() => main({ dryRun }))
|
|
process.exit(0)
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'notify_expiring_tokens failed')
|
|
process.exit(1)
|
|
}
|
|
}
|