mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 08:01:38 +00:00
Merge remote-tracking branch 'origin/main' into explore-git-sync-improvements
This commit is contained in:
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as \"username?\",\n d.created_at as \"draft_saved_at!\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST",
|
||||
"query": "SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as \"username?\",\n d.created_at as \"draft_saved_at!\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n LEFT JOIN password p\n ON p.email = d.email\n AND p.super_admin = true\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -60,5 +60,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6fbd5826ae1fafd7bbb218324e3e2d85231b889743de637eb35e756da29ad47b"
|
||||
"hash": "4efaa4c3c14987fe95a508cc0cbac91ee793ad6f5ceed5b3eda9b48617c5434d"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM password WHERE username IS NOT NULL)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9ee6263223bd415d7ac21328a8ebc731d43e7331b9fd83282a87f8e2fd8fa770"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COALESCE(\n (SELECT email FROM usr WHERE workspace_id = $1 AND username = $2),\n (SELECT email FROM password WHERE (username = $2 OR email = $2) AND super_admin = true)\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "coalesce",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ae2293c7d9d2b5dad61b27db3b29d5f87fc6b8d53fe6a073eef488533cbd3ed3"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email FROM usr WHERE workspace_id = $1 AND username = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM workspace WHERE id != 'admins' AND deleted = true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fc1663ecda7167dc12c0fd5a206cbddf85e55f6c7ef226cdd66e025c002bac98"
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- Irreversible data backfill: once realigned to the derived username, a favorite
|
||||
-- is indistinguishable from one legitimately created under that username, so there
|
||||
-- is nothing safe to revert. No-op.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
-- A superadmin acting in a workspace they are not a member of used to be
|
||||
-- identified by their raw email (so favorites were stored with usr = email).
|
||||
-- They are now identified by their instance-derived username (password.username),
|
||||
-- so realign those pre-existing favorites to keep them visible. Only email-keyed
|
||||
-- rows are ever a superadmin's (members always store a non-email username), and
|
||||
-- the anti-join skips rows that would collide with an already-derived favorite.
|
||||
UPDATE favorite f
|
||||
SET usr = p.username
|
||||
FROM password p
|
||||
WHERE f.usr = p.email
|
||||
AND p.super_admin = true
|
||||
AND p.username IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM favorite f2
|
||||
WHERE f2.workspace_id = f.workspace_id
|
||||
AND f2.usr = p.username
|
||||
AND f2.favorite_kind = f.favorite_kind
|
||||
AND f2.path = f.path
|
||||
);
|
||||
@@ -2704,7 +2704,9 @@ async fn test_schedule_permissions_workspace_admin(db: Pool<Postgres>) -> anyhow
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Superadmin NOT in workspace creates a schedule — uses email as permissioned_as
|
||||
/// Superadmin NOT in workspace creates a schedule — uses their instance-derived
|
||||
/// username (`password.username`) as permissioned_as, not the raw email. The
|
||||
/// email is still stored directly on the schedule for downstream resolution.
|
||||
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
||||
async fn test_schedule_permissions_superadmin_not_in_workspace(
|
||||
db: Pool<Postgres>,
|
||||
@@ -2758,16 +2760,16 @@ async fn test_schedule_permissions_superadmin_not_in_workspace(
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
// Superadmin not in workspace: username_to_permissioned_as uses the email directly
|
||||
// since the authed username for a superadmin not in workspace IS the email
|
||||
// Superadmin not in workspace: the authed username is now their instance-derived
|
||||
// username (`password.username` = 'superadmin-external'), so permissioned_as is
|
||||
// `u/<derived>` rather than the raw email. The email is still stored directly.
|
||||
assert_eq!(
|
||||
schedule.email, "superadmin-external@windmill.dev",
|
||||
"schedule email should be superadmin email"
|
||||
);
|
||||
assert_eq!(
|
||||
schedule.permissioned_as,
|
||||
schedule.email.clone(),
|
||||
"permissioned_as should match email for superadmin not in workspace"
|
||||
schedule.permissioned_as, "u/superadmin-external",
|
||||
"permissioned_as should use the instance-derived username, not the email"
|
||||
);
|
||||
|
||||
// Update by the same superadmin
|
||||
|
||||
@@ -30,6 +30,7 @@ use windmill_common::{
|
||||
},
|
||||
error::{Error, JsonResult},
|
||||
jwt,
|
||||
usernames::get_instance_username_or_fallback_to_email,
|
||||
users::{COOKIE_NAME, SUPERADMIN_SECRET_EMAIL},
|
||||
};
|
||||
|
||||
@@ -419,18 +420,34 @@ impl AuthCache {
|
||||
read_only,
|
||||
})
|
||||
}
|
||||
None if super_admin => Some(ApiAuthed {
|
||||
email: email.clone(),
|
||||
username: email,
|
||||
is_admin: super_admin,
|
||||
is_operator: false,
|
||||
groups: vec![],
|
||||
folders: vec![],
|
||||
scopes,
|
||||
username_override,
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
}),
|
||||
None if super_admin => {
|
||||
// Fail closed on a DB error rather than
|
||||
// letting the email leak in as the username.
|
||||
match get_instance_username_or_fallback_to_email(
|
||||
&self.db, &email,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(username) => Some(ApiAuthed {
|
||||
email,
|
||||
username,
|
||||
is_admin: super_admin,
|
||||
is_operator: false,
|
||||
groups: vec![],
|
||||
folders: vec![],
|
||||
scopes,
|
||||
username_override,
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to resolve instance username for superadmin {email}: {e:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -155,10 +155,11 @@ async fn list_flows(
|
||||
"o.labels",
|
||||
"draft.email IS NOT NULL as is_draft",
|
||||
// Per-path draft owners as a JSON array; see scripts.rs for the rationale
|
||||
// (admins-workspace identity fallback, legacy NULL-email row).
|
||||
"(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \
|
||||
// (non-member superadmin identity fallback via `password`, legacy NULL-email row).
|
||||
"(SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END)) ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) NULLS LAST) \
|
||||
FROM draft d \
|
||||
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
|
||||
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
|
||||
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow') as draft_users",
|
||||
"folder_labels(o.workspace_id, o.path) as inherited_labels"
|
||||
])
|
||||
|
||||
@@ -1399,7 +1399,9 @@ async fn test_delete_fork_purges_workspace_diff(db: Pool<Postgres>) -> anyhow::R
|
||||
// Delete the fork through the real handler.
|
||||
let delete_response = client
|
||||
.client()
|
||||
.delete(&format!("{base_url}/workspaces/delete/wm-fork-test-workspace"))
|
||||
.delete(&format!(
|
||||
"{base_url}/workspaces/delete/wm-fork-test-workspace"
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
@@ -1521,3 +1523,122 @@ async fn test_create_fork_purges_stale_diff_state(db: Pool<Postgres>) -> anyhow:
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression: a stale/phantom trigger diff row must never block a privileged
|
||||
/// user's deploy. Triggers (unlike scripts/flows) are not re-validated by
|
||||
/// `compare_workspaces`, so a cached `has_changes=true` row for a trigger that
|
||||
/// no longer exists in the table is trusted, then dropped by the visibility
|
||||
/// filter (the row is gone) — flipping `all_ahead_items_visible` to false and
|
||||
/// hiding the deploy button. This used to happen even for a superadmin, because
|
||||
/// the item's absence is indistinguishable from a permission-hidden item.
|
||||
///
|
||||
/// The blast-radius guard forces the flag true for anyone who sees the relevant
|
||||
/// side in full: a target/fork admin (or superadmin) for ahead items. A regular
|
||||
/// user with no such visibility still gets the (conservative) warning, since we
|
||||
/// cannot tell a phantom from a genuine permission gap on their behalf.
|
||||
///
|
||||
/// The diff row and visibility query are OSS, so this runs on any build (no
|
||||
/// tally needed — the row is inserted directly, mimicking a delete that left the
|
||||
/// diff behind).
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_compare_workspaces_phantom_trigger_shortfuse(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base_url = format!("http://localhost:{port}/api");
|
||||
let superadmin = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
let non_admin = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN_2".to_string(),
|
||||
);
|
||||
|
||||
// Fork of test-workspace (INSERT directly; we only need the pair to exist).
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
|
||||
VALUES ('wm-fork-test-workspace', 'Fork', 'test-user', 'test-workspace')"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query!("INSERT INTO workspace_settings (workspace_id) VALUES ('wm-fork-test-workspace')")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_key(workspace_id, kind, key)
|
||||
VALUES ('wm-fork-test-workspace', 'cloud', 'test-key')"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Phantom rows: cached diffs for http_triggers with no backing row (the exact
|
||||
// state a trigger delete used to leave behind before it reset the tally). One
|
||||
// ahead (fork side), one behind (source side) so both guard branches are hit.
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_diff
|
||||
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)
|
||||
VALUES
|
||||
('test-workspace', 'wm-fork-test-workspace', 'f/rt/ghost', 'http_trigger', 1, 0, true, false, true),
|
||||
('test-workspace', 'wm-fork-test-workspace', 'f/rt/ghost_behind', 'http_trigger', 0, 1, true, true, false)"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Superadmin: the guard forces `all_ahead_items_visible = true`, and the
|
||||
// non-existent trigger is not surfaced as a diff.
|
||||
let comparison: serde_json::Value = superadmin
|
||||
.client()
|
||||
.get(&format!(
|
||||
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
|
||||
))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
comparison["all_ahead_items_visible"].as_bool(),
|
||||
Some(true),
|
||||
"phantom trigger row must not trip the 'not visible' warning for a superadmin: {comparison}"
|
||||
);
|
||||
assert_eq!(
|
||||
comparison["all_behind_items_visible"].as_bool(),
|
||||
Some(true),
|
||||
"phantom behind trigger row must not trip the warning for a superadmin: {comparison}"
|
||||
);
|
||||
assert!(
|
||||
!comparison["diffs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["path"] == "f/rt/ghost" || d["path"] == "f/rt/ghost_behind"),
|
||||
"non-existent triggers must not be surfaced as diffs: {comparison}"
|
||||
);
|
||||
|
||||
// Non-superadmin, non-fork-admin member of the source: no full-visibility
|
||||
// guarantee, so the warning still (conservatively) fires.
|
||||
let comparison: serde_json::Value = non_admin
|
||||
.client()
|
||||
.get(&format!(
|
||||
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
|
||||
))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
comparison["all_ahead_items_visible"].as_bool(),
|
||||
Some(false),
|
||||
"a user without full visibility must not be short-circuited by the guard: {comparison}"
|
||||
);
|
||||
assert_eq!(
|
||||
comparison["all_behind_items_visible"].as_bool(),
|
||||
Some(false),
|
||||
"the behind-side guard must not fire for a non-admin either: {comparison}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -211,12 +211,15 @@ async fn list_scripts(
|
||||
// Canonical reference for the draft-feature comments; flows/apps point here.
|
||||
// Per-path draft owners as a JSON array (`Json<Vec<DraftUserRef>>`); NULL -> None,
|
||||
// never an empty array. LEFT JOIN `usr` keeps orphaned drafts (user left workspace)
|
||||
// visible with `username = None`. In the `admins` workspace username IS the email,
|
||||
// so fall back to `d.email` there or the authed user's own draft resolves to a phantom
|
||||
// "Legacy workspace draft"; the genuine NULL-email legacy row stays None.
|
||||
"(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \
|
||||
// visible with `username = None`. A superadmin authoring in a workspace they are not
|
||||
// a member of has no `usr` row, so fall back to their instance-derived username
|
||||
// (`password.username`), or their email when derivation is disabled — this keeps the
|
||||
// raw email out of the payload whenever a derived username exists. The genuine
|
||||
// NULL-email legacy row stays None (no `usr`/`password` match, `d.email` is NULL).
|
||||
"(SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END)) ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) NULLS LAST) \
|
||||
FROM draft d \
|
||||
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
|
||||
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
|
||||
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script') as draft_users",
|
||||
"folder_labels(o.workspace_id, o.path) as inherited_labels"
|
||||
])
|
||||
|
||||
@@ -869,6 +869,37 @@ async fn run_setting_pre_write_hook(
|
||||
err
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
// Disabling is only allowed before any instance-wide username has been
|
||||
// assigned. Once usernames exist they are globally unique and are baked
|
||||
// into stored `u/<username>` identities (schedules, triggers, drafts,
|
||||
// and non-member superadmin ownership). Disabling would drop back to
|
||||
// workspace-local username uniqueness, letting a member reuse an
|
||||
// existing instance username and silently take over those identities —
|
||||
// so the setting is effectively one-way once derivation has taken
|
||||
// effect. Re-saving `false` on an already-disabled instance is a no-op
|
||||
// and stays allowed (guarded by the current-value check).
|
||||
let currently_enabled = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = $1",
|
||||
AUTOMATE_USERNAME_CREATION_SETTING
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
if currently_enabled {
|
||||
let usernames_exist = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM password WHERE username IS NOT NULL)"
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if usernames_exist {
|
||||
return Err(error::Error::BadRequest(
|
||||
"automate_username_creation cannot be disabled once instance-wide usernames have been assigned: existing u/<username> identities (schedules, triggers, drafts, superadmin ownership) rely on those usernames staying stable and globally unique.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING => {
|
||||
|
||||
@@ -240,6 +240,11 @@ pub struct UserInfo {
|
||||
pub folders_owners: Vec<String>,
|
||||
pub name: Option<String>,
|
||||
pub is_service_account: bool,
|
||||
// True when this row is a superadmin viewing a workspace they are not a
|
||||
// member of (so `is_admin`/`role` reflect the superadmin fallback, not an
|
||||
// actual membership). Always false for real member rows.
|
||||
#[serde(default)]
|
||||
pub non_member: bool,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize)]
|
||||
@@ -699,13 +704,17 @@ async fn whoami(
|
||||
) -> JsonResult<UserInfo> {
|
||||
let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed;
|
||||
let user = get_user(&w_id, &username, &db).await?;
|
||||
if let Some(user) = user {
|
||||
// Only treat the row as "this user is a member" when its email matches; the
|
||||
// derived username is instance-unique so a match on a different email should
|
||||
// never happen, but guard against it so a non-member superadmin is never
|
||||
// shown another member's identity/role.
|
||||
if let Some(user) = user.filter(|u| u.email == email) {
|
||||
Ok(Json(user))
|
||||
} else {
|
||||
Ok(Json(UserInfo {
|
||||
workspace_id: w_id,
|
||||
email: email.clone(),
|
||||
username: email,
|
||||
email,
|
||||
username,
|
||||
name: None,
|
||||
is_admin,
|
||||
is_super_admin: is_admin,
|
||||
@@ -725,6 +734,7 @@ async fn whoami(
|
||||
.filter_map(|x| if x.2 { Some(x.0) } else { None })
|
||||
.collect(),
|
||||
is_service_account: false,
|
||||
non_member: true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -889,6 +899,7 @@ async fn get_user(w_id: &str, username: &str, db: &DB) -> Result<Option<UserInfo
|
||||
.filter_map(|x| if x.2 { Some(x.0) } else { None })
|
||||
.collect(),
|
||||
is_service_account: usr.is_service_account,
|
||||
non_member: false,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2695,6 +2706,12 @@ async fn username_to_email(
|
||||
Path((w_id, username)): Path<(String, String)>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> Result<String> {
|
||||
// Members only: this workspace-scoped endpoint has no superadmin/target gate,
|
||||
// so it must NOT use the `password` superadmin fallback — otherwise any
|
||||
// workspace-authenticated caller could turn a guessed derived username into a
|
||||
// non-member superadmin's email. Internal callers that legitimately need the
|
||||
// fallback (schedule/trigger/draft resolution) use `resolve_username_to_email`
|
||||
// directly and never return the email to an arbitrary caller.
|
||||
let email = sqlx::query_scalar!(
|
||||
"SELECT email FROM usr WHERE username = $1 AND workspace_id = $2",
|
||||
&username,
|
||||
|
||||
@@ -3901,6 +3901,21 @@ async fn _check_nb_of_workspaces(db: &DB) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
async fn _check_nb_of_archived_workspaces(db: &DB) -> Result<()> {
|
||||
let nb_archived = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM workspace WHERE id != 'admins' AND deleted = true",
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if nb_archived.unwrap_or(0) >= 1 {
|
||||
return Err(Error::BadRequest(
|
||||
"You have reached the maximum number of archived workspaces (1) without an enterprise license. Permanently delete or unarchive the existing archived workspace first"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
async fn create_workspace(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -5775,6 +5790,11 @@ async fn archive_workspace(
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
// CE caps the number of archived (soft-deleted) workspaces so archiving can't be used to
|
||||
// stockpile hidden workspaces. Enforced here so a second archive is refused up front.
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
_check_nb_of_archived_workspaces(&db).await?;
|
||||
|
||||
// If this is an attached dev workspace, archiving it leaves the prod with no active dev (the
|
||||
// unique index and user_workspaces both ignore deleted=true), so clear the prod's
|
||||
// dev_workspace_lock too. Gate it on prod-admin since it removes prod's protection rule (mirrors
|
||||
@@ -5887,6 +5907,13 @@ async fn unarchive_workspace(
|
||||
authed: ApiAuthed,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
// Unarchiving re-activates a soft-deleted workspace, so it must respect the
|
||||
// same CE workspace-count cap as creating one. The archived workspace is
|
||||
// deleted = true and thus excluded from the count until it is restored.
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
_check_nb_of_workspaces(&db).await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!("UPDATE workspace SET deleted = false WHERE id = $1", &w_id)
|
||||
.execute(&mut *tx)
|
||||
@@ -7360,6 +7387,24 @@ async fn compare_workspaces(
|
||||
.map(|s| s.behind)
|
||||
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0));
|
||||
|
||||
// Blast-radius guard for the "changes not visible to your user" warning
|
||||
// (which hides the deploy button entirely). The flag is a pure visibility
|
||||
// guarantee — the actual deploy/update is separately authorized against the
|
||||
// target workspace's create/update endpoints — so it must be forced true for
|
||||
// anyone who, as a rule, sees every item on the relevant side (RLS is bypassed
|
||||
// for admins); any diff the visibility filter dropped for them is provably a
|
||||
// stale/phantom row, never a permission gap. Crucially the two sides live in
|
||||
// different workspaces: ahead items are the fork's own changes (gated by the
|
||||
// TARGET/fork admin), behind items physically live in the parent (gated by the
|
||||
// SOURCE/parent admin). A target admin does NOT see the parent side, so it must
|
||||
// not clear the behind flag, and vice versa. `target_admin` already folds in
|
||||
// superadmin; `source_admin` (parent side) ORs it in explicitly.
|
||||
let is_super_admin = windmill_common::auth::is_super_admin_email(&db, &authed.email).await?;
|
||||
let target_admin = fork_authed.is_admin;
|
||||
let source_admin = is_super_admin || authed.is_admin;
|
||||
let all_ahead_items_visible = all_ahead_items_visible || target_admin;
|
||||
let all_behind_items_visible = all_behind_items_visible || source_admin;
|
||||
|
||||
return Ok(Json(WorkspaceComparison {
|
||||
all_ahead_items_visible,
|
||||
all_behind_items_visible,
|
||||
|
||||
@@ -23974,6 +23974,9 @@ components:
|
||||
- $ref: "#/components/schemas/UserSource"
|
||||
is_service_account:
|
||||
type: boolean
|
||||
non_member:
|
||||
type: boolean
|
||||
description: "True when this is a superadmin viewing a workspace they are not a member of (is_admin/role reflect the superadmin fallback, not an actual membership)."
|
||||
required:
|
||||
- email
|
||||
- username
|
||||
|
||||
@@ -415,13 +415,14 @@ async fn list_apps(
|
||||
"app.labels",
|
||||
"draft.path IS NOT NULL as is_draft",
|
||||
// Per-path draft owners as a JSON array; see scripts.rs for the rationale
|
||||
// (admins-workspace identity fallback, legacy NULL-email row).
|
||||
// (non-member superadmin identity fallback via `password`, legacy NULL-email row).
|
||||
// `app`/`raw_app` are separate draft kinds over one `app` table — match
|
||||
// either (like the `is_draft` join below), else a deployed raw app's draft
|
||||
// owners are dropped and the row shows "Draft" with no user badge.
|
||||
"(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \
|
||||
"(SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END)) ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) NULLS LAST) \
|
||||
FROM draft d \
|
||||
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
|
||||
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
|
||||
WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ IN ('app', 'raw_app')) as draft_users",
|
||||
"folder_labels(app.workspace_id, app.path) as inherited_labels",
|
||||
])
|
||||
|
||||
@@ -18,6 +18,7 @@ use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
|
||||
users::resolve_username_to_email,
|
||||
variables::{build_crypt, encrypt},
|
||||
};
|
||||
|
||||
@@ -171,11 +172,16 @@ fn list_drafts_query(all_users: bool) -> String {
|
||||
// draft author at this (path, kind), legacy NULL-email row surfaced as a
|
||||
// null username. Restricted to the shared full-page-editor kinds — drawer
|
||||
// kinds keep their drafts private, so we never reveal their authors.
|
||||
// A superadmin authoring in a workspace they are not a member of has no `usr`
|
||||
// row: fall back to their instance-derived username (`password.username`), or
|
||||
// their email when derivation is disabled (`password.username` is NULL). This
|
||||
// keeps the raw email out of the payload whenever a derived username exists.
|
||||
let draft_users = r#"CASE WHEN d.typ::text IN ('script', 'flow', 'app', 'raw_app') THEN (
|
||||
SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN du.workspace_id = 'admins' THEN du.email END))
|
||||
ORDER BY COALESCE(u.username, CASE WHEN du.workspace_id = 'admins' THEN du.email END) NULLS LAST)
|
||||
SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN du.email END))
|
||||
ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN du.email END) NULLS LAST)
|
||||
FROM draft du
|
||||
LEFT JOIN usr u ON u.workspace_id = du.workspace_id AND u.email = du.email
|
||||
LEFT JOIN password p ON p.email = du.email AND p.super_admin = true
|
||||
WHERE du.workspace_id = d.workspace_id AND du.path = d.path AND du.typ = d.typ
|
||||
) ELSE NULL END"#;
|
||||
// Default lists the user's own drafts AND the legacy NULL-email rows; with
|
||||
@@ -578,20 +584,12 @@ async fn get_draft_for_user(
|
||||
|
||||
// Username -> email, scoped to the workspace. None signals "fetch the
|
||||
// legacy NULL-email row" (distinct from a username with no draft, which
|
||||
// 404s below).
|
||||
// 404s below). Resolution falls back to the instance `password` table so a
|
||||
// superadmin's draft (they are not a `usr` member of the workspace, and
|
||||
// their username is their instance-derived one) still resolves.
|
||||
let owner_email: Option<String> = if let Some(username) = &query.username {
|
||||
let email = sqlx::query_scalar!(
|
||||
r#"SELECT email FROM usr WHERE workspace_id = $1 AND username = $2"#,
|
||||
&w_id,
|
||||
username,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
match email {
|
||||
match resolve_username_to_email(&w_id, username, &db).await? {
|
||||
Some(e) => Some(e),
|
||||
// The `admins` workspace has no `usr` rows (username IS the email
|
||||
// there), so accept it as the owner email directly.
|
||||
None if w_id == "admins" => Some(username.clone()),
|
||||
None => {
|
||||
return Err(Error::NotFound(format!(
|
||||
"no user with username {username} in workspace"
|
||||
@@ -805,7 +803,6 @@ async fn require_can_read_path(
|
||||
Err(Error::NotFound(format!("no draft visible at {path}")))
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip_json_nul;
|
||||
@@ -841,7 +838,9 @@ mod tests {
|
||||
// "a" carries a real NUL; "b" carries the literal text backslash-u0000.
|
||||
// The value walk strips the former and leaves the latter intact — the
|
||||
// pathological case that needed a fallback in SQL is trivial in Rust.
|
||||
let v = parsed(strip_json_nul(r#"{"a":"x\u0000y","b":"p\\u0000q"}"#.to_string()));
|
||||
let v = parsed(strip_json_nul(
|
||||
r#"{"a":"x\u0000y","b":"p\\u0000q"}"#.to_string(),
|
||||
));
|
||||
assert_eq!(v["a"], "xy");
|
||||
assert_eq!(v["b"], "p\\u0000q");
|
||||
}
|
||||
|
||||
@@ -413,9 +413,18 @@ async fn fetch_authed_from_permissioned_as_inner(
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Bare (no `u/`|`g/` prefix) permissioned_as is reached for superadmins
|
||||
// whose identifier is their email (they are not a workspace member). Use
|
||||
// the instance-derived username when available so no email leaks
|
||||
// downstream as the acting username.
|
||||
let username = if is_super_admin && permissioned_as == email {
|
||||
crate::usernames::get_instance_username_or_fallback_to_email(&mut *conn, email).await?
|
||||
} else {
|
||||
permissioned_as.to_string()
|
||||
};
|
||||
Ok(Authed {
|
||||
email: email.to_string(),
|
||||
username: permissioned_as.to_string(),
|
||||
username,
|
||||
is_admin: is_super_admin,
|
||||
is_operator: true,
|
||||
groups: vec![],
|
||||
|
||||
@@ -237,18 +237,22 @@ async fn fetch_other_drafts_users(
|
||||
kind: UserDraftItemKind,
|
||||
path: &str,
|
||||
) -> Result<Vec<OtherDraftUser>> {
|
||||
// The `admins` workspace has no `usr` rows (username IS the email there),
|
||||
// so fall back to `d.email` when the join misses, else a real teammate's
|
||||
// draft renders as a phantom "Legacy draft". The genuine NULL-email legacy
|
||||
// row keeps `username = None` (its `d.email` is NULL, so the CASE yields NULL).
|
||||
// A superadmin authoring in a workspace they are not a member of has no `usr`
|
||||
// row: fall back to their instance-derived username (`password.username`), or
|
||||
// their email when derivation is disabled. Else a real teammate's draft renders
|
||||
// as a phantom "Legacy draft". The genuine NULL-email legacy row keeps
|
||||
// `username = None` (no `usr`/`password` match and `d.email` is NULL).
|
||||
let rows = sqlx::query_as!(
|
||||
OtherDraftUser,
|
||||
r#"SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as "username?",
|
||||
r#"SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as "username?",
|
||||
d.created_at as "draft_saved_at!"
|
||||
FROM draft d
|
||||
LEFT JOIN usr u
|
||||
ON u.workspace_id = d.workspace_id
|
||||
AND u.email = d.email
|
||||
LEFT JOIN password p
|
||||
ON p.email = d.email
|
||||
AND p.super_admin = true
|
||||
WHERE d.workspace_id = $1
|
||||
AND d.path = $2
|
||||
AND d.typ = $3
|
||||
|
||||
@@ -94,6 +94,38 @@ pub async fn generate_instance_username_for_all_users(db: &DB) -> error::Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the username to use for a user (typically a superadmin) accessing a
|
||||
/// workspace they are not a member of. When instance-wide username derivation is
|
||||
/// enabled (`automate_username_creation`, the default on almost all instances),
|
||||
/// `password.username` is populated, so we return that derived username instead
|
||||
/// of leaking the raw email address downstream (as job/script author, audit
|
||||
/// actor, etc.). Falls back to the email when no derived username exists (the few
|
||||
/// instances that keep `automate_username_creation` disabled).
|
||||
///
|
||||
/// The hot callers (token auth, `fetch_api_authed`) are already behind their own
|
||||
/// 120s auth caches, so this deliberately does not add another email->username
|
||||
/// cache (which would only help the cold JWT-mint / job-perms paths while risking
|
||||
/// cross-DB contamination in the shared-process integration tests).
|
||||
///
|
||||
/// A DB error is propagated rather than swallowed into the email fallback: falling
|
||||
/// back to the email on a transient failure would reintroduce the very email leak
|
||||
/// this path exists to prevent, so callers fail closed instead.
|
||||
pub async fn get_instance_username_or_fallback_to_email<'e, E>(
|
||||
db: E,
|
||||
email: &str,
|
||||
) -> error::Result<String>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let derived = sqlx::query_scalar!("SELECT username FROM password WHERE email = $1", email)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten();
|
||||
// No derived username (automate_username_creation disabled) → fall back to the
|
||||
// email. This is the only legitimate fallback; a query error propagates above.
|
||||
Ok(derived.unwrap_or_else(|| email.to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_instance_username_or_create_pending<'c>(
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
email: &str,
|
||||
|
||||
@@ -46,8 +46,34 @@ lazy_static::lazy_static! {
|
||||
|
||||
const EMAIL_CACHE_TTL_SECS: u64 = 60;
|
||||
|
||||
/// Resolve a workspace-scoped username to its email.
|
||||
///
|
||||
/// Members are found in `usr`. A superadmin acting in a workspace they are *not*
|
||||
/// a member of has no `usr` row; they carry either their instance-derived
|
||||
/// username (`password.username`, when `automate_username_creation` is enabled)
|
||||
/// or their email (when it is disabled), so fall back to `password` on both,
|
||||
/// gated on `super_admin` since only superadmins can act without membership.
|
||||
/// Returns `None` when the username resolves to nobody.
|
||||
pub async fn resolve_username_to_email<'c>(
|
||||
workspace_id: &str,
|
||||
username: &str,
|
||||
db: impl sqlx::PgExecutor<'c>,
|
||||
) -> crate::error::Result<Option<String>> {
|
||||
Ok(sqlx::query_scalar!(
|
||||
"SELECT COALESCE(
|
||||
(SELECT email FROM usr WHERE workspace_id = $1 AND username = $2),
|
||||
(SELECT email FROM password WHERE (username = $2 OR email = $2) AND super_admin = true)
|
||||
)",
|
||||
workspace_id,
|
||||
username
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten())
|
||||
}
|
||||
|
||||
/// Get email from permissioned_as string.
|
||||
/// - "u/{username}" → lookup email from usr table (cached)
|
||||
/// - "u/{username}" → resolve via [`resolve_username_to_email`] (cached)
|
||||
/// - "g/{group}" → "group-{group}@windmill.dev"
|
||||
/// - raw email → return as-is
|
||||
pub async fn get_email_from_permissioned_as<'c>(
|
||||
@@ -62,14 +88,9 @@ pub async fn get_email_from_permissioned_as<'c>(
|
||||
return Ok(email);
|
||||
}
|
||||
}
|
||||
let email = sqlx::query_scalar!(
|
||||
"SELECT email FROM usr WHERE username = $1 AND workspace_id = $2",
|
||||
username,
|
||||
workspace_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or_else(|| format!("{}@unknown.windmill.dev", username));
|
||||
let email = resolve_username_to_email(workspace_id, username, db)
|
||||
.await?
|
||||
.unwrap_or_else(|| format!("{}@unknown.windmill.dev", username));
|
||||
let key = (workspace_id.to_string(), username.to_string());
|
||||
EMAIL_CACHE.insert(key, (email.clone(), std::time::Instant::now()));
|
||||
Ok(email)
|
||||
|
||||
@@ -905,6 +905,26 @@ async fn delete_trigger<T: TriggerCrud>(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Reset the fork/parent workspace_diff tally for this path, exactly as
|
||||
// create/update and every other kind's delete does. Without this a deleted
|
||||
// trigger leaves its cached `has_changes=true` diff row behind: the compare
|
||||
// trusts it (triggers aren't re-validated like scripts/flows), then drops it
|
||||
// as it no longer exists in the table — a phantom "ahead" item that reads as
|
||||
// "changes not visible to your user" and hides the deploy button, even for
|
||||
// superadmins. Re-tallying sets has_changes=NULL so the next compare
|
||||
// re-evaluates and corrects/removes the row.
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&workspace_id,
|
||||
T::get_deployed_object(path.to_string(), None),
|
||||
Some(format!("{} '{}' deleted", T::DEPLOYMENT_NAME, path)),
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Trigger gone for everyone: wipe ALL users' drafts at this path; see scripts.rs.
|
||||
delete_all_drafts_for_path(&db, &workspace_id, T::user_draft_item_kind(), path).await?;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
let wsConfigs: Record<
|
||||
string,
|
||||
{
|
||||
reassign: boolean
|
||||
targetKind: 'user' | 'folder'
|
||||
selectedUser: string | undefined
|
||||
selectedFolder: string | undefined
|
||||
@@ -71,16 +72,21 @@
|
||||
UserService.listUsernames({ workspace: wp.workspace_id }),
|
||||
FolderService.listFolders({ workspace: wp.workspace_id })
|
||||
])
|
||||
const users = usernamesList
|
||||
.filter((u: string) => u !== wp.username)
|
||||
.map((u: string) => ({ label: u, value: u }))
|
||||
return {
|
||||
workspace_id: wp.workspace_id,
|
||||
config: {
|
||||
// Reassignment requires another workspace user to own items and back
|
||||
// triggers/runnables; with no other user (e.g. single-member forks) it
|
||||
// is impossible, so default off and leave items as-is.
|
||||
reassign: users.length > 0,
|
||||
targetKind: 'user' as const,
|
||||
selectedUser: undefined as string | undefined,
|
||||
selectedFolder: undefined as string | undefined,
|
||||
selectedOperator: undefined as string | undefined,
|
||||
users: usernamesList
|
||||
.filter((u: string) => u !== wp.username)
|
||||
.map((u: string) => ({ label: u, value: u })),
|
||||
users,
|
||||
folders: foldersList.map((f: { name: string }) => ({
|
||||
label: f.name,
|
||||
value: f.name
|
||||
@@ -112,15 +118,28 @@
|
||||
: undefined
|
||||
}
|
||||
|
||||
// At least one workspace can be reassigned (has another assignable user).
|
||||
let anyReassignableWorkspace = $derived(
|
||||
workspacesWithItems.some((wp) => (wsConfigs[wp.workspace_id]?.users.length ?? 0) > 0)
|
||||
)
|
||||
// At least one workspace is actually selected for reassignment.
|
||||
let anyWorkspaceReassigned = $derived(
|
||||
workspacesWithItems.some((wp) => wsConfigs[wp.workspace_id]?.reassign)
|
||||
)
|
||||
|
||||
let canSubmit = $derived(
|
||||
!doReassign ||
|
||||
(!doReassign ||
|
||||
workspacesWithItems.every((wp) => {
|
||||
const target = getReassignTo(wp.workspace_id)
|
||||
const cfg = wsConfigs[wp.workspace_id]
|
||||
if (!cfg?.reassign) return true
|
||||
const target = getReassignTo(wp.workspace_id)
|
||||
if (!target) return false
|
||||
if (!cfg?.selectedOperator) return false
|
||||
return true
|
||||
})
|
||||
})) &&
|
||||
// Reassign-only runs (no deletion) must reassign at least one workspace,
|
||||
// otherwise the request is an empty no-op reported as success.
|
||||
(deleteUser || anyWorkspaceReassigned)
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
@@ -129,13 +148,16 @@
|
||||
try {
|
||||
const reassignments: Record<string, { reassign_to: string; new_on_behalf_of_user?: string }> =
|
||||
{}
|
||||
for (const wp of workspacesWithItems) {
|
||||
const target = getReassignTo(wp.workspace_id)
|
||||
const cfg = wsConfigs[wp.workspace_id]
|
||||
if (target) {
|
||||
reassignments[wp.workspace_id] = {
|
||||
reassign_to: target,
|
||||
new_on_behalf_of_user: cfg?.selectedOperator
|
||||
if (doReassign) {
|
||||
for (const wp of workspacesWithItems) {
|
||||
const cfg = wsConfigs[wp.workspace_id]
|
||||
if (!cfg?.reassign) continue
|
||||
const target = getReassignTo(wp.workspace_id)
|
||||
if (target) {
|
||||
reassignments[wp.workspace_id] = {
|
||||
reassign_to: target,
|
||||
new_on_behalf_of_user: cfg?.selectedOperator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,7 +244,7 @@
|
||||
{/if}
|
||||
|
||||
{#if doReassign}
|
||||
{#each workspacesWithItems as wp}
|
||||
{#each workspacesWithItems as wp (wp.workspace_id)}
|
||||
{@const cfg = wsConfigs[wp.workspace_id]}
|
||||
<div class="border border-border rounded-md p-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -230,23 +252,39 @@
|
||||
{wp.workspace_id}
|
||||
<span class="text-secondary font-normal">({wp.username})</span>
|
||||
</span>
|
||||
{#if cfg}
|
||||
<Toggle
|
||||
bind:checked={cfg.reassign}
|
||||
disabled={cfg.users.length === 0}
|
||||
size="xs"
|
||||
options={{ right: 'Reassign' }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if cfg}
|
||||
<OffboardWorkspaceSection
|
||||
preview={wp.preview}
|
||||
username={wp.username}
|
||||
{deleteUser}
|
||||
bind:targetKind={cfg.targetKind}
|
||||
bind:selectedUser={cfg.selectedUser}
|
||||
bind:selectedFolder={cfg.selectedFolder}
|
||||
bind:selectedOperator={cfg.selectedOperator}
|
||||
users={cfg.users}
|
||||
folders={cfg.folders}
|
||||
size="sm"
|
||||
csvFilename="offboard-{email}-{wp.workspace_id}.csv"
|
||||
instanceLevel
|
||||
/>
|
||||
{#if cfg.users.length === 0}
|
||||
<p class="text-xs text-tertiary">
|
||||
No other users in this workspace. Items will be left as-is.
|
||||
</p>
|
||||
{:else if cfg.reassign}
|
||||
<OffboardWorkspaceSection
|
||||
preview={wp.preview}
|
||||
username={wp.username}
|
||||
{deleteUser}
|
||||
bind:targetKind={cfg.targetKind}
|
||||
bind:selectedUser={cfg.selectedUser}
|
||||
bind:selectedFolder={cfg.selectedFolder}
|
||||
bind:selectedOperator={cfg.selectedOperator}
|
||||
users={cfg.users}
|
||||
folders={cfg.folders}
|
||||
size="sm"
|
||||
csvFilename="offboard-{email}-{wp.workspace_id}.csv"
|
||||
instanceLevel
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-xs text-tertiary">Items will be left as-is.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -283,7 +321,7 @@
|
||||
different user/folder.</p
|
||||
>
|
||||
<ul class="text-xs list-disc list-inside max-h-32 overflow-y-auto">
|
||||
{#each conflicts as conflict}
|
||||
{#each conflicts as conflict, i (i)}
|
||||
<li>{conflict}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -293,7 +331,11 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
||||
{#if workspacesWithItems.length > 0 || deleteUser}
|
||||
{#if !deleteUser && workspacesWithItems.length > 0 && !anyReassignableWorkspace}
|
||||
{#if !loading}
|
||||
<Button onclick={onClose} variant="accent" size="sm">Close</Button>
|
||||
{/if}
|
||||
{:else if workspacesWithItems.length > 0 || deleteUser}
|
||||
<Button
|
||||
disabled={submitting || !canSubmit}
|
||||
onclick={submit}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
let ownedCount = $derived(preview ? countPaths(preview.owned) : 0)
|
||||
let onBehalfCount = $derived(preview ? countPaths(preview.executing_on_behalf) : 0)
|
||||
let hasItems = $derived(ownedCount > 0 || onBehalfCount > 0)
|
||||
let canReassignHere = $derived(hasItems && users.length > 0)
|
||||
|
||||
let reassignTo = $derived(
|
||||
targetKind === 'user'
|
||||
@@ -83,6 +84,11 @@
|
||||
preview = previewResult
|
||||
users = usernamesList.filter((u) => u !== username).map((u) => ({ label: u, value: u }))
|
||||
folders = foldersList.map((f) => ({ label: f.name, value: f.name }))
|
||||
// Reassignment needs another workspace user as target/operator; with none
|
||||
// (sole-member workspace) it is impossible, so fall back to plain removal.
|
||||
if (!reassignOnly && users.length === 0) {
|
||||
doReassign = false
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast('Failed to load offboard preview', true)
|
||||
onClose()
|
||||
@@ -173,33 +179,40 @@
|
||||
{:else if preview}
|
||||
<div class="mt-4 space-y-3">
|
||||
{#if hasItems}
|
||||
{#if !reassignOnly}
|
||||
<Toggle
|
||||
bind:checked={doReassign}
|
||||
size="xs"
|
||||
options={{ right: 'Reassign items before removing' }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if doReassign}
|
||||
<OffboardWorkspaceSection
|
||||
{preview}
|
||||
{username}
|
||||
{deleteUser}
|
||||
bind:targetKind
|
||||
bind:selectedUser
|
||||
bind:selectedFolder
|
||||
bind:selectedOperator
|
||||
{users}
|
||||
{folders}
|
||||
/>
|
||||
{#if users.length === 0}
|
||||
<p class="text-xs text-tertiary">
|
||||
No other users in this workspace. Items will be left as-is.
|
||||
</p>
|
||||
{:else}
|
||||
<Alert type="warning" title="Items will not be reassigned">
|
||||
<p class="text-xs">
|
||||
All items owned by {username} ({ownedCount} owned, {onBehalfCount} running on behalf)
|
||||
will be left as-is. Triggers and runnables may stop working if the user is removed.
|
||||
</p>
|
||||
</Alert>
|
||||
{#if !reassignOnly}
|
||||
<Toggle
|
||||
bind:checked={doReassign}
|
||||
size="xs"
|
||||
options={{ right: 'Reassign items before removing' }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if doReassign}
|
||||
<OffboardWorkspaceSection
|
||||
{preview}
|
||||
{username}
|
||||
{deleteUser}
|
||||
bind:targetKind
|
||||
bind:selectedUser
|
||||
bind:selectedFolder
|
||||
bind:selectedOperator
|
||||
{users}
|
||||
{folders}
|
||||
/>
|
||||
{:else}
|
||||
<Alert type="warning" title="Items will not be reassigned">
|
||||
<p class="text-xs">
|
||||
All items owned by {username} ({ownedCount} owned, {onBehalfCount} running on
|
||||
behalf) will be left as-is. Triggers and runnables may stop working if the user
|
||||
is removed.
|
||||
</p>
|
||||
</Alert>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-sm text-secondary">
|
||||
@@ -214,7 +227,7 @@
|
||||
different user/folder.</p
|
||||
>
|
||||
<ul class="text-xs list-disc list-inside max-h-32 overflow-y-auto">
|
||||
{#each conflicts as conflict}
|
||||
{#each conflicts as conflict, i (i)}
|
||||
<li>{conflict}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -224,7 +237,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
||||
{#if hasItems || deleteUser}
|
||||
{#if deleteUser || canReassignHere}
|
||||
<Button
|
||||
disabled={submitting || !canSubmit}
|
||||
onclick={submit}
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
const INCLUDE_WITHOUT_MAIN_SETTING_NAME = 'includeWithoutMain'
|
||||
let treeView = $state(getLocalSetting(TREE_VIEW_SETTING_NAME) == 'true')
|
||||
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
|
||||
$userStore?.is_super_admin && $userStore.username.includes('@')
|
||||
$userStore?.non_member
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
|
||||
@@ -52,12 +52,22 @@
|
||||
{$userStore?.email}
|
||||
</p>
|
||||
<span class="text-xs text-primary flex flex-row gap-2 items-center">
|
||||
{#if $userStore?.is_admin}
|
||||
{#if $userStore?.non_member}
|
||||
Superadmin, not a member of this workspace <Crown size={14} />
|
||||
{:else if $userStore?.is_admin}
|
||||
Admin of this workspace <Crown size={14} />
|
||||
{:else if $userStore?.operator}
|
||||
Operator in this workspace <ServerCog size={14} />
|
||||
{/if}
|
||||
</span>
|
||||
{#if $userStore?.non_member}
|
||||
<span class="text-xs text-tertiary block mt-1">
|
||||
You are not a member, but as a superadmin you can access this workspace. You act here
|
||||
under the username
|
||||
<span class="font-mono font-medium text-primary">{$userStore?.username}</span>
|
||||
with admin permissions.
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="py-1">
|
||||
<MenuItem href={USER_SETTINGS_HASH} class={itemClass} {item}>
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface UserExt {
|
||||
folders_owners: string[]
|
||||
is_service_account?: boolean
|
||||
impersonating_email?: string
|
||||
// true when the user is a superadmin viewing a workspace they are not a member of
|
||||
non_member?: boolean
|
||||
}
|
||||
|
||||
export interface UserWorkspace {
|
||||
|
||||
Reference in New Issue
Block a user