refactor: derive an app's address from its principal instead of storing it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYWxvwmteVyzcWenJfbnzs
This commit is contained in:
Ruben Fiszel
2026-09-12 11:46:15 +02:00
co-authored by Claude Opus 5
parent 49f8a253fd
commit 673e3f26e7
12 changed files with 158 additions and 185 deletions
+21 -37
View File
@@ -1,47 +1,31 @@
# Removing `policy.on_behalf_of_email`
# `policy.on_behalf_of_email`
An app's identity is `policy.on_behalf_of`; the address beside it is a function of that
principal. `on_behalf_of_email` is no longer required — a policy carrying only a principal
executes, deriving the address — but it is still written on every save, and that is the only
thing holding it in place.
principal. The policy stores only the principal. The address is derived wherever it is needed:
at execution (`get_on_behalf_of`, `$ctx:author`) and on the reads that return a deployed policy
(`attach_on_behalf_of_email`), where it is a response-only field the write path ignores.
## The gate
**This cannot ship until `MIN_KEEP_ALIVE_VERSION` (`windmill-common/src/min_version.rs`) has
passed 1.810.**
`get_on_behalf_of` gained its derive-when-absent fallback in **1.810**. Every replica before that
*requires* the key and errors outright without it, so it would 400 every anonymous, publisher and
guest app saved by a newer one. A rolling deploy runs both versions at once, which is why the
write stays until no replica older than 1.810 can be live — in practice, once
`MIN_KEEP_ALIVE_VERSION` (`windmill-common/src/min_version.rs`) has passed it.
*requires* the key and errors outright without it, so a rolling deploy that runs both would 400
every anonymous, publisher and guest app the moment one of them saves. The write is what holds
the key in place, so stopping it is only safe once no replica older than 1.810 can be live.
There is no `MIN_VERSION_*` constant for this and it does not need one: those exist to gate
behavior at runtime or to trip the build when a constraint expires, and nothing here does either.
The key is written unconditionally, so no replica ever meets its absence until someone follows
the steps below.
`MIN_KEEP_ALIVE_VERSION` is 1.420.0, and a compile-time rule keeps it at least 50 minor versions
behind current, so it cannot reach 1.810 until releases pass ~1.860.
## Step 1 — stop writing the key
There is no `MIN_VERSION_*` constant for this and it does not need one: those gate behavior at
runtime or trip the build when a constraint expires, and nothing here does either.
- `stored_on_behalf_of_email` in `windmill-api/src/apps.rs`, and the `create_app` /
`update_app_internal` call sites that store what it returns.
- The CLI and frontend workspace-deploy paths (`cli/windmill-utils-internal/src/deploy.ts`,
`frontend/src/lib/utils_workspace_deploy.ts`). These send the address *instead of* a principal
for a cross-workspace deploy, which is the one case where it is the only identity available —
so this is "stop sending it once the target resolves a principal itself", not a deletion.
## Why the address is not derived on a draft read
Policies written before this keep their key and keep being read from it; they agree with their
principal, so nothing has to strip them.
## Step 2 — drop the field
Remove `on_behalf_of_email` from `Policy` and the fallback in `get_on_behalf_of`, which then
always derives. Optionally strip the key from stored policies.
This can ship with step 1. It is written separately because step 1 alone is revertible without
touching stored data or the response schema, and because the gate above is what makes either
step safe — nothing about step 2 needs its own waiting period.
## Why the address is not derived on read
Read paths return the stored address verbatim rather than recomputing it. Deriving on read means
resolving a principal that, for a draft, is caller-controlled — which turns the read into an
oracle for addresses the caller cannot otherwise see, and leaves a principal that resolves to
nobody with no address at all. Both were live defects while the read paths did derive.
A deployed policy's principal was written by the server, from an identity `resolve_on_behalf_of`
validated. A draft's is whatever its editor last typed. Deriving one would answer "which address
is `u/x`?" for any principal a caller cares to name — including a superadmin outside their own
workspaces, whom `resolve_username_to_email` reaches instance-wide. That was a live defect while
every read derived, and is why the derivation is attached per read path rather than to `Policy`
itself.