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).