The SPKI guard decoded the body with the strict RFC 7468 decoder, which enforces
64-column wrapping, so a legitimate public key wrapped otherwise (or a PKCS#1
`RSA PUBLIC KEY`) was refused where jsonwebtoken would have parsed it. Decode the
body leniently like jsonwebtoken, then require the DER to be a public-key
structure: an SPKI (RSA or EC) or a PKCS#1 RSA public key. Private material
satisfies neither, so the round-13 bypass stays closed. Test adds a one-line
(non-64-column) public key as a positive control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VF3v6LA9399gNphmZaHYG3
jsonwebtoken 8.3 decides public vs private from the PEM label alone and never
inspects the DER, so private material relabelled `PUBLIC KEY` (or a PKCS#1 key
relabelled `RSA PUBLIC KEY`) passed the earlier label check and would be stored,
then served back through the settings response. decoding_key_from_pem now parses
the DER as a SubjectPublicKeyInfo, which only public keys satisfy, before it is
persisted or used. The regression test relabels complete, valid private keys
(EC PKCS#8, RSA PKCS#1) so the guard is what refuses them, not malformed DER.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VF3v6LA9399gNphmZaHYG3
A second way in for a guest (companion to windmill#10929): a JWT the embedding
customer's own backend mints and signs, carried on the app's share link and
verified against a key the workspace admin configured. It needs no
identity-provider round-trip, so it works inside an iframe where popups and
third-party cookies do not. Bearer prefix jwt_guest_, stateless: verified per
request, cached until exp, no token row.
A JWT guest is the same identity as a signed-in guest: no usr row, no password
row, no seat, confined to the one app app_path names. Every guest gate applies:
the plan, the workspace switch (enforced once at the auth door via the sentinel),
the app mode (guest_app_admits), and "no account at all" (has_any_account). The
claim's workspace_id must equal the route's workspace, and a workspace-less route
never accepts it.
Claims honoured: email, workspace_id, app_path, exp (mandatory); nbf/iat
validated when present; the accepted lifetime is capped at 24h. Algorithms:
RS256/384/512, PS256/384/512, ES256/384; HS* is refused. The key is a
per-workspace setting, a PEM public key or a JWKS URL (at most one, a DB CHECK
enforces it), Enterprise-plan gated like the guest switch. The JWKS URL is
validated against private ranges and the fetch is pinned to the validated
address.
Counting: a JWT guest is recorded in guest_activity (once per email, workspace
and day, cached), marked jwt_entry, and not in unique_ext_jwt_token. A first-seen
users.login_guest audit carries the entry kind.
Narrower than jwt_ext_ by design: that key is instance-level and can assert
admin, groups and folders; a guest key is scoped to one workspace and only ever
mints guests. An app-only user a customer routes through jwt_ext_ today is
counted; through a guest JWT they become a free guest, the intended pricing
change, split out as guest_jwt_count in the telemetry so it can be measured.
Changes on the parent branch, additive: ApiAuthed.credential_expiry (a
credential's own expiry when it has no token row); guest_derived_token_constraints
caps on it; guest_session_scopes moved to windmill-api-auth::scopes and
has_any_account to windmill-common::users so the mint and the JWT arm share one
copy; the signed-in mint's login_guest audit now carries entry=idp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VF3v6LA9399gNphmZaHYG3
* fix: record supplied script lock hashes so importers can skip relocking
Creating a script with a caller-supplied lock — a CLI push, a git-sync deploy,
any create carrying a lockfile — stored the lock on `script` but never wrote the
matching `lock_hash(workspace_id, path, hash_script(lock))` row. Only
worker-generated locks did.
`try_skip_relock` treats a missing hash for an imported script as changed, so no
importer of such a script could ever satisfy the skip predicate: every deploy of
it relocked every importer, forever.
The create transaction now records the hash for any lock it accepts, including
the empty one a codebase or a language with no lock generation carries — the
worker writes `hash_script("")` there, and a path going from a real lock to an
empty one has to stop matching what its importers recorded. Only a lock left to
a dependency job is skipped, because that job writes it.
A workspace clone now carries `lock_hash` too, without which every
dependency-map snapshot the clone later recorded held NULL and nothing in it
could ever skip. `dependency_map.imported_lockfile_hash` is deliberately not
copied: it records what an importer resolved against when it was last locked,
the clone runs READ COMMITTED, and a relock landing in the source between the
scripts being cloned and that statement would attach a hash the cloned
importer's lock was never resolved against — a hash older than the cloned
scripts costs one relock, a newer one skips a relock that was needed.
Lock generation is untouched, as is everything a relock does once it runs. The
only behavior that moves is which relocks are skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* fix: narrow to the create-path lock hash
Drop the workspace-clone copy of lock_hash. It sits outside the reported
bug, and its double join over `script` can emit a path twice where two
versions are live, which the unique key on (workspace_id, path) then
rejects, failing the whole fork.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* fix: restore the workspace-clone lock hash copy, guarded against fanout
A path can hold two live versions, and both joins match on path alone, so
the select can emit it four times against a primary key that admits one.
Every such row carries the single hash the path has, so ON CONFLICT DO
NOTHING settles it rather than aborting the fork.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* fix: hash a clone's own locks rather than copying the source's rows
A source row is only as current as the last write to it, and a supplied
lock deployed before this was recorded leaves one naming a lock the path
no longer holds. Copying that into a fork hands an importer a hash it
never resolved against; hashing what the clone holds cannot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* test: pin the lock hash written on a no-op push
Removing that write leaves the assertion with no row, which is the state
a script deployed before this shipped would stay in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* refactor: share one lock hash writer between the create and clone paths
Both wrote the same upsert with different SQL. The existing writers fold
theirs into the statement that writes the lock itself, which is what keeps
the two consistent; these two have nothing to fold it into, so they take a
shared one instead. The clone walks its pages by path rather than listing
them first, dropping a query with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* fix: stream a clone's locks rather than reading them in pages
script.lock is unbounded, so a page of them is bounded only by how many
it holds. Hashing each as it arrives keeps one in memory at a time and
lets the clone site collapse to a single call.
Also states on both writers that they check no access to the workspace
they write, which their callers are the ones to have established.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* fix: make the lock hash writer safe to repeat and free when unchanged
A path given twice in one call would have Postgres reject the whole
statement, so the last hash for each wins. And recording a hash a path
already has cut a row version for nothing on every unchanged sync, which
is the mode the no-op push runs in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix: upgrade argon2 to 0.6 and migrate the password hashing API
* test: pin that an unparseable stored hash reads as a failed login
* chore: update ee-repo-ref to 58738c39ac41d57917bbd9400318704763d997f7
This commit updates the EE repository reference after PR #759 was merged in windmill-ee-private.
Previous ee-repo-ref: 02a89fc4d27e49a494112fa91a8812e3ee4fb8a6
New ee-repo-ref: 58738c39ac41d57917bbd9400318704763d997f7
Automated by sync-ee-ref workflow.
---------
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
* feat: support application default credentials for gcp pub/sub triggers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: address review findings on gcp application default credentials
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: address review nits on gcp application default credentials
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: key the gcp credential-mode permission off the loaded mode
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: gate enabling an ADC gcp trigger on workspace admin
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: lock the gcp trigger row while authorizing a mode change
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: skip admin-only gcp listing when the caller cannot use those credentials
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: update ee-repo-ref to 54bf630681000c8ed87a7067e357118e015123b1
This commit updates the EE repository reference after PR #738 was merged in windmill-ee-private.
Previous ee-repo-ref: 91d0e228a0ad226625278b400c64f96a61404a10
New ee-repo-ref: 54bf630681000c8ed87a7067e357118e015123b1
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
* fix: resolve the head commit of GitHub App repos in the git repo viewer
`get_git_commit_hash` ran `git ls-remote` against the raw resource URL.
A GitHub-App-backed repository stores a tokenless URL, so the probe failed
with "could not read Username" and the viewer never got past its first
step. Resolve the head over the GitHub REST API with a server-side
installation token instead, reusing the lookup the auto-pull poller
already uses for app repos. Non-app repositories keep the ls-remote path.
Also picks up the EE-side allowlist fix that lets the clone hub script
request an installation token.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to 63c67e2a2db198af26a0334f5be14af7d9987eb1
This commit updates the EE repository reference after PR #732 was merged in windmill-ee-private.
Previous ee-repo-ref: 2a260961fa0a9bb5631c17e2f718cb8efb4f9aa2
New ee-repo-ref: 63c67e2a2db198af26a0334f5be14af7d9987eb1
Automated by sync-ee-ref workflow.
* fix: honour the app-repo head lookup's not-app-backed result
`get_app_repo_head_for_autopull` documents `Ok(None)` as "this repo is not
app-backed, use the ls-remote path", which is what the other two callers do.
Fall through to `ls-remote` on `None` instead of turning it into a 500, and
drop the handler's own `is_github_app` read now that the callee's answer is
honoured.
Also bumps ee-repo-ref to pick up route-safe ref handling in that lookup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: serve GitHub App repositories as an archive instead of a token
The viewer's clone script asked the server for an installation token and put
it in the clone URL. That token is installation-wide and carries the App's
full permissions, so minting one requires a workspace admin, and the viewer
was therefore admin-only for app-backed repositories.
The server now streams a tarball of the commit instead, authorized by read
access to the git_repository resource, so no GitHub credential reaches the
job.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: run delegate_to_git_repo playbooks from GitHub App repositories
An Ansible job's runnable_path is the user's own script, which no entry in
the git-sync script allowlist can match, so `delegate_to_git_repo` could
never obtain a token for an app-backed repo. It also gave up entirely on
agent workers, whose connection has no database to mint one from.
A playbook run only reads a working tree: the clone is followed by one
rev-parse for a log line, and nothing after that touches git. So take the
same archive route the viewer uses, extracting the commit's tarball into the
job's repository directory. No GitHub credential reaches the worker, and
agent workers work because the route is HTTP.
Archive entries are joined onto the target by hand so a crafted archive
cannot write outside the job directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: drop the now-immutable secret_url binding
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: point the repo viewer at the archive-based clone script
hub/28905 reads app-backed repositories through the server's archive route
instead of minting an installation token, which the backend in this release
no longer grants it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: stream repository archives to disk rather than into memory
The archive download went through `AuthedClient::get`, whose client caps a
request at 20 seconds and whose response was then buffered whole. A
repository is arbitrarily large, so that cut off slow downloads and put every
job on the worker at risk of running the process out of memory.
Add `get_streaming`, the read counterpart to the streaming upload path, and
write the response out chunk by chunk.
Extraction now creates each entry's parent directory: a tar carries directory
entries only by convention, and the traversal guard now has tests, one of
which caught the missing parent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: require admin to read an app-backed repository
A `git_repository` resource names the repository rather than holding a
credential for it, so read access to one authorizes nothing: anyone who can
write a resource path can point one at any repository the GitHub App
installation reaches, then read their own resource. The head lookup now
requires admin for app-backed repos, matching the archive route and the
repository picker, which already limits itself to workspaces where the
caller is an admin.
Repos that aren't app-backed are untouched and stay open to any reader.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: describe the repo viewer's hub script as it stands
The file read as a patch waiting to be applied, against a hub version two
releases stale. Describe what the published script does, including the
archive route app-backed repositories now take.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: run the archive fetch under the job poller, off the job directory
Three defects in the delegate path's fetch:
The download and extraction ran outside the job poller that the git clone
paths go through, so a cancelled or timed-out run kept streaming and
extracting an arbitrarily large repository while holding the worker. There is
no wall-clock bound on the download itself, by design, which is exactly why
it needs the poller.
The archive was written to a fixed name inside the job directory, where
`create_file_resources` has already laid down the run's own files at paths
the playbook chooses. A run naming a file `repo_archive.tar.gz` had it
truncated and then deleted. It goes to a per-job temp path now.
Link entries were unpacked with their target unchecked. `Entry::unpack`
writes the link verbatim, so a link out of the tree plus a later entry
descending through it writes wherever it points. Targets now face the same
containment check as entry paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: keep repo symlinks, refuse only writes that go through them
The link check rejected any target containing `..`, which is ordinary in a
repository — `docs/x -> ../README.md` resolves inside the tree, and a git
checkout keeps it. Rejecting it failed the whole extraction for repositories
the clone path handles, and app-backed repos have no clone path to fall back
to.
Targets are preserved as git preserves them. What would let one escape is a
later entry written at or underneath the link, so that is what is refused.
Extraction also polls an abort flag now: a `spawn_blocking` task outlives the
join handle its caller drops, so a cancelled job left it unpacking in the
background.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: refuse hard links in a repository archive
Leaving link targets verbatim is right for symlinks — git checks them out
that way, and an escape needs a second entry descending through the link,
which is refused. A hard link is not like that: unpacking one creates it
against a target resolved there and then, so an escaping target is useful on
its own.
No git tree can express a hard link, so an archive carrying one did not come
from a repository. Refuse it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: update ee-repo-ref to 21f79bbbd39ae89665d1a89738630978616aa309
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: update ee-repo-ref to 37695a769b25d16b34107eedc1076793a8b388c8
This commit updates the EE repository reference after PR #737 was merged in windmill-ee-private.
Previous ee-repo-ref: 21f79bbbd39ae89665d1a89738630978616aa309
New ee-repo-ref: 37695a769b25d16b34107eedc1076793a8b388c8
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
* refactor: combine the per-minute counters onto one shared helper
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep dashmap in windmill-store for the azure devops token cache
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: name the sweep counter for what it counts
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: design for where the npm proxy keeps cached registry content
* feat(npm-proxy): keep package files on disk and in the object store
* fix(npm-proxy): degrade when the cache is unwritable, stream and bound it
* fix(npm-proxy): keep the happy path off the heap and isolate pull scratch
* fix(npm-proxy): bound the upload, verify pulled trees, keep oversized manifests
* fix(npm-proxy): protect live scratch, bound uploads by parts, refuse traversals
* fix: let the blocking unpack own the scratch it writes into
* fix: replace a cache directory that is not a package instead of deferring to it
* fix: evict by moving a package off the live path, not by deleting it in place
* fix: leave a package the sweep cannot move rather than deleting it in place
* fix: take one registry snapshot through a cache miss
* fix: stamp a pulled package as used so the sweep does not evict it first
* feat(ata): prefer the npm proxy when the instance configures a registry
* fix(npm-proxy): cap tarball extraction and stop pinning a failed config probe
* fix(npm-proxy): keep large packages cacheable by using a single shard
* fix(npm-proxy): cache the archive so a large package is served, not refused
* fix(npm-proxy): read archives off the runtime, keeping only what types need
* fix(npm-proxy): charge a retained entry for what it allocates, not its bytes
* fix(npm-proxy): size retention for real packages and read the manifest back
* fix(npm-proxy): charge path bytes and pin the manifest read-back
* fix(npm-proxy): stop retaining past the budget instead of refusing the package
* feat(raw-apps): route in-browser npm installs through the npm proxy
* fix(npm-proxy): follow npm range semantics and cache packuments
* fix(npm-proxy): bound the packument cache by bytes and stream tarballs
* fix(npm-proxy): keep a v-prefixed pin exact and read the tarball once
* chore(raw-apps): bump the ui_builder pin to the npm-proxy installer
* feat: version resource values with history, diff and restore
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: record resource versions in a trigger so direct writes are covered
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: show the selected version's value and tighten history write access
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf: gate resource version recording in trigger WHEN clauses
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: clear a resource's past versions, and address review nits
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: restore the displayed version and keep author attribution on pooled writes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: scope history to the selected workspace and gate clearing on ownership
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: gate restore on write access and clearing on the signed-in workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(frontend): share the version-history row between script and resource drawers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf: trim resource version history in the monitor sweep, not on write
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): match the script versions drawer shell for resource history
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf(frontend): highlight version values instead of mounting monaco
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): match the script drawer's code preview presentation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: rank version trim in one windowed pass instead of a correlated delete
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(frontend): treat the newest version as current by position
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf: gate the resource version trim to an hourly sweep
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: unnest the version row action and correct the trim cadence docs
* perf: cap the history listing and use sets for reference lookup
* feat: warn when a resource is written more than 60 times a minute
* fix: lower the resource write advisory to 20 per minute
* fix: discard stale history loads and never diff against an unread value
* fix: correct the write advisory boundary and document the eviction lock
* fix: read history and the live value from one snapshot
* refactor: read the drawer's diff baseline from versions, not the live resource
* fix: open the history drawer with no version selected
* fix: disarm the clear confirmation and clear the pane when the selection moves
* fix: explain the missing diff and drop a guard that can no longer fire
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>