feat: let an admin share one native trigger connection with the whole workspace

Members can use the shared connection without reading its token. It reuses
workspace_integrations.resource_path, so existing workspace connections stay
usable by members after upgrading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-15 16:32:20 +02:00
co-authored by Claude Opus 5.5
parent 2e875e9e10
commit 3645d0401e
24 changed files with 534 additions and 52 deletions
+4 -1
View File
@@ -130,7 +130,10 @@ accounts) are many per service. Each trigger acts through one connection, named
A user may use a connection when they can read its variable (checked with a user transaction,
so the variable's row-level security decides): `resolve_usable_connection` /
`list_usable_connections` in `lib.rs`. Background work (sync, renewal, rename re-registration,
`list_usable_connections` in `lib.rs`. The exception is the **workspace connection**: an admin
may share one connection they made per service (`workspace_integrations.resource_path`, set by
`POST .../workspace_connection`), and every member may then use it without reading its token.
Renames carry the marker along; disconnecting clears it. Background work (sync, renewal, rename re-registration,
webhook handling) uses the stored `connection_path` directly.
`decrypt_oauth_data(db, w_id, service, connection_path)` assembles these into one struct, and
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT v.path, r.created_by AS \"owner?\", true AS \"workspace!\"\n FROM workspace_integrations wi\n JOIN variable v ON v.workspace_id = wi.workspace_id AND v.path = wi.resource_path\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE wi.workspace_id = $1 AND wi.service_name = $2\n AND a.client = $3 AND a.is_workspace_integration = true",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "owner?",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "workspace!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud",
"google",
"github"
]
}
}
},
"Text"
]
},
"nullable": [
false,
true,
null
]
},
"hash": "1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e"
}
@@ -0,0 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_integrations SET resource_path = NULL\n WHERE workspace_id = $1 AND service_name = $2 AND resource_path = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud",
"google",
"github"
]
}
}
},
"Text"
]
},
"nullable": []
},
"hash": "649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE v.workspace_id = $1 AND v.path = $2 AND a.client = $3\n AND a.is_workspace_integration = true AND r.created_by = $4)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "72c123eb6a0d66f4e57d86694581772154b09f4a89903462875e57c9760a19e8"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT v.path, r.created_by AS \"owner?\", false AS \"workspace!\"\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true\n ORDER BY v.path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "owner?",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "workspace!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true,
null
]
},
"hash": "79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5"
}
@@ -0,0 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_integrations SET resource_path = $3, updated_at = now()\n WHERE workspace_id = $1 AND service_name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud",
"google",
"github"
]
}
}
},
"Text"
]
},
"nullable": []
},
"hash": "bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727"
}
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT v.path, r.created_by AS \"owner?\"\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true\n ORDER BY v.path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "owner?",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "c93af685f45ed641a9f2bb3beed5298eef6f75ec3852e0f007b98f71bfe56d53"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "f5460eb13ea4f0e9896928a3266e419090f72e54f7347b35254a661116ba822d"
}
@@ -14,6 +14,7 @@ use sqlx::{Pool, Postgres};
use axum::http::StatusCode;
use windmill_api_auth::{ApiAuthed, ScopePathFilter};
use windmill_common::{
db::UserDB,
error::Error,
variables::{build_crypt, encrypt},
};
@@ -22,8 +23,8 @@ use windmill_native_triggers::{
get_workspace_integration,
github::GitHub,
google::{parse_stop_channel_params, should_renew_channel},
grant_refused, http_error_status, list_native_triggers, map_external_error,
native_trigger_is_enabled,
grant_refused, http_error_status, list_native_triggers, list_usable_connections,
map_external_error, native_trigger_is_enabled,
nextcloud::NextCloud,
require_native_integration_use, set_native_trigger_enabled, store_native_trigger,
store_workspace_integration, update_native_trigger, update_oauth_token_resource, External,
@@ -220,6 +221,45 @@ async fn test_connections_are_isolated(db: Pool<Postgres>) -> anyhow::Result<()>
Ok(())
}
/// A member who cannot read the admin's connection may still use it once it is shared as the
/// workspace connection.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_workspace_connection_usable_by_members(db: Pool<Postgres>) -> anyhow::Result<()> {
let path = "u/test-user/native_nextcloud";
setup_oauth_integration(&db, ServiceName::Nextcloud, path, "a", "r", None).await?;
let member = ApiAuthed {
email: "test2@windmill.dev".to_string(),
username: "test-user-2".to_string(),
is_admin: false,
..test_authed()
};
let usable_paths = || async {
let mut tx = UserDB::new(db.clone()).begin(&member).await?;
let connections =
list_usable_connections(&db, &mut tx, "test-workspace", ServiceName::Nextcloud).await?;
tx.commit().await?;
anyhow::Ok(
connections
.into_iter()
.map(|c| (c.path, c.workspace))
.collect::<Vec<_>>(),
)
};
assert!(usable_paths().await?.is_empty());
sqlx::query(
"UPDATE workspace_integrations SET resource_path = $1
WHERE workspace_id = 'test-workspace' AND service_name = 'nextcloud'",
)
.bind(path)
.execute(&db)
.await?;
assert_eq!(usable_paths().await?, vec![(path.to_string(), true)]);
Ok(())
}
// ============================================================================
// 2. Config Loading — workspace vs instance + token update
// ============================================================================
+36
View File
@@ -20732,6 +20732,38 @@ paths:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/workspace_connection:
post:
summary: share one of your connections with every member of the workspace, or stop sharing
operationId: setNativeTriggerWorkspaceConnection
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
path:
type: string
nullable: true
description: The connection to share, or null to stop sharing
responses:
"200":
description: workspace connection updated
content:
application/json:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/delete:
delete:
summary: delete native trigger service
@@ -39312,8 +39344,12 @@ components:
type: string
nullable: true
description: Username of whoever connected the account, when the caller can read its resource
workspace:
type: boolean
description: Whether this is the workspace connection, which every member may use
required:
- path
- workspace
CreateTriggerResponse:
type: object
+5
View File
@@ -1192,6 +1192,11 @@ async fn offboard_user_from_workspace<'c>(
&new_prefix, username, w_id
).execute(&mut **tx).await?;
sqlx::query!(
r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
&new_prefix, username, w_id
).execute(&mut **tx).await?;
sqlx::query!(
r#"UPDATE workspace_runnable_dependencies SET flow_path = REGEXP_REPLACE(flow_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE flow_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
&new_prefix, username, w_id
+9
View File
@@ -625,6 +625,15 @@ async fn update_username_in_workpsace<'c>(
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
new_username,
old_username,
w_id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"UPDATE variable SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3",
new_username,
@@ -26,6 +26,7 @@ async fn list_repos(
) -> JsonResult<Vec<GithubRepoEntry>> {
let connection_path = picker_connection(
&authed,
&db,
user_db,
&workspace_id,
ServiceName::Github,
@@ -97,6 +97,7 @@ async fn list_calendars(
) -> JsonResult<Vec<GoogleCalendarEntry>> {
let connection_path = picker_connection(
&authed,
&db,
user_db,
&workspace_id,
ServiceName::Google,
@@ -145,6 +146,7 @@ async fn list_drive_files(
) -> JsonResult<GoogleDriveFilesResponse> {
let connection_path = picker_connection(
&authed,
&db,
user_db,
&workspace_id,
ServiceName::Google,
@@ -236,6 +238,7 @@ async fn list_shared_drives(
) -> JsonResult<Vec<SharedDriveEntry>> {
let connection_path = picker_connection(
&authed,
&db,
user_db,
&workspace_id,
ServiceName::Google,
@@ -189,6 +189,7 @@ async fn create_native_trigger<T: External>(
.await?;
let connection_path = resolve_usable_connection(
&db,
&mut tx,
&workspace_id,
service_name.integration_service(),
@@ -245,12 +246,13 @@ async fn create_native_trigger<T: External>(
};
lock_connection(&mut tx, &workspace_id, &connection_path).await?;
// Through `db`: a member using the workspace connection cannot read its variable.
let still_connected = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)",
workspace_id,
connection_path,
)
.fetch_one(&mut *tx)
.fetch_one(&db)
.await?
.unwrap_or(false);
if !still_connected {
@@ -323,7 +325,8 @@ async fn update_native_trigger_handler<T: External>(
// to act as it, not just to write the runnable.
let integration_service = service_name.integration_service();
if let Some(path) = existing.connection_path.as_deref() {
resolve_usable_connection(&mut tx, &workspace_id, integration_service, Some(path)).await?;
resolve_usable_connection(&db, &mut tx, &workspace_id, integration_service, Some(path))
.await?;
}
let oauth_data: T::OAuthData = decrypt_oauth_data(
&db,
@@ -487,12 +490,15 @@ async fn get_native_trigger_handler<T: External>(
// Reading the registration acts as the connection's account, like saving does.
let usable = match windmill_trigger.connection_path.as_deref() {
Some(path) => {
list_usable_connections(&mut tx, &workspace_id, service_name.integration_service())
.await?
.iter()
.any(|c| c.path == path)
}
Some(path) => list_usable_connections(
&db,
&mut tx,
&workspace_id,
service_name.integration_service(),
)
.await?
.iter()
.any(|c| c.path == path),
None => false,
};
if !usable {
+41 -5
View File
@@ -1759,19 +1759,23 @@ pub struct Connection {
pub path: String,
/// Username of whoever connected the account, when the caller can read its resource.
pub owner: Option<String>,
/// The workspace connection, which every member may use.
pub workspace: bool,
}
/// The connections of a service the caller may use. `tx` must be a user transaction: using a
/// connection means acting as the account behind it, so it is gated on reading its token
/// variable, which the variable's row-level security decides.
/// variable, which the variable's row-level security decides. The one exception is the
/// workspace connection an admin shared, read through `db` since members cannot read its token.
pub async fn list_usable_connections(
db: &DB,
tx: &mut PgConnection,
workspace_id: &str,
service_name: ServiceName,
) -> Result<Vec<Connection>> {
let connections = sqlx::query_as!(
let mut connections = sqlx::query_as!(
Connection,
r#"SELECT v.path, r.created_by AS "owner?"
r#"SELECT v.path, r.created_by AS "owner?", false AS "workspace!"
FROM variable v
JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account
LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path
@@ -1782,18 +1786,48 @@ pub async fn list_usable_connections(
)
.fetch_all(&mut *tx)
.await?;
if let Some(shared) = workspace_connection(db, workspace_id, service_name).await? {
connections.retain(|c| c.path != shared.path);
connections.insert(0, shared);
}
Ok(connections)
}
/// The connection an admin shared with the whole workspace, if it still exists.
pub async fn workspace_connection(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<Option<Connection>> {
let connection = sqlx::query_as!(
Connection,
r#"SELECT v.path, r.created_by AS "owner?", true AS "workspace!"
FROM workspace_integrations wi
JOIN variable v ON v.workspace_id = wi.workspace_id AND v.path = wi.resource_path
JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account
LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path
WHERE wi.workspace_id = $1 AND wi.service_name = $2
AND a.client = $3 AND a.is_workspace_integration = true"#,
workspace_id,
service_name as ServiceName,
service_name.as_str(),
)
.fetch_optional(db)
.await?;
Ok(connection)
}
/// Pick the connection a new trigger acts through: the one asked for if the caller may use it,
/// otherwise the caller's only usable connection. `tx` must be a user transaction.
pub async fn resolve_usable_connection(
db: &DB,
tx: &mut PgConnection,
workspace_id: &str,
service_name: ServiceName,
requested: Option<&str>,
) -> Result<String> {
let usable = list_usable_connections(tx, workspace_id, service_name).await?;
let usable = list_usable_connections(db, tx, workspace_id, service_name).await?;
match requested {
Some(path) if usable.iter().any(|c| c.path == path) => Ok(path.to_string()),
Some(path) => Err(Error::NotAuthorized(format!(
@@ -1838,6 +1872,7 @@ pub struct ConnectionQuery {
/// connection to browse through, which the caller must be allowed to use.
pub async fn picker_connection(
authed: &ApiAuthed,
db: &DB,
user_db: UserDB,
workspace_id: &str,
service_name: ServiceName,
@@ -1845,7 +1880,8 @@ pub async fn picker_connection(
) -> Result<String> {
require_native_integration_use(authed)?;
let mut tx = user_db.begin(authed).await?;
let path = resolve_usable_connection(&mut tx, workspace_id, service_name, requested).await?;
let path =
resolve_usable_connection(db, &mut tx, workspace_id, service_name, requested).await?;
tx.commit().await?;
Ok(path)
}
@@ -30,6 +30,7 @@ async fn list_available_events<T: External>(
) -> JsonResult<Vec<NextCloudEventType>> {
let connection_path = picker_connection(
&authed,
&db,
user_db,
&workspace_id,
ServiceName::Nextcloud,
@@ -1055,11 +1055,12 @@ async fn check_instance_sharing_available(
#[cfg(feature = "native_trigger")]
async fn list_connections(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
) -> JsonResult<Vec<Connection>> {
let mut tx = user_db.begin(&authed).await?;
let connections = list_usable_connections(&mut tx, &workspace_id, service_name).await?;
let connections = list_usable_connections(&db, &mut tx, &workspace_id, service_name).await?;
tx.commit().await?;
Ok(Json(connections))
}
@@ -1070,6 +1071,83 @@ struct ConnectionPathQuery {
path: String,
}
#[cfg(feature = "native_trigger")]
#[derive(Debug, Deserialize)]
struct WorkspaceConnectionBody {
/// The connection to share with every member, or none to stop sharing.
path: Option<String>,
}
/// Share one connection with the whole workspace: every member may then create and edit triggers
/// through it, without being able to read its token.
#[cfg(feature = "native_trigger")]
async fn set_workspace_connection(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(WorkspaceConnectionBody { path }): Json<WorkspaceConnectionBody>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
// Sharing lets everyone act as the account behind it, so only the one who connected it may.
if let Some(path) = path.as_deref() {
let own = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM variable v
JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account
JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path
WHERE v.workspace_id = $1 AND v.path = $2 AND a.client = $3
AND a.is_workspace_integration = true AND r.created_by = $4)",
workspace_id,
path,
service_name.as_str(),
authed.username,
)
.fetch_one(&db)
.await?
.unwrap_or(false);
if !own {
return Err(Error::BadRequest(format!(
"No {service_name} connection you made at {path}. You can only share an account \
you connected yourself."
)));
}
}
let mut tx = db.begin().await?;
let updated = sqlx::query!(
"UPDATE workspace_integrations SET resource_path = $3, updated_at = now()
WHERE workspace_id = $1 AND service_name = $2",
workspace_id,
service_name as ServiceName,
path,
)
.execute(&mut *tx)
.await?
.rows_affected();
if updated == 0 {
return Err(Error::NotFound(format!(
"{service_name} is not set up in this workspace"
)));
}
audit_log(
&mut *tx,
&authed,
&format!("workspace_integrations.{}.share", service_name),
ActionKind::Update,
&workspace_id,
Some(path.as_deref().unwrap_or("none")),
None,
)
.await?;
tx.commit().await?;
Ok(Json(match path {
Some(path) => format!("{path} is now the workspace connection"),
None => "No workspace connection".to_string(),
}))
}
/// Disconnect one account: its triggers are deleted with it, since nothing else can act on
/// their registrations.
#[cfg(feature = "native_trigger")]
@@ -1092,7 +1170,7 @@ async fn delete_connection(
.await?;
let mut tx = user_db.clone().begin(&authed).await?;
let usable = list_usable_connections(&mut tx, &workspace_id, service_name).await?;
let usable = list_usable_connections(&db, &mut tx, &workspace_id, service_name).await?;
tx.commit().await?;
if !usable.iter().any(|c| c.path == path) {
return Err(Error::NotFound(format!(
@@ -1157,6 +1235,17 @@ async fn delete_connection(
)));
}
// A connection made later at this path must not inherit the sharing.
sqlx::query!(
"UPDATE workspace_integrations SET resource_path = NULL
WHERE workspace_id = $1 AND service_name = $2 AND resource_path = $3",
workspace_id,
service_name as ServiceName,
path,
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
@@ -1193,6 +1282,10 @@ pub fn workspaced_service() -> Router {
"/{service_name}/connections/delete",
delete(delete_connection),
)
.route(
"/{service_name}/workspace_connection",
post(set_workspace_connection),
)
.route("/{service_name}/delete", delete(delete_integration))
.route("/{service_name}/callback", post(oauth_callback));
+9
View File
@@ -2174,6 +2174,15 @@ async fn update_resource(
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
npath,
w_id,
path
)
.execute(&mut *tx)
.await?;
// Update ci_test_reference when a tested resource is renamed
sqlx::query!(
"UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'resource'",
+9
View File
@@ -1321,6 +1321,15 @@ async fn update_variable(
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
npath,
w_id,
path
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE ws_specific SET path = $1 WHERE workspace_id = $2 AND item_kind = 'variable' AND path = $3",
npath,
@@ -61,7 +61,10 @@
}
const items = $derived.by(() => {
const list = (connections ?? []).map((c) => ({ label: c.path, value: c.path }))
const list = (connections ?? []).map((c) => ({
label: c.workspace ? `${c.path} (workspace connection)` : c.path,
value: c.path
}))
if (value && !list.some((i) => i.value === value)) {
list.push({ label: value, value })
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { Button, Alert } from '$lib/components/common'
import { Button, Alert, Badge } from '$lib/components/common'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
import { X, ExternalLink, Cog, Plug } from 'lucide-svelte'
@@ -185,6 +185,32 @@
}
}
async function setWorkspaceConnection(serviceName: string, path: string | null) {
if (!$workspaceStore) return
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
if (path) {
const confirmed = await confirmationModal.ask({
title: `Share ${path} with the workspace?`,
confirmationText: 'Share',
children: `Every member will be able to create and edit ${displayName} triggers that act as this account. They will not see its token. It replaces any connection shared before.`
})
if (!confirmed) return
}
try {
await WorkspaceIntegrationService.setNativeTriggerWorkspaceConnection({
workspace: $workspaceStore,
serviceName: serviceName as NativeServiceName,
requestBody: { path }
})
sendUserToast(path ? `${path} is now the workspace connection` : 'Stopped sharing')
await loadConnections(serviceName)
} catch (err: any) {
sendUserToast(`Failed to update the workspace connection: ${err.body ?? err.message}`, true)
}
}
async function connect(serviceName: string) {
if (!$workspaceStore) return
@@ -286,7 +312,7 @@
<div class="flex flex-col">
<SettingsPageHeader
title="Native Triggers"
description="Configure the OAuth app of each service. Members then connect their own account, or one shared through a folder, and each trigger acts as the account it was created with."
description="Configure the OAuth app of each service. Members then connect their own account, or use one shared through a folder or as the workspace connection, and each trigger acts as the account it was created with."
link="https://www.windmill.dev/docs/core_concepts/native_triggers"
/>
@@ -415,11 +441,33 @@
<div class="text-xs font-semibold text-emphasis mb-1">Connected accounts</div>
{#each serviceConnections as connection (connection.path)}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="font-mono truncate">{connection.path}</span>
<div class="flex items-center gap-2 min-w-0">
<span class="font-mono truncate">{connection.path}</span>
{#if connection.workspace}
<Badge color="blue" small>Workspace connection</Badge>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
{#if connection.owner}
<span class="text-secondary">connected by {connection.owner}</span>
{/if}
{#if connection.workspace}
<Button
unifiedSize="xs"
variant="subtle"
onclick={() => setWorkspaceConnection(serviceName, null)}
>
Stop sharing
</Button>
{:else if connection.owner === $userStore?.username}
<Button
unifiedSize="xs"
variant="subtle"
onclick={() => setWorkspaceConnection(serviceName, connection.path)}
>
Share with workspace
</Button>
{/if}
<Button
unifiedSize="xs"
variant="subtle"