From ce3e676f4ab0c442058c64db4ebf35545a805ef5 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 10 Apr 2026 15:11:00 +0200 Subject: [PATCH] feat: list external JWT tokens in instance settings (#8783) * [ee] feat: add external JWT tokens listing in instance settings Add the ability for superadmins to view all external JWT tokens that have been used for authentication, along with their claim metadata. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: move external JWT tokens listing to users tab - Move list endpoint from /oidc/ext_jwt_tokens to /users/ext_jwt_tokens - Display as a sub-tab below the instance Users tab, only shown when tokens exist - Use DataTable's built-in load-more pattern for pagination - Add "Recently active only" toggle (tokens used in the last 30 days) Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add dev_override cargo feature to windmill-common * feat: show placeholder for legacy external JWT entries Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 62a462461271b900351c18b0ab1ca78651154b2a This commit updates the EE repository reference after PR #524 was merged in windmill-ee-private. Previous ee-repo-ref: 7b493a337abe00a47cf9d94847babe3cb3a6799f New ee-repo-ref: 62a462461271b900351c18b0ab1ca78651154b2a Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...0b9069de29c7a1bf3d59a4ecb5db9062de316.json | 72 ++ ...a83bad2af8f3f57a0b5b713e38db473a38507.json | 21 + ...9468e01c4bee4cd755322eb2a83353a279a2b.json | 14 - ...6619f1e1300f9f9969911608f690b78d93099.json | 68 ++ backend/ee-repo-ref.txt | 2 +- ...260409145556_add_ext_jwt_metadata.down.sql | 10 + ...20260409145556_add_ext_jwt_metadata.up.sql | 10 + backend/summarized_schema.txt | 2 +- backend/windmill-api/openapi.yaml | 63 ++ backend/windmill-api/src/users.rs | 53 +- backend/windmill-common/Cargo.toml | 1 + .../components/SuperadminSettingsInner.svelte | 644 ++++++++++-------- .../instanceSettings/ExternalJwtTokens.svelte | 97 +++ .../(user)/instance_settings/+page.svelte | 9 +- 14 files changed, 750 insertions(+), 316 deletions(-) create mode 100644 backend/.sqlx/query-235e9f3afc4127f81fd0d09e1890b9069de29c7a1bf3d59a4ecb5db9062de316.json create mode 100644 backend/.sqlx/query-3474805749cc2c0ccee995690e7a83bad2af8f3f57a0b5b713e38db473a38507.json delete mode 100644 backend/.sqlx/query-778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b.json create mode 100644 backend/.sqlx/query-c547516873b02e06d229b1c7f8e6619f1e1300f9f9969911608f690b78d93099.json create mode 100644 backend/migrations/20260409145556_add_ext_jwt_metadata.down.sql create mode 100644 backend/migrations/20260409145556_add_ext_jwt_metadata.up.sql create mode 100644 frontend/src/lib/components/instanceSettings/ExternalJwtTokens.svelte diff --git a/backend/.sqlx/query-235e9f3afc4127f81fd0d09e1890b9069de29c7a1bf3d59a4ecb5db9062de316.json b/backend/.sqlx/query-235e9f3afc4127f81fd0d09e1890b9069de29c7a1bf3d59a4ecb5db9062de316.json new file mode 100644 index 0000000000..d64776eb53 --- /dev/null +++ b/backend/.sqlx/query-235e9f3afc4127f81fd0d09e1890b9069de29c7a1bf3d59a4ecb5db9062de316.json @@ -0,0 +1,72 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT jwt_hash, email, username, is_admin, is_operator, workspace_id, label, scopes, last_used_at\n FROM unique_ext_jwt_token\n WHERE NOT $3 OR last_used_at > NOW() - INTERVAL '30 days'\n ORDER BY last_used_at DESC\n LIMIT $1 OFFSET $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jwt_hash", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "is_operator", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "label", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "last_used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "235e9f3afc4127f81fd0d09e1890b9069de29c7a1bf3d59a4ecb5db9062de316" +} diff --git a/backend/.sqlx/query-3474805749cc2c0ccee995690e7a83bad2af8f3f57a0b5b713e38db473a38507.json b/backend/.sqlx/query-3474805749cc2c0ccee995690e7a83bad2af8f3f57a0b5b713e38db473a38507.json new file mode 100644 index 0000000000..b9bff1449b --- /dev/null +++ b/backend/.sqlx/query-3474805749cc2c0ccee995690e7a83bad2af8f3f57a0b5b713e38db473a38507.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO unique_ext_jwt_token (jwt_hash, last_used_at, email, username, is_admin, is_operator, workspace_id, label, scopes)\n VALUES ($1, NOW(), $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (jwt_hash)\n DO UPDATE SET last_used_at = NOW(), email = $2, username = $3, is_admin = $4, is_operator = $5, workspace_id = $6, label = $7, scopes = $8", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Bool", + "Bool", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "3474805749cc2c0ccee995690e7a83bad2af8f3f57a0b5b713e38db473a38507" +} diff --git a/backend/.sqlx/query-778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b.json b/backend/.sqlx/query-778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b.json deleted file mode 100644 index a35e6560e3..0000000000 --- a/backend/.sqlx/query-778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO unique_ext_jwt_token (jwt_hash, last_used_at)\n VALUES ($1, NOW())\n ON CONFLICT (jwt_hash)\n DO UPDATE SET last_used_at = NOW()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b" -} diff --git a/backend/.sqlx/query-c547516873b02e06d229b1c7f8e6619f1e1300f9f9969911608f690b78d93099.json b/backend/.sqlx/query-c547516873b02e06d229b1c7f8e6619f1e1300f9f9969911608f690b78d93099.json new file mode 100644 index 0000000000..821faaf66f --- /dev/null +++ b/backend/.sqlx/query-c547516873b02e06d229b1c7f8e6619f1e1300f9f9969911608f690b78d93099.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT jwt_hash, email, username, is_admin, is_operator, workspace_id, label, scopes, last_used_at\n FROM unique_ext_jwt_token\n WHERE last_used_at > NOW() - INTERVAL '30 days'\n ORDER BY last_used_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jwt_hash", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "is_operator", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "label", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "last_used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "c547516873b02e06d229b1c7f8e6619f1e1300f9f9969911608f690b78d93099" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index b8cf7f144e..3bf97f0d99 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -d9d68c2406df0b59f413ea0b2cb24780a9817d04 +62a462461271b900351c18b0ab1ca78651154b2a diff --git a/backend/migrations/20260409145556_add_ext_jwt_metadata.down.sql b/backend/migrations/20260409145556_add_ext_jwt_metadata.down.sql new file mode 100644 index 0000000000..df7d494680 --- /dev/null +++ b/backend/migrations/20260409145556_add_ext_jwt_metadata.down.sql @@ -0,0 +1,10 @@ +-- Remove metadata columns from unique_ext_jwt_token + +ALTER TABLE unique_ext_jwt_token + DROP COLUMN IF EXISTS email, + DROP COLUMN IF EXISTS username, + DROP COLUMN IF EXISTS is_admin, + DROP COLUMN IF EXISTS is_operator, + DROP COLUMN IF EXISTS workspace_id, + DROP COLUMN IF EXISTS label, + DROP COLUMN IF EXISTS scopes; diff --git a/backend/migrations/20260409145556_add_ext_jwt_metadata.up.sql b/backend/migrations/20260409145556_add_ext_jwt_metadata.up.sql new file mode 100644 index 0000000000..5195934968 --- /dev/null +++ b/backend/migrations/20260409145556_add_ext_jwt_metadata.up.sql @@ -0,0 +1,10 @@ +-- Add metadata columns to unique_ext_jwt_token for listing external JWTs + +ALTER TABLE unique_ext_jwt_token + ADD COLUMN IF NOT EXISTS email TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS username TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS is_operator BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS workspace_id TEXT, + ADD COLUMN IF NOT EXISTS label TEXT, + ADD COLUMN IF NOT EXISTS scopes TEXT[]; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index b090030be3..1517a0e780 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -161,7 +161,7 @@ token: token_hash(char), token_prefix(char), token(char), label(char), expiratio token_expiry_notification: token_hash(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) +unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts), email(text), username(text), is_admin(bool), is_operator(bool), workspace_id(text?), label(text?), scopes(text[]?) usage: id(char), is_workspace(bool), month_(int), usage(int) usr: workspace_id(char), username(char), email(char), is_admin(bool), created_at(ts), operator(bool), disabled(bool), role(char), added_via(jsonb) FK: (workspace_id) -> workspace(id) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index dc6eb7ef66..85357437a0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -726,6 +726,36 @@ paths: items: $ref: "#/components/schemas/ExportedUser" + /users/ext_jwt_tokens: + get: + summary: list external JWT tokens (ee only) + operationId: listExtJwtTokens + tags: + - user + parameters: + - name: page + in: query + schema: + type: integer + - name: per_page + in: query + schema: + type: integer + - name: active_only + in: query + description: only tokens used in the last 30 days + schema: + type: boolean + responses: + "200": + description: list of external JWT tokens + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ExternalJwtToken" + /users/onboarding: post: summary: Submit user onboarding data @@ -21469,6 +21499,39 @@ components: - created_at - last_used_at + ExternalJwtToken: + type: object + properties: + jwt_hash: + type: integer + format: int64 + email: + type: string + username: + type: string + is_admin: + type: boolean + is_operator: + type: boolean + workspace_id: + type: string + label: + type: string + scopes: + type: array + items: + type: string + last_used_at: + type: string + format: date-time + required: + - jwt_hash + - email + - username + - is_admin + - is_operator + - last_used_at + NewToken: type: object properties: diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 6d2dd6e77d..186d2cc168 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -15,7 +15,7 @@ use crate::db::ApiAuthed; use crate::secret_backend_ext::rename_vault_secrets_with_prefix; use argon2::Argon2; use axum::{ - extract::{Extension, Path}, + extract::{Extension, Path, Query}, routing::{get, post}, Json, Router, }; @@ -52,6 +52,7 @@ pub fn global_service() -> Router { .route("/create", post(create_user)) .route("/rename/{user}", post(rename_user)) .route("/onboarding", post(submit_onboarding_data)) + .route("/ext_jwt_tokens", get(list_ext_jwt_tokens)) .route( "/offboard_preview/{user}", get(crate::offboarding::global_offboard_preview), @@ -86,6 +87,56 @@ async fn submit_onboarding_data( crate::users_oss::submit_onboarding_data(authed, Extension(db), Json(data)).await } +#[derive(serde::Serialize)] +pub struct ExternalJwtToken { + pub jwt_hash: i64, + pub email: String, + pub username: String, + pub is_admin: bool, + pub is_operator: bool, + pub workspace_id: Option, + pub label: Option, + pub scopes: Option>, + pub last_used_at: chrono::DateTime, +} + +#[derive(serde::Deserialize)] +struct ListExtJwtTokensQuery { + page: Option, + per_page: Option, + #[serde(default)] + active_only: bool, +} + +async fn list_ext_jwt_tokens( + authed: ApiAuthed, + Extension(db): Extension, + Query(query): Query, +) -> Result>> { + require_super_admin(&db, &authed.email).await?; + + let (per_page, offset) = windmill_common::utils::paginate(windmill_common::utils::Pagination { + page: query.page, + per_page: query.per_page, + }); + + let rows = sqlx::query_as!( + ExternalJwtToken, + "SELECT jwt_hash, email, username, is_admin, is_operator, workspace_id, label, scopes, last_used_at + FROM unique_ext_jwt_token + WHERE NOT $3 OR last_used_at > NOW() - INTERVAL '30 days' + ORDER BY last_used_at DESC + LIMIT $1 OFFSET $2", + per_page as i64, + offset as i64, + query.active_only, + ) + .fetch_all(&db) + .await?; + + Ok(Json(rows)) +} + async fn set_password( Extension(db): Extension, Extension(argon2): Extension>>, diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index c5a45428e9..9e7f4cd42e 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -21,6 +21,7 @@ otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep smtp = ["dep:mail-send"] scoped_cache = [] cloud = [] +dev_override = [] openidconnect = ["dep:openidconnect"] bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"] python = ["dep:windmill-parser-py"] diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index a8fe64184d..c652293fc0 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -1,5 +1,6 @@ + + + +
+ onActiveOnlyChange(e.detail)} + options={{ + left: 'Recently active only', + leftTooltip: 'Show only tokens used in the last 30 days' + }} + /> +
+ + onLoadMore()} +> + + + Email + Username + Admin + Operator + Workspace + Label + Scopes + Last Used + + + + {#each tokens as token, i (token.jwt_hash)} + + {#if token.email === ''} + + Legacy entry — details unavailable + + {displayDate(token.last_used_at)} + {:else} + {token.email} + {token.username} + + {#if token.is_admin} + + {:else} + + {/if} + + + {#if token.is_operator} + + {:else} + + {/if} + + {token.workspace_id ?? '-'} + {token.label ?? '-'} + + {#if token.scopes && token.scopes.length > 0} + {token.scopes.join(', ')} + {:else} + - + {/if} + + {displayDate(token.last_used_at)} + {/if} + + {/each} + + diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index 36f9be8faa..d74059dca6 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -351,7 +351,10 @@ ) } catch (e: any) { const msg = e?.body?.message || e?.body || e?.message || 'An error occurred' - if (typeof msg === 'string' && msg.includes('User creation is not implemented in the open-source version')) { + if ( + typeof msg === 'string' && + msg.includes('User creation is not implemented in the open-source version') + ) { ossAccountError = msg showOssAccountDialog = true } else { @@ -723,8 +726,8 @@ {ossAccountError} - Click "Continue" to finish setup and log in with the default credentials - (admin@windmill.dev / changeme). + Click "Continue" to finish setup and log in with the default credentials (admin@windmill.dev + / changeme).