mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: token expiration notifications (#8190)
* feat: add token expiration notifications via email, critical alerts, and webhooks - Monitor loop checks for tokens expiring within 7 days and sends email notifications to token owners. Tracks notification state via new `expiry_notified` column on the token table to avoid duplicates. - When tokens expire and are deleted, owners are also notified. - Critical alerts (in-app UI) are gated behind a new instance setting `critical_alerts_on_token_expiry` (off by default); emails are always sent regardless of the setting. - Add TokenExpiringSoon and TokenExpired webhook message variants for workspace webhook integrations. - Frontend: show expiration badges and a warning banner on the tokens table for tokens expiring within 30 days. - Exclude session and ephemeral tokens from all notifications. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: use separate token_expiry_notification table for dedup - Replace `expiry_notified` column on token table with a dedicated `token_expiry_notification` table (token, expiration) - Insert notification row on token creation via shared `register_token_expiry_notification()` helper - Delete notification row atomically when sending the notification - Clean up orphaned rows in `delete_expired_items()` - No FK constraint to avoid cascade overhead on token deletions - Add index on expiration column for efficient range queries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: calendar-based expiration badge and move notification cleanup - Fix daysUntilExpiration to compare calendar dates instead of time diff - Move notification row cleanup from delete_expired_items to check_expiring_tokens to keep it off the hot path - Use simple expiration <= now() index scan instead of NOT EXISTS join Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token_expiry_notification WHERE expiration <= now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a6b1c8808c892e62ae4ba04171d856a39c89cdc658b09c478050de5145a45ca4"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token WHERE expiration <= now()\n RETURNING substring(token for 10) as token_prefix, label, email, workspace_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token_prefix",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "label",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token = t.token\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token_prefix",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "label",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS token_expiry_notification;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Tracks pending expiry notifications: row exists = not yet notified.
|
||||
-- Deleted once the notification is sent. Orphaned rows are harmless (filtered out by the join).
|
||||
CREATE TABLE token_expiry_notification (
|
||||
token VARCHAR(255) PRIMARY KEY,
|
||||
expiration TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_token_expiry_notification_expiration ON token_expiry_notification (expiration);
|
||||
+14
-9
@@ -38,11 +38,11 @@ use windmill_common::{
|
||||
agent_workers::AgentConfig,
|
||||
global_settings::{
|
||||
APP_WORKSPACED_ROUTE_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING,
|
||||
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
|
||||
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
|
||||
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
|
||||
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
|
||||
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING,
|
||||
DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING,
|
||||
ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
|
||||
EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
|
||||
INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING,
|
||||
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING,
|
||||
MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
|
||||
@@ -99,10 +99,10 @@ use crate::monitor::{
|
||||
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, monitor_db,
|
||||
reload_app_workspaced_route_setting, reload_base_url_setting,
|
||||
reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting,
|
||||
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
|
||||
reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_job_default_timeout_setting,
|
||||
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
|
||||
reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
|
||||
reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting,
|
||||
reload_extra_pip_index_url_setting, reload_hub_api_secret_setting, reload_hub_base_url_setting,
|
||||
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
|
||||
reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
|
||||
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
|
||||
reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration,
|
||||
};
|
||||
@@ -1717,6 +1717,11 @@ async fn process_notify_event(
|
||||
tracing::error!(error = %e, "Could not reload critical alert UI setting");
|
||||
}
|
||||
}
|
||||
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING => {
|
||||
if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await {
|
||||
tracing::error!(error = %e, "Could not reload critical alerts on token expiry setting");
|
||||
}
|
||||
}
|
||||
"workspace_telemetry_enabled" => {
|
||||
// Read the new value from the database and log it
|
||||
let enabled = sqlx::query_scalar!(
|
||||
|
||||
+149
-18
@@ -44,19 +44,20 @@ use windmill_common::{
|
||||
apps::APP_WORKSPACED_ROUTE,
|
||||
auth::create_token_for_owner,
|
||||
ee_oss::CriticalErrorChannel,
|
||||
email_oss::send_email_if_possible,
|
||||
error,
|
||||
flow_status::{FlowStatus, FlowStatusModule},
|
||||
global_settings::{
|
||||
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING,
|
||||
DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
|
||||
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
|
||||
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
|
||||
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
|
||||
NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING,
|
||||
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
|
||||
POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
|
||||
CRITICAL_ERROR_CHANNELS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
|
||||
EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING,
|
||||
INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING,
|
||||
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
|
||||
NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
|
||||
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
|
||||
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
|
||||
UV_INDEX_STRATEGY_SETTING,
|
||||
@@ -76,10 +77,11 @@ use windmill_common::{
|
||||
DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR,
|
||||
WORKER_CONFIG, WORKER_GROUP,
|
||||
},
|
||||
KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERT_MUTE_UI_ENABLED,
|
||||
CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS,
|
||||
METRICS_DEBUG_ENABLED, METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED,
|
||||
OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS,
|
||||
KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY,
|
||||
CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL,
|
||||
HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED,
|
||||
SERVICE_LOG_RETENTION_SECS,
|
||||
};
|
||||
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -207,6 +209,10 @@ pub async fn initial_load(
|
||||
tracing::error!("Error loading critical alert mute ui setting: {e:#}");
|
||||
}
|
||||
|
||||
if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await {
|
||||
tracing::error!("Error loading critical alerts on token expiry setting: {e:#}");
|
||||
}
|
||||
|
||||
if let Some(db) = conn.as_sql() {
|
||||
if let Err(e) = load_tag_per_workspace_enabled(db).await {
|
||||
tracing::error!("Error loading default tag per workpsace: {e:#}");
|
||||
@@ -477,6 +483,21 @@ pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload_critical_alerts_on_token_expiry_setting(
|
||||
conn: &Connection,
|
||||
) -> error::Result<()> {
|
||||
if let Ok(Some(serde_json::Value::Bool(t))) = load_value_from_global_settings_with_conn(
|
||||
conn,
|
||||
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
CRITICAL_ALERTS_ON_TOKEN_EXPIRY.store(t, Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_metrics_debug_enabled(conn: &Connection) -> error::Result<()> {
|
||||
let metrics_enabled =
|
||||
load_value_from_global_settings_with_conn(conn, EXPOSE_DEBUG_METRICS_SETTING, true).await;
|
||||
@@ -845,18 +866,82 @@ struct LogFile {
|
||||
hostname: String,
|
||||
}
|
||||
|
||||
struct TokenRow {
|
||||
token_prefix: Option<String>,
|
||||
label: Option<String>,
|
||||
email: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
fn is_user_token(label: Option<&str>) -> bool {
|
||||
match label {
|
||||
None => true,
|
||||
Some(l) => l != "session" && !l.starts_with("ephemeral") && !l.starts_with("Ephemeral"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn report_token_expiration(db: &DB, token: &TokenRow, expired: bool) {
|
||||
if !is_user_token(token.label.as_deref()) {
|
||||
return;
|
||||
}
|
||||
let prefix = token.token_prefix.as_deref().unwrap_or("??????????");
|
||||
let email_addr = token.email.as_deref().unwrap_or("unknown");
|
||||
let token_desc = match token.label.as_deref() {
|
||||
Some(l) if !l.is_empty() => format!("'{l}' ({prefix}****)"),
|
||||
_ => format!("{prefix}****"),
|
||||
};
|
||||
|
||||
let (alert_message, email_subject, email_body) = if expired {
|
||||
(
|
||||
format!(
|
||||
"API token {token_desc} of '{email_addr}' has expired and been deleted"
|
||||
),
|
||||
"Windmill: Your API token has expired",
|
||||
format!(
|
||||
"Your API token {token_desc} has expired and been deleted.\n\nPlease create a new token if you still need API access."
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("API token {token_desc} of '{email_addr}' is expiring soon"),
|
||||
"Windmill: Your API token is expiring soon",
|
||||
format!(
|
||||
"Your API token {token_desc} is expiring soon.\n\nPlease rotate or renew your token to avoid service disruption."
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
tracing::info!("{}", alert_message);
|
||||
if CRITICAL_ALERTS_ON_TOKEN_EXPIRY.load(Ordering::Relaxed) {
|
||||
report_critical_error(
|
||||
alert_message,
|
||||
db.clone(),
|
||||
token.workspace_id.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Some(email) = &token.email {
|
||||
send_email_if_possible(email_subject, &email_body, email);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_expired_items(db: &DB) -> () {
|
||||
let tokens_deleted_r: std::result::Result<Vec<String>, _> = sqlx::query_scalar(
|
||||
let expired_tokens_r = sqlx::query_as!(
|
||||
TokenRow,
|
||||
"DELETE FROM token WHERE expiration <= now()
|
||||
RETURNING concat(substring(token for 10), '*****')",
|
||||
RETURNING substring(token for 10) as token_prefix, label, email, workspace_id",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await;
|
||||
|
||||
match tokens_deleted_r {
|
||||
match expired_tokens_r {
|
||||
Ok(tokens) => {
|
||||
if tokens.len() > 0 {
|
||||
tracing::info!("deleted {} tokens: {:?}", tokens.len(), tokens)
|
||||
if !tokens.is_empty() {
|
||||
tracing::info!("deleted {} expired tokens", tokens.len());
|
||||
for t in &tokens {
|
||||
report_token_expiration(db, t, true).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error deleting token: {}", e.to_string()),
|
||||
@@ -1065,6 +1150,41 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_expiring_tokens(db: &DB) {
|
||||
// Find tokens expiring within 7 days that still have a pending notification row
|
||||
let expiring_tokens_r = sqlx::query_as!(
|
||||
TokenRow,
|
||||
"DELETE FROM token_expiry_notification n
|
||||
USING token t
|
||||
WHERE n.token = t.token
|
||||
AND n.expiration > now()
|
||||
AND n.expiration <= now() + interval '7 days'
|
||||
RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await;
|
||||
|
||||
match expiring_tokens_r {
|
||||
Ok(tokens) => {
|
||||
for t in &tokens {
|
||||
report_token_expiration(db, t, false).await;
|
||||
}
|
||||
if !tokens.is_empty() {
|
||||
tracing::info!("Sent expiration warnings for {} token(s)", tokens.len());
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error checking expiring tokens: {}", e),
|
||||
}
|
||||
|
||||
// Clean up notification rows whose expiration has passed
|
||||
if let Err(e) = sqlx::query!("DELETE FROM token_expiry_notification WHERE expiration <= now()")
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error cleaning up expired token notifications: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a batch of expired jobs with LIMIT and SKIP LOCKED for high-scale environments.
|
||||
/// Uses a single transaction per batch to minimize lock duration.
|
||||
/// Returns the number of jobs deleted in this batch.
|
||||
@@ -2052,6 +2172,16 @@ pub async fn monitor_db(
|
||||
}
|
||||
};
|
||||
|
||||
// Run every hour (10 iterations * 30s = 5 minutes)
|
||||
// Check for tokens expiring within 7 days and send alerts
|
||||
let check_expiring_tokens_f = async {
|
||||
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
|
||||
if let Some(db) = conn.as_sql() {
|
||||
check_expiring_tokens(&db).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
join!(
|
||||
expired_items_f,
|
||||
zombie_jobs_f,
|
||||
@@ -2073,6 +2203,7 @@ pub async fn monitor_db(
|
||||
cleanup_worker_group_stats_f,
|
||||
native_triggers_sync_f,
|
||||
cleanup_notify_events_f,
|
||||
check_expiring_tokens_f,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,8 @@ sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attri
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
token: token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
token_expiry_notification: token(char), expiration(ts)
|
||||
INDEX: idx_token_expiry_notification_expiration (expiration)
|
||||
tutorial_progress: email(char), progress(bit64), skipped_all(bool)
|
||||
unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts)
|
||||
usage: id(char), is_workspace(bool), month_(int), usage(int)
|
||||
|
||||
@@ -557,6 +557,14 @@ pub async fn create_token_internal(
|
||||
));
|
||||
}
|
||||
|
||||
register_token_expiry_notification(
|
||||
&mut *tx,
|
||||
&token,
|
||||
token_config.label.as_deref(),
|
||||
token_config.expiration,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
authed,
|
||||
@@ -572,6 +580,31 @@ pub async fn create_token_internal(
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Insert a pending expiry notification row for user tokens that have an expiration.
|
||||
pub async fn register_token_expiry_notification(
|
||||
tx: &mut sqlx::PgConnection,
|
||||
token: &str,
|
||||
label: Option<&str>,
|
||||
expiration: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) {
|
||||
let Some(expiration) = expiration else { return };
|
||||
if label == Some("session")
|
||||
|| label.is_some_and(|l| l.starts_with("ephemeral") || l.starts_with("Ephemeral"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
token,
|
||||
expiration,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to register token expiry notification: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------ Permission helpers ------------
|
||||
|
||||
pub fn get_perm_in_extra_perms_for_authed(
|
||||
|
||||
@@ -1850,6 +1850,14 @@ async fn impersonate(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
windmill_api_auth::register_token_expiry_notification(
|
||||
&mut *tx,
|
||||
&token,
|
||||
new_token.label.as_deref(),
|
||||
new_token.expiration,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -48,6 +48,7 @@ pub const DISABLE_HUB_SETTING: &str = "disable_hub";
|
||||
pub const CRITICAL_ERROR_CHANNELS_SETTING: &str = "critical_error_channels";
|
||||
pub const CRITICAL_ALERT_MUTE_UI_SETTING: &str = "critical_alert_mute_ui";
|
||||
pub const CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING: &str = "critical_alerts_on_db_oversize";
|
||||
pub const CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING: &str = "critical_alerts_on_token_expiry";
|
||||
pub const DEV_INSTANCE_SETTING: &str = "dev_instance";
|
||||
pub const JWT_SECRET_SETTING: &str = "jwt_secret";
|
||||
pub const EMAIL_DOMAIN_SETTING: &str = "email_domain";
|
||||
|
||||
@@ -193,6 +193,7 @@ lazy_static::lazy_static! {
|
||||
pub static ref METRICS_DEBUG_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub static ref CRITICAL_ALERT_MUTE_UI_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
pub static ref CRITICAL_ALERTS_ON_TOKEN_EXPIRY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub static ref BASE_URL: Arc<RwLock<String>> = Arc::new(RwLock::new("".to_string()));
|
||||
pub static ref IS_READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
@@ -39,29 +39,115 @@ pub enum WebhookPayload {
|
||||
#[serde(tag = "type")]
|
||||
pub enum WebhookMessage {
|
||||
// See https://serde.rs/enum-representations.html#internally-tagged for how this looks in JSON
|
||||
CreateApp { workspace: String, path: String },
|
||||
DeleteApp { workspace: String, path: String },
|
||||
UpdateApp { workspace: String, old_path: String, new_path: String },
|
||||
CreateFlow { workspace: String, path: String },
|
||||
UpdateFlow { workspace: String, old_path: String, new_path: String },
|
||||
ArchiveFlow { workspace: String, path: String },
|
||||
DeleteFlow { workspace: String, path: String },
|
||||
CreateFolder { workspace: String, name: String },
|
||||
UpdateFolder { workspace: String, name: String },
|
||||
DeleteFolder { workspace: String, name: String },
|
||||
DeleteResource { workspace: String, path: String },
|
||||
CreateResource { workspace: String, path: String },
|
||||
UpdateResource { workspace: String, old_path: String, new_path: String },
|
||||
CreateResourceType { name: String },
|
||||
DeleteResourceType { name: String },
|
||||
UpdateResourceType { name: String },
|
||||
CreateScript { workspace: String, path: String, hash: String },
|
||||
UpdateScript { workspace: String, path: String, hash: String },
|
||||
DeleteScript { workspace: String, hash: String },
|
||||
DeleteScriptPath { workspace: String, path: String },
|
||||
CreateVariable { workspace: String, path: String },
|
||||
UpdateVariable { workspace: String, old_path: String, new_path: String },
|
||||
DeleteVariable { workspace: String, path: String },
|
||||
CreateApp {
|
||||
workspace: String,
|
||||
path: String,
|
||||
},
|
||||
DeleteApp {
|
||||
workspace: String,
|
||||
path: String,
|
||||
},
|
||||
UpdateApp {
|
||||
workspace: String,
|
||||
old_path: String,
|
||||
new_path: String,
|
||||
},
|
||||
CreateFlow {
|
||||
workspace: String,
|
||||
path: String,
|
||||
},
|
||||
UpdateFlow {
|
||||
workspace: String,
|
||||
old_path: String,
|
||||
new_path: String,
|
||||
},
|
||||
ArchiveFlow {
|
||||
workspace: String,
|
||||
path: String,
|
||||
},
|
||||
DeleteFlow {
|
||||
workspace: String,
|
||||
path: String,
|
||||
},
|
||||
CreateFolder {
|
||||
workspace: String,
|
||||
name: String,
|
||||
},
|
||||
UpdateFolder {
|
||||
workspace: String,
|
||||
name: String,
|
||||
},
|
||||
DeleteFolder {
|
||||
workspace: String,
|
||||
name: String,
|
||||
},
|
||||
DeleteResource {
|
||||
workspace: String,
|
||||
path: String,
|
||||
},
|
||||
CreateResource {
|
||||
workspace: String,
|
||||
path: String,
|
||||
},
|
||||
UpdateResource {
|
||||
workspace: String,
|
||||
old_path: String,
|
||||
new_path: String,
|
||||
},
|
||||
CreateResourceType {
|
||||
name: String,
|
||||
},
|
||||
DeleteResourceType {
|
||||
name: String,
|
||||
},
|
||||
UpdateResourceType {
|
||||
name: String,
|
||||
},
|
||||
CreateScript {
|
||||
workspace: String,
|
||||
path: String,
|
||||
hash: String,
|
||||
},
|
||||
UpdateScript {
|
||||
workspace: String,
|
||||
path: String,
|
||||
hash: String,
|
||||
},
|
||||
DeleteScript {
|
||||
workspace: String,
|
||||
hash: String,
|
||||
},
|
||||
DeleteScriptPath {
|
||||
workspace: String,
|
||||
path: String,
|
||||
},
|
||||
CreateVariable {
|
||||
workspace: String,
|
||||
path: String,
|
||||
},
|
||||
UpdateVariable {
|
||||
workspace: String,
|
||||
old_path: String,
|
||||
new_path: String,
|
||||
},
|
||||
DeleteVariable {
|
||||
workspace: String,
|
||||
path: String,
|
||||
},
|
||||
TokenExpiringSoon {
|
||||
workspace: String,
|
||||
token_prefix: String,
|
||||
label: String,
|
||||
owner: String,
|
||||
expires_at: String,
|
||||
days_remaining: i64,
|
||||
},
|
||||
TokenExpired {
|
||||
workspace: String,
|
||||
token_prefix: String,
|
||||
label: String,
|
||||
owner: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -267,6 +353,20 @@ mod tests {
|
||||
new_path: "n".into(),
|
||||
},
|
||||
WebhookMessage::DeleteVariable { workspace: "w".into(), path: "p".into() },
|
||||
WebhookMessage::TokenExpiringSoon {
|
||||
workspace: "w".into(),
|
||||
token_prefix: "abc1234567".into(),
|
||||
label: "my-token".into(),
|
||||
owner: "user@example.com".into(),
|
||||
expires_at: "2026-03-10T00:00:00Z".into(),
|
||||
days_remaining: 7,
|
||||
},
|
||||
WebhookMessage::TokenExpired {
|
||||
workspace: "w".into(),
|
||||
token_prefix: "abc1234567".into(),
|
||||
label: "my-token".into(),
|
||||
owner: "user@example.com".into(),
|
||||
},
|
||||
];
|
||||
|
||||
for msg in &messages {
|
||||
|
||||
@@ -571,6 +571,15 @@ export const settings: Record<string, Setting[]> = {
|
||||
requiresReloadOnChange: true,
|
||||
ee_only: 'Critical alerts in UI are only available in the EE version'
|
||||
},
|
||||
{
|
||||
label: 'Alert on token expiry',
|
||||
description:
|
||||
'Send critical alerts when API tokens are about to expire (within 7 days) or have expired',
|
||||
key: 'critical_alerts_on_token_expiry',
|
||||
fieldType: 'boolean',
|
||||
storage: 'setting',
|
||||
ee_only: ''
|
||||
},
|
||||
{
|
||||
label: 'Slack',
|
||||
key: 'slack',
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import CreateToken from './CreateToken.svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import Badge from '../common/badge/Badge.svelte'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import { Trash } from 'lucide-svelte'
|
||||
|
||||
// --- Props ---
|
||||
@@ -35,6 +37,46 @@
|
||||
listTokens()
|
||||
})
|
||||
|
||||
function isUserToken(label: string | undefined): boolean {
|
||||
if (!label) return true
|
||||
return label !== 'session' && !label.toLowerCase().startsWith('ephemeral')
|
||||
}
|
||||
|
||||
function daysUntilExpiration(expiration: string | undefined): number | null {
|
||||
if (!expiration) return null
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const exp = new Date(expiration)
|
||||
exp.setHours(0, 0, 0, 0)
|
||||
return Math.round((exp.getTime() - today.getTime()) / 86400000)
|
||||
}
|
||||
|
||||
function expirationBadge(
|
||||
expiration: string | undefined,
|
||||
label: string | undefined
|
||||
): {
|
||||
color: 'red' | 'orange' | 'yellow' | 'gray'
|
||||
text: string
|
||||
} | null {
|
||||
if (!isUserToken(label)) return null
|
||||
const days = daysUntilExpiration(expiration)
|
||||
if (days === null) return null
|
||||
if (days < 0) return { color: 'red', text: 'Expired' }
|
||||
if (days === 0) return { color: 'red', text: 'Expires today' }
|
||||
if (days === 1) return { color: 'orange', text: 'Expires tomorrow' }
|
||||
if (days <= 7) return { color: 'orange', text: `Expires in ${days}d` }
|
||||
if (days <= 30) return { color: 'yellow', text: `Expires in ${days}d` }
|
||||
return null
|
||||
}
|
||||
|
||||
let expiringSoonCount = $derived(
|
||||
tokens.filter((t) => {
|
||||
if (!isUserToken(t.label)) return false
|
||||
const days = daysUntilExpiration(t.expiration)
|
||||
return days !== null && days >= 0 && days <= 7
|
||||
}).length
|
||||
)
|
||||
|
||||
function handleTokenCreated(token: string) {
|
||||
onTokenCreated(token)
|
||||
listTokens()
|
||||
@@ -70,6 +112,11 @@
|
||||
<div class="text-xs text-secondary mb-2">
|
||||
Authenticate to the Windmill API with access tokens.
|
||||
</div>
|
||||
{#if expiringSoonCount > 0}
|
||||
<div class="mb-2">
|
||||
<Alert type="warning" title="{expiringSoonCount} token{expiringSoonCount > 1 ? 's' : ''} expiring within 7 days" size="xs" />
|
||||
</div>
|
||||
{/if}
|
||||
<CreateToken
|
||||
{showMcpMode}
|
||||
{openWithMcpMode}
|
||||
@@ -91,13 +138,19 @@
|
||||
{#snippet body()}
|
||||
<tbody>
|
||||
{#if tokens && tokens.length > 0}
|
||||
{#each tokens as { token_prefix, expiration, label, scopes }}
|
||||
{#each tokens as { token_prefix, expiration, label, scopes } (token_prefix)}
|
||||
{@const badge = expirationBadge(expiration, label)}
|
||||
<tr>
|
||||
<td class="w-32 text-xs text-primary">{token_prefix}****</td>
|
||||
<td class="min-w-0 max-w-32 truncate text-xs text-primary">{label ?? ''}</td>
|
||||
<td class="w-24 whitespace-nowrap text-xs text-secondary"
|
||||
>{displayDate(expiration ?? '')}</td
|
||||
>
|
||||
<td class="w-40 whitespace-nowrap text-xs text-secondary">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{displayDate(expiration ?? '')}
|
||||
{#if badge}
|
||||
<Badge color={badge.color} small>{badge.text}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="min-w-0 max-w-48 truncate text-xs text-secondary"
|
||||
title={scopes?.join(', ') ?? ''}>{scopes?.join(', ') ?? ''}</td
|
||||
|
||||
Reference in New Issue
Block a user