diff --git a/backend/.sqlx/query-14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701.json b/backend/.sqlx/query-14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701.json new file mode 100644 index 0000000000..6310017f18 --- /dev/null +++ b/backend/.sqlx/query-14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url,\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "installation_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "github_base_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "provisioned_by_admin!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701" +} diff --git a/backend/.sqlx/query-2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d.json b/backend/.sqlx/query-2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d.json new file mode 100644 index 0000000000..5964de4111 --- /dev/null +++ b/backend/.sqlx/query-2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"is_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n AND (elem->>'installation_id')::bigint = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_admin!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d" +} diff --git a/backend/.sqlx/query-3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7.json b/backend/.sqlx/query-3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7.json new file mode 100644 index 0000000000..83a2c7bfc6 --- /dev/null +++ b/backend/.sqlx/query-3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM workspace_settings WHERE workspace_id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7" +} diff --git a/backend/.sqlx/query-f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f.json b/backend/.sqlx/query-f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f.json new file mode 100644 index 0000000000..ac592bafbe --- /dev/null +++ b/backend/.sqlx/query-f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n workspace_id,\n (elem->>'installation_id')::bigint as \"installation_id!\",\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE (elem->>'installation_id')::bigint = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "installation_id!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "provisioned_by_admin!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + null, + null + ] + }, + "hash": "f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 1a1930d96e..1e357752b5 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -14315067c083d3361512de621b12e41dbe3b017d +da5189cf69a453de3855057f41be0d84e5910707 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e8b1459e20..e2c8cfad4d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2574,6 +2574,104 @@ paths: - app_slug - client_id + /github_app/ghes/discover: + get: + summary: Discover GHES App installations + description: | + Lists every installation the configured self-managed GitHub App can see, + annotated with the workspaces in this Windmill instance the + installation is currently assigned to. Super-admin only. + operationId: discoverGhesInstallations + tags: + - Git Sync + responses: + "200": + description: Discovered installations + content: + application/json: + schema: + type: array + items: + type: object + required: + - installation_id + - account_id + - assigned_workspaces + properties: + installation_id: + type: integer + format: int64 + account_id: + type: string + description: GitHub login of the installation's account (org or user) + assigned_workspaces: + type: array + items: + type: object + required: + - workspace_id + - provisioned_by_admin + properties: + workspace_id: + type: string + provisioned_by_admin: + type: boolean + + /github_app/ghes/assign: + post: + summary: Assign GHES installation to a workspace + description: | + Assigns a discovered GHES App installation to a workspace. The resulting + installation is marked as admin-provisioned, so workspace admins cannot + remove it. Super-admin only. + operationId: assignGhesInstallation + tags: + - Git Sync + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - workspace_id + - installation_id + properties: + workspace_id: + type: string + installation_id: + type: integer + format: int64 + responses: + "200": + description: Installation assigned + + /github_app/ghes/assign/{workspace_id}/{installation_id}: + delete: + summary: Unassign GHES installation from a workspace + description: | + Removes an installation (admin-provisioned or otherwise) from a + workspace. Super-admin only. Does not affect the installation on the + GitHub side. + operationId: unassignGhesInstallation + tags: + - Git Sync + parameters: + - name: workspace_id + in: path + required: true + schema: + type: string + - name: installation_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: Installation unassigned + /users/accept_invite: post: summary: accept invite to workspace @@ -27823,6 +27921,13 @@ components: error: type: string description: Error message if token retrieval failed + github_base_url: + type: string + nullable: true + description: Set for self-managed (GHES) installs. Cloud installs omit this field. + provisioned_by_admin: + type: boolean + description: True when the installation was assigned by the instance super-admin from instance settings. Workspace admins cannot remove these. required: - installation_id - account_id diff --git a/frontend/src/lib/components/GitHubAppIntegration.svelte b/frontend/src/lib/components/GitHubAppIntegration.svelte index 7b35dbf84c..7aa136b072 100644 --- a/frontend/src/lib/components/GitHubAppIntegration.svelte +++ b/frontend/src/lib/components/GitHubAppIntegration.svelte @@ -295,13 +295,21 @@ {#each githubState.workspaceGithubInstallations as installation (`current-${installation.installation_id}-${installation.workspace_id}`)} -
+
{#if installation.error} {/if} {installation.account_id} + {#if installation.provisioned_by_admin} + + Provisioned by admin + + {/if}
@@ -310,34 +318,41 @@ {#if installation.error} - Token error + Token error {:else} {installation.repositories.length} repos {/if}
- - + {#if !installation.github_base_url} + + {/if} + {#if !installation.provisioned_by_admin} + + {/if}
@@ -381,7 +396,10 @@ {#if installation.error} - Token error + Token error {:else} {installation.repositories.length} repos {/if} @@ -414,26 +432,28 @@
-
-

Import installation from other instance:

-
- - +
+ + +
-
+ {/if} {/snippet} diff --git a/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte index e94e56be0c..86e3038fdf 100644 --- a/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte +++ b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte @@ -1,6 +1,12 @@
@@ -185,4 +311,121 @@ bind:value={$values['github_enterprise_app'].private_key} >
+ + {#if assignmentsReady} +
+
+

Workspace assignments

+ +
+

+ Assign installations of the configured GitHub App to specific workspaces so workspace users + don't need GitHub permissions to set up sync. Click Refresh to load installations + the App can see (save the config above first if you haven't). +

+ + {#if discoveryError} +

{discoveryError}

+ {:else if loadingDiscovery && discovered.length === 0} +
+ {:else if discovered.length === 0} +

+ The configured GitHub App has no installations yet. Install it on a GitHub account, then + click Refresh. +

+ {:else} + + + + + + + + + + + {#each discovered as install (install.installation_id)} + + + + + + + {/each} + +
+ GitHub account + + The GitHub organization or user the App is installed on (e.g. + windmill-labs). A GitHub App installation is always scoped to exactly + one account. + + Installation IDAssigned to
{install.account_id}{install.installation_id} + {#if install.assigned_workspaces.length === 0} + + {:else} +
+ {#each install.assigned_workspaces as assignment (assignment.workspace_id)} + + {assignment.workspace_id} + + + {/each} +
+ {/if} +
+
+
+
+ {/if} +
+ {/if} diff --git a/frontend/src/lib/githubApp.ts b/frontend/src/lib/githubApp.ts index be82fb3940..f60b1f8538 100644 --- a/frontend/src/lib/githubApp.ts +++ b/frontend/src/lib/githubApp.ts @@ -16,6 +16,13 @@ export interface GitHubAppState { installationCheckInterval: number | undefined isCheckingInstallation: boolean importJwt: string + /** + * True when the instance has a self-managed (GHES) GitHub App configured. + * Used to hide cloud-only UI like the Export/Import buttons, since those + * JWTs carry no `github_base_url` and would round-trip into broken + * github.com-pointed installs. + */ + isGhesSelfManaged: boolean } export interface GitHubRepository { @@ -99,7 +106,8 @@ export function createGitHubAppState(): GitHubAppState { githubInstallationUrl: undefined, installationCheckInterval: undefined, isCheckingInstallation: false, - importJwt: '' + importJwt: '', + isGhesSelfManaged: false } } @@ -137,6 +145,7 @@ export async function loadGithubInstallations( try { const ghesConfig: GetGhesConfigResponse = await GitSyncService.getGhesConfig() if (ghesConfig?.base_url && ghesConfig?.app_slug) { + state.isGhesSelfManaged = true const ghesBaseUrl = ghesConfig.base_url.replace(/\/$/, '') // GHES (self-hosted) uses /github-apps/, github.com and GHE Cloud (*.ghe.com) use /apps/ const hostname = new URL(ghesBaseUrl).hostname @@ -149,10 +158,12 @@ export async function loadGithubInstallations( : ghesConfig.app_slug state.githubInstallationUrl = `${ghesBaseUrl}/${appsPath}/${appPath}/installations/new?state=${stateParam}` } else { + state.isGhesSelfManaged = false state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` } } catch { // No GHES config — use default github.com URL + state.isGhesSelfManaged = false state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` } } catch (err) {