Files
windmill/backend/windmill-common/tests/permissioned_as_authz.rs
Ruben FiszelandClaude Opus 5 c90d1d95c2 refactor: make the app policy's principal the authority for its identity (#10440)
* refactor: make the app policy's principal the authority for its identity

* fix: align the app backfill with the sibling migration and audit the uncached address

* chore: refresh the sqlx cache after rebasing onto the merged base

* fix: resolve the app execution address uncached, it decides the job's authorization

* chore: cache the EE queries at the ref this branch pins

* chore: cache the EE queries at the ref this branch pins

* fix: derive the app draft's on-behalf-of address on read

* chore: cache the query the draft derivation test added

* fix: derive the app identity on the draft-table and version reads too

* docs: state the draft resolver's authorization contract

* fix: resolve a draft's principal against workspace membership only

* chore: cache the membership lookup the draft resolver added

* fix: drop an unresolvable draft's address instead of leaving it stale

* perf: evict the address cache on change so app dispatch can read it

* fix: evict on superadmin role changes, not only address changes

* refactor: make the app policy's address optional instead of derived on read

* fix: follow an external superadmin's rename into the apps that name them

* docs: state the removal gate once, and correctly

* refactor: drop the app-policy version constant that gated nothing

* docs: drop the last reference to the removed constant

* perf: read the address cache everywhere now that eviction reaches every replica

* fix: keep persisted addresses off the cache the poller evicts asynchronously

* docs: state where the cached address is accepted and where it is not

* docs: keep the cache rule in one place and drop the stale premise

* docs: sort the two lookups by how long a wrong answer lives

* fix: resolve the schedule address uncached where it is written to the row

* docs: name the release this actually ships in

* perf: evict a superadmin's key per workspace instead of the whole cache

* fix: evict every alias a superadmin principal can be spelled as

* docs: describe the trigger as it is

* docs: cover the round-tripped read in the cache rule

* docs: record why a stale dispatch address cannot escalate

* fix: validate a dispatch address against the principal's live binding

* fix: carry the validated address through to the job row and token

* fix: record the validated address on the job row, not the one handed in

* test: run the substep tag check as the non-superadmin it means to test

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: rewrite a stored app address that disagrees with its principal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the accepted staleness window of the cached dispatch address

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: record the validated address on the job's audit row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the accepted rename race of pre-transaction identity resolution

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: separate the app's stored address from the derived one in the resolver doc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: describe the job identity fast path the push comments skipped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: backfill a legacy group-prefixed username as the group it names

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: resolve a schedule edit's identity before opening its transaction

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: never resolve a disabled member to a same-named superadmin

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: state what the email-change notify buys, and rewrap two comment lines

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: keep a group's runnables when offboarding a legacy group-prefixed member

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: read the app author from the stored address, as execution does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the rename race's full consequence as a known, accepted limitation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the keep-target group address case as a known, accepted limitation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 09:10:15 +02:00

82 lines
3.5 KiB
Rust

use sqlx::{Pool, Postgres};
use windmill_common::auth::fetch_authed_from_permissioned_as;
/// The address handed to `fetch_authed_from_permissioned_as` may come from a cache that a
/// username reassignment has outrun. It must not be believed: the workspace role is keyed on the
/// principal while `super_admin` and `email_to_igroup` are keyed on the address, so trusting a
/// stale one would run the new holder's job with the previous holder's instance privileges.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_stale_address_cannot_carry_the_previous_holders_privileges(db: Pool<Postgres>) {
// `test-user` in the fixture is a superadmin with the address `test@windmill.dev`. Free the
// username and hand it to somebody who is not, exactly as an offboard-then-onboard would.
sqlx::query("DELETE FROM usr WHERE workspace_id = 'test-workspace' AND username = 'test-user'")
.execute(&db)
.await
.expect("free the username");
sqlx::query(
"INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
VALUES ('newcomer@windmill.dev', 'x', 'password', false, true, 'Newcomer')",
)
.execute(&db)
.await
.expect("create the new account");
sqlx::query(
"INSERT INTO usr(workspace_id, email, username, is_admin, role)
VALUES ('test-workspace', 'newcomer@windmill.dev', 'test-user', false, 'User')",
)
.execute(&db)
.await
.expect("reassign the username");
// What a replica that has not yet consumed the eviction would pass: the principal is the
// reassigned username, the address is the one it cached for the previous holder.
let authed = fetch_authed_from_permissioned_as(
"u/test-user",
"test@windmill.dev",
"test-workspace",
&db,
)
.await
.expect("should authenticate the current holder");
assert_eq!(
authed.email, "newcomer@windmill.dev",
"the principal's live address must win over the one supplied"
);
assert!(
!authed.is_admin,
"the new holder must not inherit the previous holder's superadmin"
);
}
/// A disabled member still holds its username in the workspace. Workspace usernames are only
/// unique per workspace, so an unrelated instance superadmin can share it, and falling through to
/// the `password` fallback would run the disabled member's jobs as that superadmin.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_disabled_member_never_resolves_to_a_same_named_superadmin(db: Pool<Postgres>) {
sqlx::query(
"UPDATE usr SET disabled = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-2'",
)
.execute(&db)
.await
.expect("disable the member");
sqlx::query(
"INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
VALUES ('other-superadmin@windmill.dev', 'x', 'password', true, true, 'Other', 'test-user-2')",
)
.execute(&db)
.await
.expect("create the same-named superadmin");
for supplied in ["test2@windmill.dev", "other-superadmin@windmill.dev"] {
let authed =
fetch_authed_from_permissioned_as("u/test-user-2", supplied, "test-workspace", &db)
.await;
assert!(
authed.is_err(),
"a disabled member must not authenticate (supplied {supplied}): {:?}",
authed.map(|a| (a.email, a.is_admin))
);
}
}