diff --git a/backend/migrations/20261015160053_account_created_by_follows_user.down.sql b/backend/migrations/20261015160053_account_created_by_follows_user.down.sql new file mode 100644 index 0000000000..218946e997 --- /dev/null +++ b/backend/migrations/20261015160053_account_created_by_follows_user.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS account_created_by_follows_user_trigger ON usr; +DROP FUNCTION IF EXISTS account_created_by_follows_user(); diff --git a/backend/migrations/20261015160053_account_created_by_follows_user.up.sql b/backend/migrations/20261015160053_account_created_by_follows_user.up.sql new file mode 100644 index 0000000000..fc8987d1c0 --- /dev/null +++ b/backend/migrations/20261015160053_account_created_by_follows_user.up.sql @@ -0,0 +1,21 @@ +-- `account.created_by` grants the right to link the account to a variable, so it must name the +-- same person for as long as it is set: follow the user through a rename, and clear it when the +-- user leaves the workspace, so a later member given the same username does not inherit it. +-- A trigger rather than application code, because users are removed from several code paths. +CREATE OR REPLACE FUNCTION account_created_by_follows_user() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + UPDATE account SET created_by = NULL + WHERE workspace_id = OLD.workspace_id AND created_by = OLD.username; + ELSIF NEW.username IS DISTINCT FROM OLD.username THEN + UPDATE account SET created_by = NEW.username + WHERE workspace_id = OLD.workspace_id AND created_by = OLD.username; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER account_created_by_follows_user_trigger +AFTER UPDATE OF username OR DELETE ON usr +FOR EACH ROW +EXECUTE FUNCTION account_created_by_follows_user(); diff --git a/backend/windmill-api-integration-tests/tests/variables.rs b/backend/windmill-api-integration-tests/tests/variables.rs index b74e50ac88..14e0fcda05 100644 --- a/backend/windmill-api-integration-tests/tests/variables.rs +++ b/backend/windmill-api-integration-tests/tests/variables.rs @@ -454,3 +454,33 @@ async fn test_oauth_account_link_requires_ownership(db: Pool) -> anyho Ok(()) } + +/// The creator recorded on an OAuth account follows the user through a rename and is cleared +/// when they leave, so a later member reusing the username cannot link the account. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +async fn test_oauth_account_creator_follows_user(db: Pool) -> anyhow::Result<()> { + let account: i32 = sqlx::query_scalar( + "INSERT INTO account (workspace_id, client, expires_at, refresh_token, created_by) + VALUES ('test-workspace', 'github', now(), 'r', 'alice') RETURNING id", + ) + .fetch_one(&db) + .await?; + let creator = || async { + sqlx::query_scalar::<_, Option>("SELECT created_by FROM account WHERE id = $1") + .bind(account) + .fetch_one(&db) + .await + }; + + sqlx::query("UPDATE usr SET username = 'alice2' WHERE username = 'alice'") + .execute(&db) + .await?; + assert_eq!(creator().await?.as_deref(), Some("alice2")); + + sqlx::query("DELETE FROM usr WHERE username = 'alice2'") + .execute(&db) + .await?; + assert_eq!(creator().await?, None); + + Ok(()) +}