From 539ca6a4fe8767fbcd3cbefedd5dd0c35091fdfd Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 19 Aug 2026 20:01:23 +0200 Subject: [PATCH 1/5] unbreak the scratch-dir permission guards on macOS (#10766) * fix(agents): unbreak the scratch-dir guards on macOS Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): fold case in the scratch-guard exclusion list Co-Authored-By: Claude Opus 5 (1M context) * fix(agents): match the MCP cache roots exactly, not by prefix Co-Authored-By: Claude Opus 5 (1M context) * test(agents): pin the MCP cache class on the fileops guard Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .claude/hooks/allow-fileops-in-tmp.sh | 42 ++++++++------ .claude/hooks/guard-rm-outside-tmp.sh | 54 +++++++++++------- .claude/hooks/lib-guarded-verb.sh | 81 ++++++++++++++++++++++++--- .claude/hooks/test-hooks.sh | 21 +++++++ AGENTS.md | 18 +++--- 5 files changed, 163 insertions(+), 53 deletions(-) diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh index 87ce6541aa..b6ab85fe85 100755 --- a/.claude/hooks/allow-fileops-in-tmp.sh +++ b/.claude/hooks/allow-fileops-in-tmp.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # PreToolUse allowance for scratch file ops: auto-allow `mkdir` / `cp` / `mv` / `touch` / # `chmod` whose every path operand resolves inside one of the roots `path_class` recognizes — -# under /tmp, or inside a git working tree under $HOME — and `tar` / `unzip` confined to /tmp. +# under /tmp, inside a git working tree under $HOME, or in an MCP browser cache — and +# `tar` / `unzip` confined to /tmp. # Anything else makes no decision (exit 0) and falls back to the normal permission flow, except # for `mv` and `chmod`: those get an explicit `ask`, the only prompt they get (see # lib-guarded-verb.sh). @@ -28,9 +29,10 @@ # file there has never prompted, and moving or chmod-ing one is not the graver act. # # Deny-by-default tokenizing, in the same spirit as guard-rm-outside-tmp.sh: every path token -# must consist only of alphanumerics and `. _ / -`. That set contains none of the characters +# must consist only of alphanumerics and `. _ / -`, the one exception being the leading `~/` or +# `$HOME/` that `expand_home_prefix` rewrites first. That set contains none of the characters # bash uses for quoting, expansion, or command separation ($ ` ~ { } ( ) ' " \ ; & | < >), nor -# any glob character, so all of those forms fail by construction. `realpath -m` then resolves +# any glob character, so all of those forms fail by construction. `canon_path` then resolves # `..` and existing symlinks, so `/tmp/link` pointing at /etc/passwd is caught. # # `tar` and `unzip` keep the stricter rule — /tmp only, and absolute operands only — because @@ -52,7 +54,8 @@ # extracts. The archive itself must be under /tmp to get here, so this is a hazard only for # archives fetched from an untrusted source into the scratch dir. # -# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env. +# Assumes `jq`. Path canonicalization goes through `canon_path`, which covers both the Linux dev +# env and macOS; with neither backend available it proves nothing and every op falls back. set -uo pipefail . "${BASH_SOURCE[0]%/*}/lib-guarded-verb.sh" @@ -90,16 +93,17 @@ literal_path() { # resolving a relative one against the tracked working directory. Fails, printing nothing, # when the token is unsafe to reason about or lands outside every root. operand_class() { - local t="$1" canon alt cls alt_cls="" + local t canon alt cls alt_cls="" + t=$(expand_home_prefix "$1") literal_path "$t" || return 1 case "$t" in - /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; + /*) canon=$(canon_path "$t") ;; *) # A `cd` may fail at runtime and leave the command where it started, so a relative # operand has to land in the same root either way. [ -n "$seg_cwd" ] || return 1 - canon=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null) + canon=$(canon_path "$seg_cwd/$t") if [ -n "$alt_cwd" ]; then - alt=$(realpath -m -- "$alt_cwd/$t" 2>/dev/null) + alt=$(canon_path "$alt_cwd/$t") [ -n "$alt" ] || return 1 alt_cls=$(path_class "$alt") || return 1 fi @@ -116,13 +120,14 @@ operand_class() { # 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. The archive # parser's stricter check; everything else goes through operand_class. under_tmp() { - local t="$1" canon + local t canon + t=$(expand_home_prefix "$1") literal_path "$t" || return 1 case "$t" in /*) ;; *) return 1 ;; esac - canon=$(realpath -m -- "$t" 2>/dev/null) + canon=$(canon_path "$t") [ -n "$canon" ] || return 1 # /tmp itself is never a target — only paths strictly inside it. - case "$canon" in /tmp/?*) return 0 ;; esac + case "$canon" in "$TMP_ROOT"/?*) return 0 ;; esac return 1 } @@ -191,7 +196,7 @@ check_archive_segment() { # Proves one `mkdir` / `cp` / `mv` / `touch` / `chmod` segment ($1 = the verb), whose tokens # are in SEG_TOKS. check_fileops_segment() { - local verb="$1" takes_mode ok_opts t cls resolved seen_class="" + local verb="$1" takes_mode ok_opts t cls resolved dest seen_class="" local path_operand=0 seen_mode=0 end_opts=0 i=1 rel_operand=0 local -a ops=() # Options are an allowlist per command, so anything that changes how symlinks are followed @@ -233,13 +238,15 @@ check_fileops_segment() { continue fi - resolved=$(operand_class "$t") || defer "\`$t\` is outside /tmp and not inside a git checkout in \$HOME" + resolved=$(operand_class "$t") || defer "\`$t\` is outside /tmp and the MCP caches, and not inside a git checkout in \$HOME" cls="${resolved%%$'\n'*}" # Every operand of one operation stays in one root: see the exfiltration note above. [ -n "$seen_class" ] && [ "$cls" != "$seen_class" ] && defer "\`$t\` puts this $verb across two roots" seen_class="$cls" ops+=("${resolved#*$'\n'}") - case "$t" in /*) ;; *) rel_operand=1 ;; esac + # Against the expanded token, since `~/a` is cwd-independent and only reads as relative + # before `expand_home_prefix` has run. + case "$(expand_home_prefix "$t")" in /*) ;; *) rel_operand=1 ;; esac path_operand=1 done @@ -260,8 +267,11 @@ check_fileops_segment() { # does not exist, while the one it actually ran in is a directory full of symlinks. [ -n "$alt_cwd" ] && [ "$rel_operand" = 1 ] \ && defer "a relative operand after a \`cd\` lands in one of two directories" - [ -d "${ops[-1]}" ] \ - && defer "\`${ops[-1]}\` already exists as a directory, so this $verb writes a path it does not name" + # Index arithmetic rather than `${ops[-1]}`: macOS ships bash 3.2, where a negative + # subscript is a fatal error and would abort the guard mid-decision. + dest="${ops[$((${#ops[@]} - 1))]}" + [ -d "$dest" ] \ + && defer "\`$dest\` already exists as a directory, so this $verb writes a path it does not name" ;; esac } diff --git a/.claude/hooks/guard-rm-outside-tmp.sh b/.claude/hooks/guard-rm-outside-tmp.sh index 4d253ffc62..71657f031c 100755 --- a/.claude/hooks/guard-rm-outside-tmp.sh +++ b/.claude/hooks/guard-rm-outside-tmp.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # PreToolUse guard for `rm`: auto-allow deletes whose every operand is a whitelisted target — -# under /tmp, or inside a git working tree located in $HOME (a version-controlled project dir). +# under /tmp, inside a git working tree located in $HOME (a version-controlled project dir), or +# in one of the browser-automation caches the MCP servers rebuild on demand. # Any other command that runs `rm` gets an explicit `ask`, which is the ordinary permission # prompt and the only one `rm` gets (see lib-guarded-verb.sh); a command that runs no `rm` at # all makes no decision (exit 0). @@ -14,20 +15,22 @@ # it would turn a trailing `rm -f /tmp/x` into a way to auto-approve anything. # # Deny-by-default: every token must consist only of a safe character set (alphanumerics, -# `. _ / -` and glob chars `* ? [ ]`). That set contains none of the characters bash uses for +# `. _ / -` and glob chars `* ? [ ]`), the one exception being the leading `~/` or `$HOME/` that +# `expand_home_prefix` rewrites first. That set contains none of the characters bash uses for # quoting, expansion, or command separation ($ ` ~ { } ( ) ' " \ ; & | < >), so those forms -# fail by construction rather than needing to be enumerated. `realpath -m` then resolves `..` +# fail by construction rather than needing to be enumerated. `canon_path` then resolves `..` # and existing symlinks (so a symlink out of the allowed roots is caught), and a wildcard in a # non-final path segment is refused because it can expand through a symlink realpath can't see. # -# Which targets those two roots cover, and the tradeoff they rest on, is `path_class` in -# lib-guarded-verb.sh. Globs auto-allow only under /tmp — elsewhere their expansion -# could reach `.git` or a dotfile the literal checks never see. Relative operands resolve +# Which targets those roots cover, and the tradeoff they rest on, is `path_class` in +# lib-guarded-verb.sh. Globs auto-allow only under /tmp and the MCP caches — elsewhere their +# expansion could reach `.git` or a dotfile the literal checks never see. Relative operands resolve # against the working directory the command runs from, which a `cd` in an earlier segment # moves; once a `cd` is one this guard cannot resolve, that directory is unknown and a # relative operand can no longer be proved. # -# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env. +# Assumes `jq`. Path canonicalization goes through `canon_path`, which covers both the Linux dev +# env and macOS; with neither backend available it proves nothing and every delete prompts. set -uo pipefail . "${BASH_SOURCE[0]%/*}/lib-guarded-verb.sh" @@ -51,13 +54,15 @@ has_substitution "$cmd" && defer "command substitution in the command line" # operands against $seg_cwd. Returns only once every operand is an auto-allowable target; # anything it cannot prove defers instead. check_rm_segment() { - local i=1 t canon candidates had_operand=0 end_opts=0 + local i=1 t p canon candidates had_operand=0 end_opts=0 while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do t="${SEG_TOKS[$i]}" i=$((i + 1)) + # Messages keep the token as written; everything downstream reasons about the expansion. + p=$(expand_home_prefix "$t") # Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm` # can't slip past): any character outside the safe set makes it unsafe to reason about. - [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/*?[]-')" ] && defer "unsafe characters in \`$t\`" + [ -n "$(printf '%s' "$p" | tr -d 'A-Za-z0-9._/*?[]-')" ] && defer "unsafe characters in \`$t\`" # A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name` # into an operand — never a real option, so defer. case "$t" in -*[*?[]*) defer "glob inside the option \`$t\`" ;; esac @@ -73,25 +78,34 @@ check_rm_segment() { had_operand=1 # No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink # realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine. - case "$t" in */*) case "${t%/*}" in *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac + case "$p" in */*) case "${p%/*}" in *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac # A relative operand has as many candidate paths as the command has candidate working # directories, and every one of them has to be auto-allowable: a `cd` that fails at runtime # leaves the delete running in the directory it started in. - case "$t" in - /*) candidates=$(realpath -m -- "$t" 2>/dev/null) ;; + case "$p" in + /*) candidates=$(canon_path "$p") ;; *) [ -n "$seg_cwd" ] || defer "\`$t\` is relative to a working directory this guard cannot pin down" - candidates=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null) + candidates=$(canon_path "$seg_cwd/$p") [ -n "$alt_cwd" ] && candidates="$candidates -$(realpath -m -- "$alt_cwd/$t" 2>/dev/null)" +$(canon_path "$alt_cwd/$p")" ;; esac while IFS= read -r canon; do [ -n "$canon" ] || defer "cannot resolve \`$t\`" - # A glob may auto-allow only under /tmp, where everything is deletable. Elsewhere its - # expansion could match `.git`, a dotfile like `.*`, or a nested checkout root that the - # literal-path checks never see — so require literal operands in git repos. - case "$t" in *[*?[]*) case "$canon" in /tmp/?*) ;; *) defer "glob \`$t\` is outside /tmp" ;; esac ;; esac - path_class "$canon" >/dev/null || defer "\`$canon\` is outside /tmp and not inside a git checkout in \$HOME" + # A glob may auto-allow only in a root where everything is deletable — /tmp and the MCP + # caches, both of which `rm -rf ` already clears wholesale, so matching inside one + # grants nothing more. In a checkout the expansion could reach `.git`, a dotfile like + # `.*`, or a nested checkout root that the literal-path checks never see, so require + # literal operands there. + case "$p" in + *[*?[]*) + case "$(path_class "$canon")" in + tmp | mcp-cache) ;; + *) defer "glob \`$t\` is outside /tmp and the MCP caches" ;; + esac + ;; + esac + path_class "$canon" >/dev/null || defer "\`$canon\` is outside /tmp and the MCP caches, and not inside a git checkout in \$HOME" done <<< "$candidates" done [ "$had_operand" = 1 ] || defer "no operand" @@ -136,5 +150,5 @@ for seg in "${SEGMENTS[@]}"; do done [ "$proved" = 1 ] || exit 0 -[ "$only_ours" = 1 ] && decide allow 'rm operands are under /tmp or inside a git checkout in $HOME' +[ "$only_ours" = 1 ] && decide allow 'rm operands are under /tmp, in an MCP cache, or inside a git checkout in $HOME' exit 0 diff --git a/.claude/hooks/lib-guarded-verb.sh b/.claude/hooks/lib-guarded-verb.sh index 6ef76a5466..a1c7b79a9d 100644 --- a/.claude/hooks/lib-guarded-verb.sh +++ b/.claude/hooks/lib-guarded-verb.sh @@ -10,6 +10,45 @@ # expand a glob operand against the filesystem. Neither guard relies on pathname expansion. set -f +# Canonical absolute path: `..` and existing symlinks resolved, missing trailing components +# allowed. Resolving symlinks is the load-bearing half — a lexical normalizer would collapse +# `/tmp/link/..` without seeing where `link` points, and let an operand out of its root. +# GNU `realpath -m` is exactly this; BSD realpath on macOS has no `-m` and exits on it, which +# would leave every operand unresolvable and every delete prompting, so fall back to python3's +# os.path.realpath, which has the same semantics. Trying rather than probing keeps the cost off +# the Bash calls that never reach a path check — most of them. With neither available this +# prints nothing, and every caller treats that as "cannot prove". +canon_path() { + local out + out=$(realpath -m -- "$1" 2>/dev/null) && [ -n "$out" ] && { printf '%s' "$out"; return; } + python3 -c 'import os,sys;sys.stdout.write(os.path.realpath(sys.argv[1]))' "$1" 2>/dev/null +} + +# The roots every class is anchored to, in the form a canonicalized operand comes back in. On +# macOS /tmp is a symlink to /private/tmp, so a resolved scratch path never starts with `/tmp` +# and matching the literal would put every scratch path outside every class. Both exist, so +# `cd -P` resolves them without the process canon_path would spawn on every sourcing. +TMP_ROOT=$(cd -P -- /tmp 2>/dev/null && pwd) +[ -n "$TMP_ROOT" ] || TMP_ROOT=/tmp +HOME_ROOT="" +[ -n "${HOME:-}" ] && HOME_ROOT=$(cd -P -- "$HOME" 2>/dev/null && pwd) + +# Prints ($1) with a leading `~/`, `$HOME/` or `${HOME}/` — and those three words on +# their own — replaced by the home directory, so the ordinary spelling of a path outside every +# checkout can still be proved. Only that prefix and only those spellings: `~user/` names another +# account, and any other `$` is an expansion nothing here can evaluate, so both stay in the token +# and fail the caller's charset check. A quoted token keeps its quotes and fails there too. +expand_home_prefix() { + [ -n "$HOME_ROOT" ] || { printf '%s' "$1"; return; } + case "$1" in + '~' | '$HOME' | '${HOME}') printf '%s' "$HOME_ROOT" ;; + '~/'*) printf '%s/%s' "$HOME_ROOT" "${1#'~/'}" ;; + '$HOME/'*) printf '%s/%s' "$HOME_ROOT" "${1#'$HOME/'}" ;; + '${HOME}/'*) printf '%s/%s' "$HOME_ROOT" "${1#'${HOME}/'}" ;; + *) printf '%s' "$1" ;; + esac +} + # 0 iff ($1) starts with a command that only reads its input. An allowlist, because the # opposite — naming the shells to avoid — would have to be complete: an unlisted one (`ash`, # `rbash`, `busybox sh`) executes the body while the guard calls it data. Unrecognized here only @@ -193,15 +232,16 @@ apply_cd() { local cwd="$1" t shift [ "$#" -eq 1 ] || return 1 - t="$1" + t=$(expand_home_prefix "$1") [ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1 # Absolute only. A relative destination is not `$cwd/$t`: the shell searches $CDPATH first, # so `cd ssh` may land in /etc/ssh, and this cannot see the caller's $CDPATH to rule it out. case "$t" in /*) ;; *) return 1 ;; esac - realpath -m -- "$t" 2>/dev/null + canon_path "$t" } -# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, or +# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, +# `mcp-cache` for one in a browser-automation cache the MCP servers rebuild on demand, or # `repo:` for one strictly inside the git working tree at , itself under $HOME. # Fails, printing nothing, for anything else — those are the only roots the guards are willing # to touch unprompted. The root is part of the class so that a caller pairing two operands can @@ -224,19 +264,42 @@ apply_cd() { # `credentials.json`, `.secret*` — because a `cp` or `mv` that is auto-allowed on both ends # would rename one out of those globs and hand back through `Read` exactly what they deny. path_class() { - local canon="$1" d root="" - case "$canon" in + local canon="$1" d root="" folded + # Matched against a lowercased copy: APFS is case-insensitive by default, so `.GIT` and `.git` + # are one directory, and a case-sensitive list would leave the history — and these guards' own + # settings — one keystroke from an auto-allowed delete. On a case-sensitive volume a genuinely + # distinct `.GIT/` over-matches, which costs a prompt and nothing else. `tr` and not `${x,,}`: + # macOS ships bash 3.2, which has no case-folding expansion. + folded=$(printf '%s' "$canon" | tr 'A-Z' 'a-z') + case "$folded" in *"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;; *"/.env" | *"/.env."*) return 1 ;; *"/secrets" | *"/secrets/"*) return 1 ;; *.pem | *.key | *"/credentials.json") return 1 ;; *"/.secret"* | *.secret | *.secrets) return 1 ;; esac - case "$canon" in /tmp/?*) printf 'tmp'; return 0 ;; esac - [ -n "${HOME:-}" ] || return 1 - case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac + case "$canon" in "$TMP_ROOT"/?*) printf 'tmp'; return 0 ;; esac + [ -n "$HOME_ROOT" ] || return 1 + # The Playwright MCP servers download browsers into `ms-playwright` and open a throwaway + # profile per session under `ms-playwright-mcp`; nothing prunes either, so they grow without + # bound (10G here) and clearing one costs a re-download and nothing else. They sit outside + # every checkout, where no other class reaches them. Matched including the root itself, + # unlike the repo class, because wiping the whole directory is the point. + # Each root is named exactly and then again with `/*`, rather than one trailing `*`: a case + # pattern's `*` spans the `-` as well, which would put a sibling somebody created themselves — + # `ms-playwright-mcp-backup` — in a class that auto-allows deleting it. + case "$canon" in + "$HOME_ROOT"/Library/Caches/ms-playwright | "$HOME_ROOT"/Library/Caches/ms-playwright/* \ + | "$HOME_ROOT"/Library/Caches/ms-playwright-mcp | "$HOME_ROOT"/Library/Caches/ms-playwright-mcp/* \ + | "$HOME_ROOT"/.cache/ms-playwright | "$HOME_ROOT"/.cache/ms-playwright/* \ + | "$HOME_ROOT"/.cache/ms-playwright-mcp | "$HOME_ROOT"/.cache/ms-playwright-mcp/*) + printf 'mcp-cache' + return 0 + ;; + esac + case "$canon" in "$HOME_ROOT"/?*) ;; *) return 1 ;; esac d="$canon" - while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do + while [ "$d" != "/" ] && [ "$d" != "$HOME_ROOT" ]; do [ -e "$d/.git" ] && { root="$d"; break; } d=$(dirname "$d") done diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh index 01ed237baf..1263dbc669 100644 --- a/.claude/hooks/test-hooks.sh +++ b/.claude/hooks/test-hooks.sh @@ -58,6 +58,20 @@ run $G ask "rm -rf $CWD/.env.local" run $G $ROOT_SOLO "rm -rf $CWD" run $G ask "rm -rf $CWD/*" run $G ask "rm -rf /etc/passwd" +# The MCP caches are the one allowed root outside /tmp and the checkouts, and `~/` and `$HOME/` +# the one expansion the charset check tolerates — so the row that matters is the one proving the +# prefix does not carry anything else along with it. +run $G allow "rm -rf ~/Library/Caches/ms-playwright-mcp" +run $G allow "rm -rf ~/.cache/ms-playwright-mcp" # the Linux spelling of the same root +run $G allow 'rm -rf $HOME/Library/Caches/ms-playwright-mcp/mcp-chrome-*' +run $G ask "rm -rf ~/.cache/ms-playwright-mcp-backup" # a sibling, not the cache +run $G ask "rm -rf ~/not-a-git-tree" +# The exclusion list is the whole protection for these paths — the `repo:` class allows deletes +# everywhere else in a checkout — and macOS resolves `.GIT` to `.git`, so the fold is what keeps +# the list from failing open there. Pattern-matched, so the row holds on either platform. +run $G ask "rm -rf $CWD/.GIT" +run $G ask "rm $CWD/.CLAUDE/settings.json" +run $G ask "rm -rf $CWD/backend/.ENV" run $G ask 'rm -rf "$HOME/x"' run $G ask "rm -rf /tmp/../$OUT" run $G none "ls /tmp && rm -rf /tmp/x" # proved delete, unexamined neighbour @@ -170,6 +184,13 @@ run $A ask "env -i A=1 B=2 C=3 D=4 E=5 F=6 mv /tmp/a /etc" run $A none "cp $CWD/AGENTS.md /tmp/a" run $A none "tar -xzf /tmp/a.tar.gz -C $OUT" run $A none "cargo build" +run $A ask "chmod -R 777 $CWD/.GIT" +run $A allow "chmod -R 755 ~/Library/Caches/ms-playwright-mcp" +run $A ask "chmod -R 777 ~/Library/Caches/ms-playwright-mcp-backup" +# The home prefix reaches this guard through `operand_class`, not the rm guard's own resolver. +case "$CWD" in + "$HOME"/*) run $A allow "mv ~${CWD#"$HOME"}/frontend/a.ts ~${CWD#"$HOME"}/frontend/b.ts" ;; +esac run $A none "mkdir -p /tmp/x; mv /tmp/a /tmp/x; chmod 755 /tmp/x" # one write per line run $A none "$(printf 'mv /tmp/a /tmp/b\nchmod 755 /tmp/b')" diff --git a/AGENTS.md b/AGENTS.md index 1da4be6c98..47919cfeda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,14 +148,16 @@ $NAV --root backend callees "X" # what does X call? - **Scratch stays outside the checkout.** Temp scripts, data dumps, cache backups and screenshots go in the session scratch directory or `/tmp`, so nothing temporary can end up committed. Write the paths in `rm`/`mv`/`cp` out literally: a PreToolUse hook proves each - operand, and auto-allows deletes, moves, copies and mode changes under `/tmp` or inside a git - checkout under `$HOME`, as long as one operation stays within a single root — a sibling - checkout is a root of its own (`tar` and `unzip` stay `/tmp`-only). Chain deletes freely, each - proved on its own operands, but keep writes to one per line, name the destination rather than - a directory to drop it in, and put anything else on its own line: a command the hook does not - prove drops the whole line back to the normal permission flow. A - quoted or `$VAR` operand, a `~`, a redirect, a `$(…)`, a relative `cd`, or a wrapper like - `xargs rm` cannot be proved, and that deferral is what turns a cleanup into a prompt. + operand, and auto-allows deletes, moves, copies and mode changes under `/tmp`, inside a git + checkout under `$HOME`, or in the Playwright MCP browser caches (`~/Library/Caches/ms-playwright` + and `ms-playwright-mcp`, `~/.cache/…` on Linux), as long as one operation stays within a single + root — a sibling checkout is a root of its own (`tar` and `unzip` stay `/tmp`-only). Chain + deletes freely, each proved on its own operands, but keep writes to one per line, name the + destination rather than a directory to drop it in, and put anything else on its own line: a + command the hook does not prove drops the whole line back to the normal permission flow. A + leading `~/` or `$HOME/` is expanded and proved; a quoted operand, any other `$VAR`, a redirect, + a `$(…)`, a relative `cd`, or a wrapper like `xargs rm` cannot be, and that deferral is what + turns a cleanup into a prompt. - **Change files with Edit/Write, not the shell.** `sed -i`, `cat > file <<'EOF'` and inline `python3 - <<'PY'` scripts put an edit through the PreToolUse guards and the permission classifier, which match `Bash` and nothing else, so a routine edit arrives as a prompt. Bash From 5fb145c79ff74e7447a699c6c70c876ee69fad43 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 19 Aug 2026 20:03:14 +0200 Subject: [PATCH 2/5] feat: guided setup wizard for data tables on Cloud (#10584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(frontend): guided setup wizard for data tables On Cloud a data table cannot use the Windmill instance database, so a new workspace hit a dead end: an alert telling the user to go find a PostgreSQL resource somewhere else. Setting one up meant three disconnected places, and the connection could only be tested after the config had already been saved. Adds a three-step wizard (choose a database -> set it up -> name it) reached from the data tables settings page: - Supabase: signs in via the existing supabase_wizard OAuth client and creates the project from inside Windmill. Because db_pass is an input to project creation, Windmill sets the password and the user never visits a dashboard. - Your own database: picks an existing postgresql resource, or adds one with a connection string through the form that already supports it. - Windmill database: hands back to the inline row editor, since instance databases are provisioned by a superadmin. Verifying access is no longer a step the user takes: Continue runs the check and passing it is what advances the wizard, so a database that cannot create tables never reaches the workspace config. Co-Authored-By: Claude Opus 5 (1M context) * chore: pin ee-repo-ref to the Supabase provisioning endpoints Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): do not claim the database is ready when its check failed Co-Authored-By: Claude Opus 5 (1M context) * fix: address review findings on the data table wizard - The Supabase create branch advanced on `provisioning === 4` without consulting the check it had just run, so a role that cannot create tables could reach Finish. It now blocks and offers Try again. - Retrying no longer mints a fresh secret variable + resource each time: the credentials are only re-created when the password actually changed. - The generated password is captured before the create call rather than after, since a throw there can still leave a project behind. - On a failed provision the project list is refreshed, so the just-created project can be picked up from the other tab instead of provisioning a second. - Finish refuses a name that already belongs to another data table, which previously repointed it at the new database. - Secrets go to the acting user's namespace instead of a literal `u/admin/`. - The progress list no longer ticks "Created on Supabase" before the request is sent, and does not claim the database is ready when its check failed. - The wizard's resume state is cleared when it closes, so reopening after an abandoned OAuth round trip is not stuck on step 2. - The OAuth callback shares the session-storage key rather than repeating it. - SupabaseConnect uses the shared provisioning helpers instead of a fork. - Restores the doc comment displaced onto TestDataTableResourceQuery. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): simplify Alert layout and balance its vertical padding The body was rendered by two near-duplicate branches, each wrapping the text in an extra div only to hang a margin on it, and the margins disagreed: the collapsible branch spaced above with mt-2, the static one below with mb-2. Since isCollapsed defaults to true, every non-collapsible alert took the static branch, so titled alerts read as 24px of space below the text against 16px above -- visibly off-centre -- with the title and body flush against each other. Collapse both branches into one and drop the margins; the container's own padding now sets top and bottom equally, with a small gap under the title row. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): only offer Supabase when its OAuth client is configured The wizard offered the Supabase card unconditionally, so on an instance whose superadmin never configured a supabase_wizard client -- or whose backend is built without the oauth2 feature, which compiles the whole /api/oauth router out -- the card dead-ended at a 404. Gate it on listOauthConnects, the same check ApiConnectForm already makes, fetched on open so configuring the client mid-session does not require a reload. Also drop the Supabase project ref from the existing-project cards: it is an opaque identifier that means nothing outside Supabase's own dashboard URLs. Show the region instead, plus a status word when the project is not healthy, since a paused project is the one case where the connection check fails for a reason unrelated to the password. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): run the Supabase OAuth leg in a popup A full-page redirect unmounts the wizard, so anything the user does on Supabase's side -- signing in, confirming an email, browsing their dashboard -- leaves them with nothing pointing back at Windmill, and the wizard had to park its state in sessionStorage to survive the trip. Open the connect endpoint in a popup instead. The modal stays on screen throughout and the callback hands the token back through postMessage rather than navigating. The parked-state path stays as the fallback for browsers that block the popup. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): scope the connection check to the choice that produced it A failed check stayed on screen when the user switched Supabase mode or picked a different provider, so a fresh tab opened showing an error about a database it had nothing to do with. Clear the report and the error on both switches; re-clicking the tab already selected leaves an error the user is reading in place. Also polish the Supabase step: project cards get the provider-card treatment (icon, p-3, flex column) instead of a hand-rolled variant whose block layout left more padding above the name than below; form labels settle on text-emphasis; and the signup link sits under the primary button for anyone who does not have an account yet. Drop the "free" badge and the "Free on Supabase" line -- every option in the wizard is free, so neither told the user anything -- and say what the Supabase card actually does now that connecting an existing project is the default. Co-Authored-By: Claude Opus 5 (1M context) * refactor(frontend): one setup checklist and one Supabase step for every host The data table wizard, the instance database modal and the resource drawer each had their own version of the same two interactions, and they had already begun to drift: the wizard's Supabase resource shape was rebuilt by hand in the drawer, and the instance checks rendered with no notion of a step being in flight. SetupChecklist replaces LoggedWizardResult, whose only consumer was the instance modal. It adds the running state that component lacked, so a list driven by an endpoint that reports nothing until it returns still shows where it is. Both the instance checks and the Supabase provisioning stages render through it. SupabaseProjectStep owns picking or creating a project, and useSupabaseOauth owns the popup leg. Each host keeps only what is genuinely its own: the wizard saves a variable and resource then verifies the connection, the resource drawer fills in its own form. Both trigger authorization themselves, so a host can offer it a screen earlier than the step does. The lists load behind a spinner because which mode to open on depends on whether the account has projects; deciding that after rendering flipped the toggle under the user. Adds a kitchen_sink playground for the checklist so the animation and every failure position can be exercised without a backend, a superadmin, or a Supabase account. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): tidy the resource drawer around the Supabase entry point Connect Supabase was a hand-styled anchor carrying Supabase's brand hex values rather than a Button, and it sat in a row whose other controls had settled on unifiedSize md. Making it a Button meant SupabaseIcon had to satisfy IconType, so it now takes `size` (deriving height/width from it) alongside the string props its other callers pass. The manual resource form spaced every field 32px apart and WhitelistIp added another 16px of its own, which read as a gap rather than a rhythm. One gap of 16px, with the form itself given a little more separation from the description above it. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): stop Supabase resources coming up modified when first opened Resource forms fill in every unset property from the schema as soon as they render, so a postgresql resource saved without region, root_certificate_pem and use_iam_auth was dirty -- and had saved a draft -- the first time anyone looked at it. Write them with the rest of the value. SupabaseConnect also rebuilt the resource shape by hand instead of using the shared helper, which is how the pooler host format ended up in two places. Co-Authored-By: Claude Opus 5 (1M context) * feat(backend): record where a data table came from and whether setup finished edit_datatable_config replaces the whole datatables map and DataTable does not deny unknown fields, so anything the request omits is dropped without a word. origin and setup_incomplete would have been erased by any unrelated save; preserve_unmanaged_datatable_fields carries them -- and migrations_enabled, which had the same problem inline -- forward for entries that already exist, following renames. setup_incomplete is what lets a row be recorded before the resource it points at exists, so the wizard can write nothing until the user finishes. There is deliberately no intermediate state: the setup runs entirely in the browser, so nothing server-side could advance one. datatable_health probes every data table at once for the settings page and skips the incomplete ones, whose resource_path resolves to nothing yet. set_datatable_setup patches a single entry instead of resending the map. test_datatable_connection_value checks a connection the caller has not saved anywhere, which the wizard needs before it has written a resource. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): make destructive default and subtle buttons read red Both variants were neutral until the pointer arrived, then filled solid red: nothing marked the button as destructive until you were already on it. They now carry red text at rest, with a faded red border on default and a light red wash on hover, which is what the legacy red border style in the same file had always done. Three call sites passed color="red" alongside a design-system variant. getStyleClass returns before colour is read for accent, accent-secondary, default and subtle, so the delete-migration control, its modal confirm and the import-database button had all been rendering neutral. They pass destructive now. The dropdown variant strips the button's own border, and matched border-border-light literally -- a class the destructive style no longer contains. Co-Authored-By: Claude Opus 5 (1M context) * feat(frontend): rebuild data table setup around a read-only row The wizard gathers intent over two steps, reviews it on a third and writes nothing until Finish, so a billable Supabase project is created only once the user has seen what will happen. runSetup is also the retry: every step probes for its own result before doing anything, so running it again on a half-finished data table resumes instead of duplicating. Its steps are keyed rather than dispatched on their titles, where rewording one changed what it did. The settings row stops being an editable form with a dirty/save cycle. It carries the name, where the database came from, a health dot and two actions; everything rare moved into the gear panel, which also offers Finish setup for a data table whose wizard never completed. Manage is ExploreAssetButton, the control the ducklake list already uses, and the row and panel both link out to the underlying resource. supabaseResourceValue no longer assembles the pooler host from the region. aws-0-.pooler.supabase.com is wrong for any project Supabase allocated elsewhere, so the host, user and port come from the pooler config endpoint. Two data tables sharing one database also share _wm_migrations, which is probed unqualified, so the review step warns when the database being connected is already behind another data table. SupabaseConnect is deleted. The resource drawer uses the shared project step restricted to existing projects: creating one is a billed action and belongs in the wizard, which has somewhere to report what it did. The kitchen_sink checklist playground goes with it. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): fall back to a direct Supabase connection when the pooler cannot be read Reading a project's Supavisor config needs the database_pooling_config_read scope, which an instance's Supabase OAuth app may never have been granted. No retry recovers from that, and the wizard treated it as fatal: the user was left with an error and no way to finish connecting a project that was otherwise fine. resolveSupabaseConnection replaces the bare pooler read everywhere it happened. Asking for session pooling and failing now yields a direct connection plus the reason, which supabaseResourceValue already knew how to write. Nothing about the fallback is silent -- direct is IPv6-only, which is the whole reason session pooling is the default -- so the wizard warns on its review step and the resource drawer says so in its toast. The row is recorded before credentials are saved, so an origin claiming session pooling has to be corrected once a direct host is what gets written; the run patches it through set_datatable_setup rather than leaving the panel to report a mode nothing uses. Co-Authored-By: Claude Opus 5 (1M context) * feat(frontend): open the database behind a data table, and say when it cannot write Every database in the list now opens the surface that owns its credentials. A postgres one opens its resource in the editor drawer; a Windmill instance one opens the instance modal, which is where its setup checks, password rotation and drop already lived. Both are reachable from the row and from the panel's provenance list, and the provider icon moved inside the button so the whole thing is one target. CustomInstanceDbWizardModal targeted #content unconditionally, which put it underneath the panel drawer that now opens it. It takes a target, and the panel portals it to the body. The status column gains a third state. The probe reports privileges but nothing gated the dot on them, so a data table whose role cannot create tables showed as Connected and only failed when someone ran a migration. It reads "Limited permissions" instead, and opens the panel on the report carrying the GRANTs that fix it -- the settings page has already probed, so the panel takes that report rather than asking the user to run Test connection over work already done. fullyPrivileged is exported from the report component so the dot and the report cannot disagree about what counts as healthy. Co-Authored-By: Claude Opus 5 (1M context) * revert(frontend): keep the data tables settings table as it was The settings table and the setup wizard are two changes that only shared a file. Splitting them makes each reviewable: this branch keeps the wizard, and the read-only row, gear panel, health probe and clickable databases move to their own branch. The rows go back to the editable form with its pickers and save footer, still opening the wizard from Add a database. DataTableSettingsPanel, dataTableHealth and dataTableOrigin had no other consumers and go with them; the connection report stays, because the wizard shows it too. DataTableSettingsType keeps `origin`: the wizard writes it, and the review step reads it back to warn when two data tables would share one database and therefore one _wm_migrations table. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): confirm before dismissing the data table wizard mid-setup Closing was guarded while a run was in flight and unguarded before one, which is backwards: a run leaves a row to resume from, whereas a backdrop click on the review step threw away the project, the pasted password and the folder with nothing to recover them from. Backdrop, Escape and the close button now go through one path that asks first. It only asks when there is something to lose -- no provider chosen yet, or a run that already produced a result, closes immediately -- so the dialog does not become something to click through. Continue in the background still leaves in one click; that exit was always the deliberate one. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): stop the wizard claiming the resource folder controls who can use a data table "Who can use this database" was wrong. Every path that resolves a datatable:// reference -- both executors and the agent-worker endpoint -- reads the resource unchecked, by workspace and name. A resource in u/admin is usable by everyone's scripts. The folder governs who can see and edit the connection, and who can reference the resource directly in a SQL step; neither is who can use the data table. The wizard was contradicting the tab's own description two screens later. The folder select and name field become one Path picker, the same one the resource, variable and script forms use, so the review step reads as a resource path rather than a permission choice. Its initialPath is snapshotted when the step opens: Path seeds itself from it, and a live value fights the typing. Finish now also gates on Path's error, so a taken or malformed path stops the run before it writes anything. The button that opens all this says "Add a data table" -- the data table is what you get; the database is a detail chosen along the way. Co-Authored-By: Claude Opus 5 (1M context) * revert(frontend): move the destructive button restyle out of the wizard PR This reverts 3881e4d8ea. Making default and subtle destructive buttons red at rest changes every existing caller of the prop -- the workspace integrations, AI skills, workspace creation and the instance database drop -- so it is a design-system change, and the call sites it fixed are the migrations list and the database manager. None of that is the setup wizard. Nothing on this branch passes destructive any more, so it leaves with no loose ends. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): make the wizard stepper navigate the steps it already offers Stepper dispatches a click and paints cursor-pointer on every reached step, but the wizard never listened, so the breadcrumbs invited a click and did nothing. They now reach any step already passed, in either direction: going back to check something should not cost the progress, which means tracking the furthest step reached rather than the current one. Forward movement still only happens through the primary action, so a step is never reachable without having been validated -- and changing the intent revokes the steps ahead of it, or Finish could run against a review built from something the user has since edited. The five places that cleared the probe on an edit now do both through one call. During a run nothing is reachable, and the stepper says so rather than showing a pointer over steps that will not respond. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): restore the data tables description lost in the branch split The rewritten description went into DataTableSettings.svelte shortly before that file was restored wholesale to its pre-rebuild state, so it left with the row rework it had nothing to do with. The tab went back to describing the plumbing -- a fully managed PostgreSQL database, reachable from the SDK -- which never answered the question a new user actually has: why this rather than a Postgres resource. It leads with what a data table is, then the two things a resource cannot do -- nobody needs the credentials to query it, and the name can be pointed at another database without editing anything that uses it -- and closes with what Windmill runs on top. Both middle claims are the ones every resolution path backs up: datatable:// resolves by workspace and name, unchecked. Co-Authored-By: Claude Opus 5 (1M context) * fix(backend): say what is missing when a $res: or $var: reference does not resolve Both interpolations fetched with fetch_one and mapped the error through to_anyhow, so a reference to something deleted surfaced as "no rows returned by a query that expected to return at least one row @workspaces.rs:2169". It names neither the kind of thing that was missing nor its path, and it is what a data table pointing at a deleted resource reports. They now fetch_optional and return NotFound naming the path, and datatable resolution adds the data table on the way out: the caller asked for one by name, and a bare "resource f/x/y does not exist" leaves them to work out which of them points at it. The health probe is new, so this string had only just become something users read. Co-Authored-By: Claude Opus 5 (1M context) * feat(frontend): gate the data table wizard behind a dev flag The wizard only appears with `dataTableWizard` set in localStorage; without it the settings page keeps the inline-row flow it had before this branch, down to the empty-state copy and the "New Data Table" button, and the wizard component is not mounted at all. The existing e2e suite drives that button, so the default-off flag is also what keeps it green. Step 2 of "your own database" becomes one list rather than a segmented control: the workspace's Postgres resources, then a New resource card that expands in place. A connection string is not an alternative to a resource, it is how one is written, and the old layout taught otherwise. The card holds the same connection as a string or as fields and carries values across when you switch, so `parse` and `compose` have to be inverses -- hence the percent-encoding on both sides, which also fixes a password containing `@` silently corrupting in the resource form. The Supabase step now uses the same shape. Names and paths are checked as they are typed rather than at the end of a run that may have created a billed project first: the data table name against the charset `edit_datatable_config` enforces, the instance database name against what `setup_custom_instance_db` will accept, and the resource path against both the resource and variable namespaces, since the run writes to both and both writes upsert. `test_datatable_connection_value` refuses `$var:`/`$res:` in its body. It feeds `transform_json_value_unchecked`, which resolves references with no permission check of its own, so an admin could otherwise have had the API server decrypt any workspace secret and hand it to a host the same request chose -- without the audit trail a variable read leaves. Callers testing something unsaved hold the literal value already. Alert, SetupChecklist and postgresConnectionString change for everyone, not just behind the flag: body-only alerts no longer reserve an empty title row, the checklist can nest the checks a step is made of, and the connection-string parser is shared with the resource form. Co-Authored-By: Claude Opus 5 (1M context) * chore: pin ee-repo-ref to the EE branch merged with EE main The Supabase proxies the wizard calls are still unmerged, so the ref cannot be an EE main commit yet; it now names that branch merged with EE main rather than the branch alone, which was nine commits behind and would have been built against a CE main it never saw. Co-Authored-By: Claude Opus 5 (1M context) * feat(frontend): gate the supabase resource path behind the dev flag * test(frontend): pin connection string parsing to libpq behaviour * fix(frontend): keep the supabase resource link off the popup callback path * refactor(frontend): load the supabase resource dialog only behind the flag * fix(frontend): refuse a resource path the wizard run does not own * fix(frontend): let a failed data table setup be corrected without losing what it made * fix(frontend): let a failed setup reuse the resource path it claimed Co-Authored-By: Claude Opus 5 (1M context) * fix(backend): record the two data table connection tests in the audit log Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): use Section for the data table wizard advanced group Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): read connection strings the way libpq does Co-Authored-By: Claude Opus 5 (1M context) * fix(backend): pin the ee ref back to a commit this branch can build Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): keep a failed setup's claims across the redirect and rollback Co-Authored-By: Claude Opus 5 (1M context) * fix(backend): probe a data table with the auth mode the worker will use Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): keep every part of a connection string through the round trip Co-Authored-By: Claude Opus 5 (1M context) * refactor(frontend): give a setup run one record of what it created Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): mark a resource claim by edited_at, not its creator Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): mark every claim by revision, and keep an unconfirmed project's secret Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): refuse to test or save behind a connection string that will not parse Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): refuse a connection string carrying options the resource cannot hold Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): allowlist the connection-string parameters a resource can honour Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): guard every created Supabase project, not just the last one Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): do not warn about renaming an item that does not exist yet Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): make the review step read as one list of what will exist Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): keep the picked Supabase project across the redirect, reject connect_timeout Co-Authored-By: Claude Opus 5 (1M context) * fix: check the data table connection from a worker, not the API server The wizard's connection check ran on the API server through two endpoints added for it. That server is a different machine with a different identity, so the answer was about the API server rather than about the worker that will run the queries: a host reachable from one is not necessarily reachable from the other, and IAM RDS and Azure workload identity authenticate as whichever process opens the connection. Run the privilege query as a preview job instead. A job goes through the worker's Postgres executor, which is where `PgAuthMode::of` already picks the authentication mode, and it takes either a resource value or a `$res:` path exactly as a Postgres step does. Postgres composes the suggested GRANT statements through `format('%I')`, so identifier quoting stays where it is already implemented. Removes `test_datatable_resource_connection` and `test_datatable_connection_value`, and `connect_as_the_worker_would` with them. Co-Authored-By: Claude Opus 5 (1M context) * refactor: fold check_datatable_connection back into its only caller The helper was split out so the two connection-test endpoints could share a body. Those endpoints are gone, leaving one caller. Co-Authored-By: Claude Opus 5 (1M context) * revert: keep the data table connection check schema inline It was lifted into components so three endpoints could share it. Two of those are gone, so it is back to one user and the extraction changes nothing. Co-Authored-By: Claude Opus 5 (1M context) * fix: restore openapi.yaml to the branch point The previous commit restored main's tip rather than the merge base, which carried three unrelated main-only changes into this branch: the resource mcp_tools truncation fields, the execution_mode description, and a version bump. Co-Authored-By: Claude Opus 5 (1M context) * refactor(frontend): drop four effects from the data table wizard Each was doing work a derived, a load callback or a real entry point does better. - The name conflict is kept with the name it was raised for and derived from it. As an effect it was correct only because it never read what it wrote: the pre-flight sets the message and the effect does not re-trigger, so adding a read would have cleared it the instant it appeared. The message now also comes back if the taken name is retyped, which is what the server will say. - The default resource selection is seeded inside the fetcher that loads the list, where "has the fetch settled" cannot be asked wrong. - Reset-on-open becomes an exported open(), called by the settings page, so a fresh run is set up by the act of opening rather than by a flag emulating mount. - The OAuth connects and the folder list become resources; supabaseAvailable and folders are derived from them. defaultFolder takes the list rather than reading it, so the fetch can seed off its own result. Leaves the debounced path check, which is async with an out-of-order guard. Co-Authored-By: Claude Opus 5 (1M context) * refactor(frontend): drop three effects from the Supabase branch - useSupabaseOauth reports success as onAuthed, alongside the failures it already reported. SupabaseResourceConnect was watching `authed` to find out; it takes the callback instead, keeping the guard that stops an authorization started elsewhere on the page from opening its dialog. - SupabaseProjectStep loads its orgs and projects through a resource keyed on the token, so the `loaded` latch goes and re-authorizing reloads rather than keeping the lists from the expired session. - SetupChecklist records what the user toggled and derives the open state from it, a failed step defaulting to open. Recording the open state instead needed an effect to force it, and that effect re-ran on every progress update, so a description closed while anything was still ticking reopened. A close now holds for the life of the checklist, including across Try again. Leaves the message listener, which subscribes to another window. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): confine the modal restyle to the wizard, and trim the comments The wider side padding and lighter dialog heading were changing all 17 Modal2 dialogs to suit this one flow. They move behind an opt-in `formStyling`, taken by the three dialogs this branch owns; every other Modal2 renders as it did. Also drops two comments that cited a design approval rather than a constraint, and shortens the blocks that had grown past the four lines AGENTS.md asks for. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): use the accent token for the wizard's links `text-blue-500` is the marketing blue `#3B82F6`, which brand-guidelines.md rules out in the app interface. Co-Authored-By: Claude Opus 5 (1M context) * chore: point ee-repo-ref at the EE branch head Picks up EE main, which the branch now needs, and the Supabase proxy auth fix. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): read sslmode by name, and stop decrypting a secret to date it - `sslmode` was found by searching the query text, so it also matched inside another parameter's value: `?application_name=sslmode=disable` passed the allowlist on the parameter name and then parsed as a request to turn TLS off, which both the wizard and the resource form saved and probed. Parsed with `URLSearchParams` by exact name, with a test. - `secretMark` read the variable with `decryptSecret` defaulted to true, so every write decrypted a secret nothing reads and recorded the decryption -- including someone else's on the retry about to refuse it. It wants only `edited_at`, which is returned either way. - The probe gave up at 15s while the worker allows its Postgres connect 20s, so a host that accepts the connection and never answers was cancelled and reported as a missing worker rather than a failed connection. - The create-mode region and project name did not report an intent change, so renaming a project after a name collision left the failure naming the old one. - Two comments described the code as it was before the claim mark became a revision, and a doc comment outlived the field it documented. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): read connection parameters the way libpq does One reader for both the parser and the allowlist, since they disagreed about what a string says in two ways that both ended in a weaker connection than was pasted: - `URLSearchParams.get` takes the first of a repeated parameter and libpq takes the last, so `?sslmode=disable&sslmode=require` was read as `disable`. - The allowlist folded the parameter name and the parser did not, so `?SslMode=verify-full` was refused by neither and honoured by neither, and saved as the `require` default. The parked Supabase run is now handed to `open()` rather than read back off the `resume` prop it was just assigned to, so restoring it does not depend on when that prop reaches the component. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): keep connection parameter names case-sensitive libpq does not fold them: `?SslMode=disable` is rejected as an invalid URI query parameter rather than read as `sslmode`, which a local server confirms. Folding made Windmill accept and honour a string Postgres itself refuses; naming the parameter instead tells the user why it cannot be stored. The last-value-wins rule for a repeated parameter is unchanged, and matches what the same server does. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): seed the Supabase organization from the project it selects The loader took `orgs[0]` independently of the project it seeded, so an account whose first project sits outside its first organization had the review step name an organization the database does not belong to. Picking a project by hand already derives it; the seeding now does the same. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): let the probe report an empty search_path instead of failing on it `format('%I', NULL)` raises rather than returning NULL, so a role whose search_path names no valid schema failed the whole privilege query and was reported as an unreachable database. That is the one case `fix_search_path` exists to name, and it never reached the user. Verified against a local server with `SET search_path = ''`. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): say which of the two refusals a connection string hit Making parameter names case-sensitive gave `unsupportedConnectionParam` two reasons to refuse, and the single message explained only one. `?SslMode=` was answered with "Windmill cannot store SslMode on a Postgres resource", which is false twice over: sslmode is exactly what the resource stores, and the string asks for nothing because Postgres rejects the URI. It now names the spelling when the parameter is one we keep, and the storage limit otherwise. The folder-list guard also still read the `resume` prop that `open(parked)` was changed to stop trusting, so the resumed path now comes from whatever `reset` was handed. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): leave the Supabase organization unset when the lookup misses Falling back to the first organization named one the seeded project is not in, since `supabaseSummary` prefers `intent.org` over the project's own. Unset, it falls through to the project's organization identifier — the right one, spelled as a slug rather than a name. Co-Authored-By: Claude Opus 5 (1M context) * test(frontend): pin which refusal a connection string gets The two messages differ in what they ask the user to do, and the condition choosing between them — whether the lowercased name is one the resource keeps — is not visible from either call site. `Connect_Timeout` is the case that keeps them honest: miscased *and* unstorable, so respelling it would not help and the message must not suggest it. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): hand a failed Supabase leg back to the page holding its run Denial, a token error and a malformed callback all sent the user to /resources whether or not a run was parked. Nothing else consumes the park, so the run stayed in sessionStorage and sprang the wizard open on an unrelated later visit instead. A parked run now lands on the data tables tab, where the wizard resumes on the setup step and can authorize again. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): let a run reuse the name of a row it could not take back out `removeRow` reports `kept` when the undo cannot reach the server, so the row this run wrote stays in the workspace config and comes back in `existingNames`. The client-side name check then refused the retry on the run's own name, with no way forward but a rename. The instance database name has carried the same exemption since it was written; this is the data table name catching up. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): discard a variable check the wizard has moved on from The post-await guard compared only the path, and the path is built from the review step's fields -- so picking an existing resource stops the wizard minting one without changing it. A check already in flight then answered for a branch nobody was on, and a `true` disabled Finish over a path the run no longer writes. The cleanup cannot help: it cancels a pending timer, not a live request. Both sides of the await now ask the same question. Co-Authored-By: Claude Opus 5 (1M context) * chore: update ee-repo-ref to 483513b70979aa9497cab869837108d948449984 This commit updates the EE repository reference after PR #715 was merged in windmill-ee-private. Previous ee-repo-ref: 8604b30a740c5620069208801a7ae50937b61977 New ee-repo-ref: 483513b70979aa9497cab869837108d948449984 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-common/src/workspaces.rs | 27 +- .../src/lib/components/ApiConnectForm.svelte | 87 +- .../src/lib/components/AppConnectInner.svelte | 30 +- frontend/src/lib/components/Path.svelte | 12 +- .../src/lib/components/WhitelistIp.svelte | 1 - .../lib/components/common/alert/Alert.svelte | 73 +- .../lib/components/common/modal/Modal2.svelte | 13 +- .../components/common/stepper/Stepper.svelte | 38 +- .../lib/components/copilot/ResourceGen.svelte | 1 + .../lib/components/icons/SupabaseIcon.svelte | 19 +- .../wizards/LoggedWizardResult.svelte | 108 -- .../components/wizards/SetupChecklist.svelte | 117 ++ .../AddDataTableWizard.svelte | 1435 +++++++++++++++++ .../CustomInstanceDbWizardModal.svelte | 70 +- .../DataTableConnectionReport.svelte | 77 + .../DataTableSettings.svelte | 134 +- .../SupabaseConnectionMode.svelte | 62 + .../SupabaseProjectStep.svelte | 290 ++++ .../SupabaseResourceConnect.svelte | 120 ++ .../addDataTableModel.test.ts | 434 +++++ .../workspaceSettings/addDataTableModel.ts | 853 ++++++++++ .../workspaceSettings/datatableProbe.ts | 108 ++ .../workspaceSettings/instanceDbSteps.ts | 89 + .../workspaceSettings/setupClaims.test.ts | 72 + .../workspaceSettings/setupClaims.ts | 87 + .../workspaceSettings/supabaseOauth.svelte.ts | 107 ++ .../workspaceSettings/supabaseProvisioning.ts | 250 +++ .../workspaceSettings/utils.svelte.ts | 12 + .../workspaceSettings/wizardParking.ts | 63 + .../utils/postgresConnectionString.test.ts | 186 +++ .../src/lib/utils/postgresConnectionString.ts | 144 ++ .../oauth/callback_supabase/+page.svelte | 47 +- 33 files changed, 4869 insertions(+), 299 deletions(-) delete mode 100644 frontend/src/lib/components/wizards/LoggedWizardResult.svelte create mode 100644 frontend/src/lib/components/wizards/SetupChecklist.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/DataTableConnectionReport.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/SupabaseConnectionMode.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/SupabaseProjectStep.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/SupabaseResourceConnect.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts create mode 100644 frontend/src/lib/components/workspaceSettings/addDataTableModel.ts create mode 100644 frontend/src/lib/components/workspaceSettings/datatableProbe.ts create mode 100644 frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts create mode 100644 frontend/src/lib/components/workspaceSettings/setupClaims.test.ts create mode 100644 frontend/src/lib/components/workspaceSettings/setupClaims.ts create mode 100644 frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts create mode 100644 frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts create mode 100644 frontend/src/lib/components/workspaceSettings/wizardParking.ts create mode 100644 frontend/src/lib/utils/postgresConnectionString.test.ts create mode 100644 frontend/src/lib/utils/postgresConnectionString.ts diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ec7c2ac03e..1b9eb3fe0e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -bd4de74eb37b32a2b6c7c69f6dedac031ef8436b +483513b70979aa9497cab869837108d948449984 diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 7ba930c3ea..c45d0c79f9 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1123,12 +1123,18 @@ async fn get_datatable_resource_inner( serde_json::to_value(&pg_creds) .map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))? } else { + // Name the data table too: the caller asked for one by name, and a bare + // "resource f/x/y does not exist" leaves them to work out which one points at it. transform_json_unchecked( &serde_json::Value::String(format!("$res:{}", datatable.database.resource_path)), w_id, db, ) - .await? + .await + .map_err(|e| match e { + Error::NotFound(m) => Error::NotFound(format!("data table {name}: {m}")), + e => e, + })? }; Ok(db_resource) @@ -2105,25 +2111,32 @@ async fn transform_json_unchecked( serde_json::Value::Array(transformed_array) } serde_json::Value::String(s) if s.starts_with("$res:") => { + // A reference to something that was deleted is the common failure here, and + // `fetch_one` reports it as "no rows returned by a query that expected to + // return at least one row" -- which names neither what was missing nor where. + let path = &s[5..]; let resource = sqlx::query_scalar!( "SELECT value AS \"value!: _\" FROM resource WHERE workspace_id = $1 AND path = $2", &w_id, - &s[5..] + path ) - .fetch_one(db) + .fetch_optional(db) .await - .map_err(to_anyhow)?; + .map_err(to_anyhow)? + .ok_or_else(|| Error::NotFound(format!("resource {path} does not exist")))?; transform_json_unchecked(&resource, w_id, db).await? } serde_json::Value::String(s) if s.starts_with("$var:") => { + let path = &s[5..]; let (value, is_secret): (String, bool) = sqlx::query_as( "SELECT value, is_secret FROM variable WHERE workspace_id = $1 AND path = $2", ) .bind(&w_id) - .bind(&s[5..]) - .fetch_one(db) + .bind(path) + .fetch_optional(db) .await - .map_err(to_anyhow)?; + .map_err(to_anyhow)? + .ok_or_else(|| Error::NotFound(format!("variable {path} does not exist")))?; let value = if is_secret { if is_external_stored_value(&value) { get_secret_value(db, w_id, &s[5..], &value).await? diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index e3927ddc64..e4ee3ca7b9 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -11,12 +11,14 @@ import Button from './common/button/Button.svelte' import { Loader2 } from 'lucide-svelte' import { untrack } from 'svelte' - import { base } from '$lib/base' import GitHubAppIntegration from './GitHubAppIntegration.svelte' import BedrockCredentialsCheck from './BedrockCredentialsCheck.svelte' import { isCloudHosted } from '$lib/cloud' import ResourceGen from './copilot/ResourceGen.svelte' import SyncResourceTypes from './SyncResourceTypes.svelte' + import { base } from '$lib/base' + import { isDataTableWizardEnabled } from './workspaceSettings/utils.svelte' + import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString' interface Props { resourceType: string @@ -98,35 +100,42 @@ let connectionString = $state('') let validConnectionString = $state(true) function parseConnectionString(close: (_: any) => void) { - const regex = - /postgres(?:ql)?:\/\/(?[^:@]+)(?::(?[^@]+))?@(?[^:\/?]+)(?::(?\d+))?\/(?[^\?]+)?(?:\?.*sslmode=(?[^&]+))?/ - const match = connectionString.match(regex) - if (match) { - validConnectionString = true - const { user, password, host, port, dbname, sslmode } = match.groups! - rawCode = JSON.stringify( - { - ...args, - user, - password: password || args?.password, - host, - port: (port ? Number(port) : undefined) || args?.port, - dbname: dbname || args?.dbname, - sslmode: sslmode || args?.sslmode - }, - null, - 2 - ) - rawCodeEditor?.setCode(rawCode) - close(null) - } else { + const parts = parsePostgresConnectionString(connectionString) + if (!parts) { validConnectionString = false + return } + validConnectionString = true + rawCode = JSON.stringify( + { + ...args, + user: parts.user, + password: parts.password || args?.password, + host: parts.host, + port: parts.port || args?.port, + dbname: parts.dbname || args?.dbname, + sslmode: parts.sslmode || args?.sslmode + }, + null, + 2 + ) + rawCodeEditor?.setCode(rawCode) + close(null) } let rawCodeEditor: { setCode: (code: string) => void } | undefined = $state(undefined) let textFileContent: string | undefined = $state(undefined) + // The wizard's Supabase entry point is opt-in for now; without it the form keeps the link + // that hands the whole leg over to the resources page. + const wizardEnabled = isDataTableWizardEnabled() + + function applySupabasePick(value: Record) { + args = { ...(args ?? {}), ...value } + rawCode = JSON.stringify(args, null, 2) + rawCodeEditor?.setCode(rawCode) + } + function parseTextFileContent() { args = { content: textFileContent @@ -172,7 +181,7 @@ }} > {#snippet trigger()} - {/snippet} @@ -206,14 +215,28 @@ {/if} {#if resourceType == 'postgresql' && supabaseWizard} - - -
Connect Supabase
-
+ {#if wizardEnabled} + + {#await import('./workspaceSettings/SupabaseResourceConnect.svelte')} + + {:then Module} + + {/await} + {:else} + + + +
Connect Supabase
+
+ {/if} {/if} {:else if step == 2 && manual} -
+
{#if !emptyString(resourceTypeInfo?.description)} {/if} @@ -1332,18 +1332,22 @@ Acquire the token automatically via client credentials instead {/if} - {#key resourceTypeInfo} - - {/key} + +
+ {#key resourceTypeInfo} + + {/key} +
{:else if step == 2 && !manual} {#if manual == false && resourceType != ''} diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 284677ce7c..639081cc3d 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -85,6 +85,10 @@ * workspace when the editor operates on a workspace other than the one the * top nav points at (see the sessions preview / dev-workspace flows). */ workspaceOverride?: string + /** One path that does not count as taken, for a caller creating something that may + * already have written there itself — a setup flow correcting its own failed attempt. + * Every other existing path is still refused. */ + allowedExistingPath?: string } let { @@ -102,7 +106,8 @@ disableEditing = false, size = 'md', drawerOffset = 0, - workspaceOverride = undefined + workspaceOverride = undefined, + allowedExistingPath = undefined }: Props = $props() let ws = $derived(workspaceOverride ?? $workspaceStore) @@ -240,6 +245,7 @@ } validateTimeout = setTimeout(async () => { if ( + path !== allowedExistingPath && (path == '' || checkInitialPathExistence || path != initialPath) && (await pathExists(path, kind)) ) { @@ -420,8 +426,12 @@ }) } }) + // Nothing depends on an item that does not exist yet, so editing a *suggested* path is not a + // rename. `checkInitialPathExistence` is what callers set when they are creating something, + // which is the same question asked the other way round. let displayPathChangedWarning = $derived( (['flow', 'script', 'resource', 'variable'] as PathKind[]).includes(kind) && + !checkInitialPathExistence && initialPath && initialPath !== path ) diff --git a/frontend/src/lib/components/WhitelistIp.svelte b/frontend/src/lib/components/WhitelistIp.svelte index be2f8f56dd..fbc32d79b6 100644 --- a/frontend/src/lib/components/WhitelistIp.svelte +++ b/frontend/src/lib/components/WhitelistIp.svelte @@ -22,7 +22,6 @@ {#if ips} -
If necessary, the workers IPs to whitelist are: {ips.join(', ')} diff --git a/frontend/src/lib/components/common/alert/Alert.svelte b/frontend/src/lib/components/common/alert/Alert.svelte index 7c03de5dca..c3d86693be 100644 --- a/frontend/src/lib/components/common/alert/Alert.svelte +++ b/frontend/src/lib/components/common/alert/Alert.svelte @@ -54,6 +54,10 @@ } const SvelteComponent = $derived(icons[type]) + + // A blank title would still occupy a text line and push the body down, leaving an alert + // that is visibly top-heavy. Body-only alerts skip the row, and the gap under it, entirely. + const hasTitleRow = $derived(!!title || collapsible || tooltip != '' || !!documentationLink)
-
- - {title} - {#if tooltip != '' || documentationLink} - {tooltip} - {/if} - - {#if collapsible} - - {/if} -
- - {#if children && !isCollapsed} -
-
- {@render children?.()} -
+ + {#if collapsible} + + {/if}
- {:else if children && !collapsible} -
-
- {@render children?.()} -
+ {/if} + + {#if children && (!collapsible || !isCollapsed)} +
+ {@render children?.()}
{/if}
diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index c00bf758d0..f33b1dec04 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -26,6 +26,9 @@ * and clicks "outside" the child would otherwise propagate * here and close the underlying modal. */ closeOnOutsideClick?: boolean + /** Wider side padding and a lighter title, for a dialog whose body is a form rather + * than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */ + formStyling?: boolean headerLeft?: import('svelte').Snippet headerRight?: import('svelte').Snippet children?: import('svelte').Snippet @@ -43,6 +46,7 @@ fixedHeight = 'md', contentClasses = '', closeOnOutsideClick = true, + formStyling = false, headerLeft, headerRight, children @@ -91,7 +95,9 @@ // Elevate above the AI chat panel (zIndexes.aiChat) while chat is open so // the dialog isn't hidden behind it; otherwise keep the default modal // stacking just above disposables (zIndexes.disposables). - const overlayZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10) + const overlayZIndex = $derived( + chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10 + ) @@ -109,7 +115,8 @@ heightMap[fixedHeight] ? `height: ${heightMap[fixedHeight]}; ` : '' }${css?.popup?.style || ''}`} class={twMerge( - 'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface p-4', + 'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface', + formStyling ? 'py-4 px-6' : 'p-4', css?.popup?.class, 'wm-modal-form-popup' )} @@ -120,7 +127,7 @@
-

{title}

+

{title}

diff --git a/frontend/src/lib/components/common/stepper/Stepper.svelte b/frontend/src/lib/components/common/stepper/Stepper.svelte index bbd6b1afe4..de6303cedc 100644 --- a/frontend/src/lib/components/common/stepper/Stepper.svelte +++ b/frontend/src/lib/components/common/stepper/Stepper.svelte @@ -4,12 +4,14 @@ import { createEventDispatcher } from 'svelte' interface Props { - tabs: string[]; - selectedIndex?: number; - maxReachedIndex?: number; - statusByStep?: Array<'success' | 'error' | 'pending'>; - hasValidations?: boolean; - allowStepNavigation?: boolean; + tabs: string[] + selectedIndex?: number + maxReachedIndex?: number + statusByStep?: Array<'success' | 'error' | 'pending'> + hasValidations?: boolean + allowStepNavigation?: boolean + /** Compact variant, for steering a dialog rather than a full page. */ + small?: boolean } let { @@ -18,8 +20,9 @@ maxReachedIndex = -1, statusByStep = [], hasValidations = false, - allowStepNavigation = false - }: Props = $props(); + allowStepNavigation = false, + small = false + }: Props = $props() const dispatch = createEventDispatcher() @@ -63,13 +66,20 @@
-
    +
      {#each tabs ?? [] as step, index}
    1. { @@ -77,11 +87,13 @@ }} > {#if statusByStep[index] === 'pending'} - + {:else} {#if index !== (tabs ?? []).length - 1}
    2. -
      +
    3. {/if} {/each} diff --git a/frontend/src/lib/components/copilot/ResourceGen.svelte b/frontend/src/lib/components/copilot/ResourceGen.svelte index 42e42fb9fa..34534285b4 100644 --- a/frontend/src/lib/components/copilot/ResourceGen.svelte +++ b/frontend/src/lib/components/copilot/ResourceGen.svelte @@ -124,6 +124,7 @@ + {:else} + {step.title} + {/if} + + {#if descriptionOpened} +
      + {step.description} +
      + {/if} +
      +
+
+ {#if step.substeps?.length} +
+ +
+ {/if} +
+ {/each} +
diff --git a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte new file mode 100644 index 0000000000..2e19cbea82 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte @@ -0,0 +1,1435 @@ + + + opened, + (v) => { + if (!v) requestClose() + else opened = v + } + } + target="#content" + formStyling + title="Add a data table" + contentClasses="flex flex-col" + fixedWidth="md" + fixedHeight="lg" +> +
+ goToStep(e.detail.index)} + /> + +
+
+ {#if run.steps.length} + + {#if run.running} +

+ Setting up. This can take a few minutes — leave this open until it finishes. +

+ {/if} + {#if run.result} + {@render poolerWarning()} + + {/if} + {:else if wiz.step === 1} + + A data table runs on a database of your own. It stays yours — you can take it with + you at any time. + +
+ {#if $isCustomInstanceDbEnabled} + {#snippet instanceIcon()} + + {/snippet} + {@render providerCard( + 'instance', + instanceIcon, + 'Windmill database', + 'Windmill creates and manages a database on this instance.' + )} + {/if} + {#if supabaseAvailable} + {#snippet supabaseIcon()} + + {/snippet} + {@render providerCard( + 'supabase', + supabaseIcon, + 'Supabase', + 'Create a project, or connect one you already have. Signing in is required, and connecting an existing project needs its database password.' + )} + {/if} + {#snippet ownIcon()} + + {/snippet} + {@render providerCard( + 'resource', + ownIcon, + 'Your own database', + 'Any Postgres — RDS, Neon, self-hosted. Pick a resource, or paste a connection string.' + )} +
+ {:else if wiz.step === 2} + {#if wiz.provider === 'supabase'} + {#if !supaOauth.authed} + + {#if supaOauth.pending} + Sign in and approve Windmill in the Supabase window, then come back here. + {:else} + Windmill needs your approval on Supabase to see your databases. + {/if} + + {:else} + invalidate()} + /> + {/if} + {:else if wiz.provider === 'instance'} + {@render instanceStep()} + {:else} + {@render ownStep()} + {/if} + + + {:else} +
+ {@render reviewStep()} +
+ {/if} +
+ +
+
+
+ {#if wiz.step > 1 && !run.steps.length} + + {:else if canEditAfterFailure} + + + {/if} +
+ +
+ {#if wiz.provider === 'supabase' && !supaOauth.authed} +

+ If you do not have a Supabase account you can create one for free. +

+ {/if} +
+
+
+
+ +{#snippet providerCard(key: Provider, icon: Snippet, title: string, subtitle: string)} + {@const selected = wiz.provider === key} + +{/snippet} + +{#snippet instanceStep()} + {@const instanceDbs = Object.entries(customInstanceDbs.current ?? {}) + .filter(([_, db]) => db.tag === 'datatable') + .map(([name, db]) => ({ name, db }))} + {#if instanceDbs.length} + wiz.instance.mode, + (v) => { + wiz.instance.mode = v + wiz.instance.dbName = v === 'create' ? defaultInstanceDbName() : undefined + } + } + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {#if wiz.instance.mode === 'existing'} + {@const shared = ( + customInstanceDbs.current?.[wiz.instance.dbName ?? '']?.used_by_workspaces ?? [] + ).filter((w) => w !== $workspaceStore)} + + {#if shared.length} + + This database is also used by workspace{shared.length > 1 ? 's' : ''} + {shared.join(', ')}. Any data written here will be shared + with {shared.length > 1 ? 'them' : 'it'}. + + {/if} +
+ {#each instanceDbs as { name, db } (name)} + {@const selected = wiz.instance.dbName === name} + {@const others = (db.used_by_workspaces ?? []).filter((w) => w !== $workspaceStore)} + + {/each} +
+ {:else} +
+ Database name + wiz.instance.dbName ?? '', (v) => (wiz.instance.dbName = v)} + error={!!instanceNameError} + inputProps={{ placeholder: defaultInstanceDbName() }} + /> + + {#if !instanceNameError} +

+ Created in the Windmill PostgreSQL instance when you finish. Windmill manages its + credentials. +

+ {/if} +
+ {/if} +{/snippet} + +{#snippet ownStep()} + {@const resources = pgResources.loading ? undefined : pgResources.current} + {#if resources === undefined} +

Loading resources...

+ {:else} + {#if resources.length} + Postgres resources in this workspace + {:else} +

A resource is a saved connection your scripts can use.

+ {/if} +
+ {#each resources as r (r.path)} + {@const selected = !wiz.own.creating && wiz.own.resourcePath === r.path} + + {/each} + +
+ + {#if wiz.own.creating} +
{@render newResourceForm()}
+ {/if} +
+
+ {/if} +{/snippet} + +{#snippet newResourceForm()} +
+
+ + {wiz.own.form === 'string' ? 'Connection string' : 'Connection'} + + +
+ {#if wiz.own.form === 'string'} + wiz.own.connectionString, + (v) => { + wiz.own.connectionString = v + absorbConnectionString(v) + invalidate() + } + } + error={!!connectionStringError} + inputProps={{ placeholder: 'postgres://user:password@host:5432/database' }} + /> + + {:else} +
+
+ Host + wiz.own.fields.host, (v) => setField('host', v)} + inputProps={{ placeholder: 'db.example.com' }} + /> +
+
+ Port + wiz.own.fields.port ?? '', + (v) => setField('port', v === '' ? undefined : Number(v)) + } + inputProps={{ placeholder: '5432', type: 'number' }} + /> +
+
+ Database + wiz.own.fields.dbname ?? '', (v) => setField('dbname', v)} + inputProps={{ placeholder: 'postgres' }} + /> +
+
+ SSL mode + certVerification, + (v) => + setAdvanced('accept_invalid_certs', v === 'default' ? undefined : v === 'accept') + } + clearable={false} + /> +
+ setAdvanced('use_iam_auth', e.detail)} + options={{ right: 'Authenticate with AWS IAM' }} + /> + {#if wiz.own.advanced.use_iam_auth} +
+ Region + wiz.own.advanced.region, (v) => setAdvanced('region', v)} + inputProps={{ placeholder: 'us-east-1' }} + /> +
+ {/if} +
+ +
+{/snippet} + +{#snippet poolerWarning()} + {#if poolerUnavailable} + +
+ {poolerUnavailable} + + Windmill connects directly instead, which needs IPv6 from the workers, or the IPv4 add-on + on the project. Granting the Supabase OAuth app + database_pooling_config_read and connecting again restores the + pooler. + +
+
+ {/if} +{/snippet} + +{#snippet reviewStep()} + {#if lastFailure} + {lastFailure} + {/if} + + + {#if wiz.provider === 'supabase'} + + + {@render poolerWarning()} + {:else if wiz.provider === 'instance'} + + {:else if !wiz.own.creating} + + {/if} + + {#if mintsResource} + + {/if} + + {#if sharesDatabaseWith} + + {sharesDatabaseWith.name} already uses this database. Both data + tables would write to the same schema, so each one's tables are visible to the other and two tables + of the same name collide. Migrations are tracked per data table, so those stay separate. + + {/if} + + {#if wiz.provider === 'supabase'} + + Your Supabase sign-in is not stored. If the database password ever changes, anyone with access + to the project can sign in and reconnect it. Deleting the data table never deletes the + Supabase project. + + {/if} +{/snippet} diff --git a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte index 644f5e2af5..8246118c49 100644 --- a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte +++ b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte @@ -8,7 +8,7 @@ import { slide } from 'svelte/transition' import Modal2 from '../common/modal/Modal2.svelte' import Alert from '../common/alert/Alert.svelte' - import LoggedWizardResult, { firstEmptyStepIsError } from '../wizards/LoggedWizardResult.svelte' + import SetupChecklist from '../wizards/SetupChecklist.svelte' import Button from '../common/button/Button.svelte' import { sendUserToast } from '$lib/toast' import { isCustomInstanceDbEnabled } from './utils.svelte' @@ -20,6 +20,7 @@ import { truncate } from '$lib/utils' import Tooltip from '../meltComponents/Tooltip.svelte' import { superadmin } from '$lib/stores' + import { instanceSetupSteps } from './instanceDbSteps' type Props = { customInstanceDbs: ResourceReturn @@ -45,6 +46,7 @@ !!opened, (v) => !v && !preventClose && (opened = undefined)} target="#content" + formStyling title={'Custom Instance Database Setup'} contentClasses="flex flex-col" fixedWidth="md" @@ -59,7 +61,7 @@
{dbname} - + Custom instance databases are databases created in the Windmill PostgreSQL instance. Their credentials are automatically managed by Windmill and are never exposed to users. Only super admins can create them. @@ -127,68 +129,8 @@
{/if} -
{#if $superadmin} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableConnectionReport.svelte b/frontend/src/lib/components/workspaceSettings/DataTableConnectionReport.svelte new file mode 100644 index 0000000000..68984321a3 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/DataTableConnectionReport.svelte @@ -0,0 +1,77 @@ + + +{#if error} + + {error} + +{:else if report} + +
+
+ Connects as {report.user}{#if report.schema}, resolving + unqualified statements to schema {report.schema}{/if}. +
+ {#if report.suggested_search_path} +
+ Its search_path resolves to no schema, so unqualified statements fail with + no schema has been selected to create in whatever + privileges the role holds. Point it at one, e.g. + {report.suggested_search_path}. +
+ {/if} +
    +
  • + Create tables{report.schema ? ` in ${report.schema}` : ''}: + {report.can_create_table ? 'yes' : 'no'} +
  • +
  • + Create schemas: + {report.can_create_schema ? 'yes' : 'no'} +
  • +
  • + Migration bookkeeping table exists: + {report.migrations_table_exists ? 'yes' : 'no'} +
  • +
+ {#if report.suggested_grants.length > 0} +
+ Windmill connects as the role that lacks these privileges, so it cannot grant them itself. + Run as a schema owner or superuser on that database: +
+
{report.suggested_grants.map((g) => `${g};`).join('\n')}
+ {#if report.schema && !report.can_create_table && !report.migrations_table_exists} +
+ Alternatively, create the _wm_migrations bookkeeping table + yourself and grant only SELECT, INSERT, UPDATE, DELETE on it. +
+ {/if} + {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte index 254a20fe80..bb97e434f6 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte @@ -66,7 +66,11 @@ import Row from '../table/Row.svelte' import TextInput from '../text_input/TextInput.svelte' import Tooltip from '../Tooltip.svelte' - import { isCustomInstanceDbEnabled, getUnusedInstanceDbName } from './utils.svelte' + import { + isCustomInstanceDbEnabled, + getUnusedInstanceDbName, + isDataTableWizardEnabled + } from './utils.svelte' import { random_adj } from '../random_positive_adjetive' import { sendUserToast } from '$lib/toast' import { @@ -89,6 +93,10 @@ import Alert from '../common/alert/Alert.svelte' import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte' import { isCloudHosted } from '$lib/cloud' + import AddDataTableWizard from './AddDataTableWizard.svelte' + import { takeParkedWizard, type WizardResume } from './wizardParking' + import { Database } from 'lucide-svelte' + import { onMount } from 'svelte' type Props = { dataTableSettings: DataTableSettingsType @@ -156,6 +164,8 @@ return getUnusedInstanceDbName('dt', $workspaceStore ?? '', usedNames) } + // Kept for the flag-off path: adding a data table is a row in this table that the user + // fills in and saves, rather than a wizard. function onNewDataTable() { const name = tempSettings.dataTables.some((d) => d.name === 'main') ? `${random_adj()}_datatable` @@ -211,6 +221,37 @@ } } + const wizardEnabled = isDataTableWizardEnabled() + let wizardOpen = $state(false) + /** Opened through the wizard's own `open()`, which is what sets a fresh run up. */ + let wizard: { open: (parked?: WizardResume) => void } | undefined = $state(undefined) + let wizardResume: WizardResume | undefined = $state(undefined) + + // Supabase sends the user back here after authorizing; pick the wizard back up where it + // was rather than making them start again. + onMount(() => { + if (!wizardEnabled) return + const parked = takeParkedWizard() + if (parked) { + wizardResume = parked + // Handed in, not left to the `resume` prop: the wizard rebuilds the run synchronously + // inside this call, and a parked run that arrived late would come back as a fresh one. + wizard?.open(parked) + } + }) + + /** + * The wizard persists what it creates, so the server is authoritative afterwards and the + * whole baseline comes from it. `tempSettings` derives from that baseline, so this discards + * uncommitted edits in the table -- which is why the wizard cannot be opened while there + * are any (see the disabled entry points below). + */ + async function reloadAfterWizard() { + const s = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) + dataTableSettings = convertDataTableSettingsFromBackend(s.datatable) + wizardResume = undefined + } + let confirmationModal = createAsyncConfirmationModal() let dirtyMap = $derived.by(() => { const map: Record = {} @@ -241,7 +282,7 @@ @@ -273,9 +314,37 @@ {#if tempSettings.dataTables.length == 0} - - No data table in this workspace yet - + {#if wizardEnabled} + +
+ +
+ No data table yet +

+ Give your scripts a database to store and query data. + {#if isCloudHosted()} + Set one up free in about a minute. + {:else} + Use the Windmill database, or bring your own. + {/if} +

+
+ +
+
+ {:else} + + No data table in this workspace yet + + {/if}
{/if} {#each tempSettings.dataTables as dataTable, dataTableIndex (dataTable.id)} @@ -383,15 +452,27 @@ {/each} - - -
- -
-
-
+ {#if !wizardEnabled || tempSettings.dataTables.length > 0} + + +
+ +
+
+
+ {/if} @@ -467,3 +548,28 @@ /> + +{#if wizardEnabled} + wizardOpen, + (v) => { + wizardOpen = v + // Drop the parked run once the wizard closes: leaving it set would force the next + // open straight back to the Supabase setup step. + if (!v) wizardResume = undefined + } + } + existingNames={tempSettings.dataTables.map((d) => d.name)} + existingDataTables={tempSettings.dataTables.map((d) => ({ + name: d.name, + resourcePath: d.database.resource_path + }))} + resume={wizardResume} + onDone={reloadAfterWizard} + {customInstanceDbs} + {confirmationModal} + {defaultInstanceDbName} + /> +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/SupabaseConnectionMode.svelte b/frontend/src/lib/components/workspaceSettings/SupabaseConnectionMode.svelte new file mode 100644 index 0000000000..6f2523a729 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/SupabaseConnectionMode.svelte @@ -0,0 +1,62 @@ + + +
+ + {#if open} +
+ + {#each OPTIONS as option (option.value)} + {@const selected = mode === option.value} + + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/workspaceSettings/SupabaseProjectStep.svelte b/frontend/src/lib/components/workspaceSettings/SupabaseProjectStep.svelte new file mode 100644 index 0000000000..0406587ce7 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/SupabaseProjectStep.svelte @@ -0,0 +1,290 @@ + + +{#if loading} +
+ + Loading your Supabase projects... +
+{:else if (projects ?? []).length === 0 && existingOnly} + + This Supabase account has no projects yet. + +{:else} + {#if (projects ?? []).length} + Projects in your Supabase account + {:else} +

This Supabase account has no projects yet.

+ {/if} +
+ {#each projects ?? [] as p (projectRef(p))} + {@const selected = intent.mode === 'existing' && isSelected(intent.project, p)} + +
+ + {#if selected} +
+
+ Database password + intent.password, (v) => ((intent.password = v ?? ''), onIntentChange?.()) + } + placeholder="••••••••" + /> +

+ Supabase only shows this when the project is created, and never exposes it through + its API. If you no longer have it, set a new one — every existing connection to this project stops working when you do. +

+
+ +
+ {/if} +
+ {/each} + {#if !existingOnly} +
+ + {#if intent.mode === 'create'} +
{@render newProjectFields()}
+ {/if} +
+ {/if} +
+{/if} + +{#snippet newProjectFields()} +
+
+
+ Organization + + ({ label: r.label, value: r.code }))} + bind:value={() => intent.region, (v) => ((intent.region = v), onIntentChange?.())} + placeholder="Region" + /> +
+
+
+ Project name + intent.projectName, (v) => ((intent.projectName = String(v)), onIntentChange?.()) + } + inputProps={{ placeholder: 'windmill-data' }} + /> +
+ + Windmill generates and stores the database password. A new project takes a minute or two to + come up. + + +
+{/snippet} diff --git a/frontend/src/lib/components/workspaceSettings/SupabaseResourceConnect.svelte b/frontend/src/lib/components/workspaceSettings/SupabaseResourceConnect.svelte new file mode 100644 index 0000000000..f9f4586503 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/SupabaseResourceConnect.svelte @@ -0,0 +1,120 @@ + + + + + +
+
+ {#if oauth.token} + + {/if} +
+
+ +
+
+
diff --git a/frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts b/frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts new file mode 100644 index 0000000000..cb42d73075 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts @@ -0,0 +1,434 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const listSupabaseProjectsMock = vi.fn() +const createSupabaseProjectMock = vi.fn() +vi.mock('./supabaseProvisioning', async (importOriginal) => ({ + ...(await importOriginal()), + listSupabaseProjects: (...a: any[]) => listSupabaseProjectsMock(...a), + createSupabaseProject: (...a: any[]) => createSupabaseProjectMock(...a), + generateDbPassword: () => 'generated-password', + // Whatever the run does after creating a project is not what these tests are about, and the + // real ones poll Supabase until it answers. + waitUntilSupabaseHealthy: async (_t: string, _r: string) => ({ id: '2', name: 'later' }), + resolveSupabaseConnection: async () => { + throw new Error('stop the run here') + } +})) + +const existsVariableMock = vi.fn() +const getVariableMock = vi.fn() +const getResourceMock = vi.fn() +const createVariableMock = vi.fn() +const getSettingsMock = vi.fn() +const editDataTableConfigMock = vi.fn() +const testDataTableConnectionMock = vi.fn() +const setupCustomInstanceDbMock = vi.fn() +vi.mock('$lib/gen', () => ({ + VariableService: { + existsVariable: (...a: any[]) => existsVariableMock(...a), + getVariable: (...a: any[]) => getVariableMock(...a), + createVariable: (...a: any[]) => createVariableMock(...a), + updateVariable: vi.fn() + }, + ResourceService: { + existsResource: vi.fn(), + getResource: (...a: any[]) => getResourceMock(...a), + createResource: vi.fn(), + updateResource: vi.fn() + }, + SettingService: { setupCustomInstanceDb: (...a: any[]) => setupCustomInstanceDbMock(...a) }, + WorkspaceService: { + getSettings: (...a: any[]) => getSettingsMock(...a), + editDataTableConfig: (...a: any[]) => editDataTableConfigMock(...a), + testDataTableConnection: (...a: any[]) => testDataTableConnectionMock(...a) + } +})) + +import { + intentComplete, + newResourceParts, + newWizardState, + runSetup, + type WizardState +} from './addDataTableModel' +import { noClaims } from './setupClaims' + +/** Nothing at the path: the reads that answer "is this ours?" find no object. */ +function nothingThere() { + getVariableMock.mockRejectedValue(new Error('not found')) + getResourceMock.mockRejectedValue(new Error('not found')) +} + +/** A resource that exists, with the timestamp the claim is marked by. */ +function resourceEditedAt(at: string) { + getResourceMock.mockResolvedValue({ path: 'p', created_by: 'alice', edited_at: at }) +} + +/** A wizard about to create the Supabase project `later`, in the organization `acme`. */ +function creating(): WizardState { + const state = newWizardState({ name: 'main', projectName: 'later', folder: 'f/team' }) + state.provider = 'supabase' + state.supabase.mode = 'create' + state.supabase.org = 'acme' + state.review.resourceName = 'db' + return state +} + +/** The path `creating()` writes to, and where an earlier attempt's password would sit. */ +const MINTED_PATH = 'f/team/db' + +const deps = (createdProjectName?: string, createdProjectPath = MINTED_PATH) => ({ + workspace: 'w', + supabaseToken: 'token', + onProgress: () => {}, + claims: noClaims, + username: 'alice', + createdProjects: createdProjectName + ? [{ name: createdProjectName, path: createdProjectPath }] + : [] +}) + +// `writeSecret` overwrites in place, and Supabase never shows a project's password twice, so +// minting a second one at the path where an earlier project's is stored destroys the only copy. +describe('runSetup refusing to mint over a project it already created', () => { + beforeEach(() => { + vi.clearAllMocks() + existsVariableMock.mockResolvedValue(false) + nothingThere() + }) + + it('refuses while the earlier project is still there', async () => { + listSupabaseProjectsMock.mockResolvedValue([ + { id: '1', name: 'earlier', organization_id: 'acme' } + ]) + const result = await runSetup(creating(), deps('earlier')) + expect(result.ok).toBe(false) + expect(result.error).toContain('earlier') + expect(createVariableMock).not.toHaveBeenCalled() + expect(createSupabaseProjectMock).not.toHaveBeenCalled() + }) + + // The name is also recorded when a create could not be confirmed -- an expired token answers + // neither the create nor the lookup. Refusing on that forever would strand the session. + it('proceeds when no project by that name exists after all', async () => { + listSupabaseProjectsMock.mockResolvedValue([]) + createSupabaseProjectMock.mockResolvedValue({ id: '2', name: 'later' }) + await runSetup(creating(), deps('earlier')) + expect(createSupabaseProjectMock).toHaveBeenCalled() + }) + + // Connecting the created project as an existing one reaches the same secret by another + // route: the project list on step 2 is where it now appears, so this is the likely move. + it('refuses to write over the secret from the existing-project branch', async () => { + const state = creating() + state.supabase.mode = 'existing' + state.supabase.project = { id: '1', name: 'earlier' } as any + state.supabase.password = 'typed-by-hand' + const result = await runSetup(state, deps('earlier')) + expect(result.ok).toBe(false) + expect(result.error).toContain(MINTED_PATH) + expect(createVariableMock).not.toHaveBeenCalled() + }) + + // Aimed somewhere else, there is nothing to protect -- and over-refusing here would block + // the ordinary way out of every refusal above, which is to choose another path. + it('writes when the run is aimed at a different path', async () => { + const state = creating() + state.supabase.mode = 'existing' + state.supabase.project = { id: '1', name: 'earlier' } as any + state.supabase.password = 'typed-by-hand' + await runSetup(state, deps('earlier', 'f/team/somewhere-else')) + expect(createVariableMock).toHaveBeenCalled() + }) + + // The organization selected now is not the one the earlier project was created under, and + // switching it is one of the ways to arrive here. + it('refuses a project listed under a different organization', async () => { + listSupabaseProjectsMock.mockResolvedValue([ + { id: '1', name: 'earlier', organization_id: 'other-org' } + ]) + const result = await runSetup(creating(), deps('earlier')) + expect(result.ok).toBe(false) + expect(createSupabaseProjectMock).not.toHaveBeenCalled() + }) +}) + +// The instance branch is the one that has to write its row before it can probe it, since the +// probe is by data table name. A database Windmill cannot store data in must not stay in the +// config -- and a probe that throws leaves exactly the same unusable row as one that says no. +describe('runSetup rolling the instance row back', () => { + function usingInstanceDb(): WizardState { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'instance' + state.instance = { mode: 'existing', dbName: 'shared' } + return state + } + + // The rollback reads the config back before deleting, so the config has to behave like one: + // a mock that always answers empty would let a rollback that never finds its own row pass. + let datatables: Record + + beforeEach(() => { + vi.clearAllMocks() + datatables = {} + getSettingsMock.mockImplementation(async () => ({ datatable: { datatables } })) + editDataTableConfigMock.mockImplementation(async ({ requestBody }: any) => { + datatables = { ...requestBody.settings.datatables } + }) + setupCustomInstanceDbMock.mockResolvedValue({ success: true, logs: {} }) + nothingThere() + }) + + // The pre-flight runs once, before a Supabase create that can take minutes, and every + // wizard suggests the same `main` -- so the name can be taken by the time the row is + // written. Repointing it would hand another admin's data table a database nobody chose. + it('refuses a name that was taken while it was running', async () => { + datatables = { main: { database: { resource_path: 'someone-else' } } } + const result = await runSetup(usingInstanceDb(), { + workspace: 'w', + onProgress: () => {}, + claims: noClaims, + username: 'alice', + createdProjects: [] + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('main') + expect(editDataTableConfigMock).not.toHaveBeenCalled() + }) + + // Rolling back is just as dangerous once someone else owns the name: the row under it is + // no longer the one this run wrote. + it('leaves a row it no longer recognises alone', async () => { + // Repointed by someone else while this run was probing it. + testDataTableConnectionMock.mockImplementation(async () => { + datatables = { main: { database: { resource_path: 'someone-else' } } } + throw new Error('connection refused') + }) + const result = await runSetup(usingInstanceDb(), { + workspace: 'w', + onProgress: () => {}, + claims: noClaims, + username: 'alice', + createdProjects: [] + } as any) + expect(result.ok).toBe(false) + expect(result.rowRolledBack).toBe(false) + // One call: the write. The rollback found a row it did not write and left it. + expect(editDataTableConfigMock).toHaveBeenCalledTimes(1) + // And the name is not handed back as ours: claiming it would let Try again write over + // the row the other admin now owns. + expect(result.rowWritten).toBe(false) + }) + + it('takes the row back out when the probe never answers', async () => { + testDataTableConnectionMock.mockRejectedValue(new Error('connection refused')) + const result = await runSetup(usingInstanceDb(), { + workspace: 'w', + supabaseToken: undefined, + onProgress: () => {}, + claims: noClaims, + username: 'alice', + createdProjects: [] + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('connection refused') + expect(result.rowRolledBack).toBe(true) + expect(result.rowWritten).toBe(false) + const lastWrite = editDataTableConfigMock.mock.calls.at(-1)?.[0] + expect(lastWrite.requestBody.settings.datatables).not.toHaveProperty('main') + }) +}) + +// The fields are the connection; a connection string is a way of writing one down. Reading the +// resource back out of the string is what let a URI grammar gap change what got saved. +describe('newResourceParts', () => { + function typedByHand(): WizardState { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'resource' + state.own.creating = true + state.own.fields = { + host: 'db.example.com', + port: 5432, + dbname: 'mydb', + user: 'u', + password: 'p', + sslmode: 'prefer' + } + return state + } + + it('reads the fields whichever notation is on screen', () => { + const state = typedByHand() + state.own.form = 'string' + state.own.connectionString = 'postgres://u:p@db.example.com:5432/mydb' + // The string names no sslmode. The choice on the fields is what gets saved. + expect(newResourceParts(state)?.sslmode).toBe('prefer') + state.own.form = 'fields' + expect(newResourceParts(state)?.sslmode).toBe('prefer') + }) + + it('is unaffected by a string that cannot be parsed', () => { + const state = typedByHand() + state.own.form = 'string' + state.own.connectionString = 'not a uri' + expect(newResourceParts(state)?.host).toBe('db.example.com') + }) +}) + +// `created_by` survives an update, so it cannot tell an edit by somebody else from no edit at +// all. The claim is marked by `edited_at`, which moves on every write. +describe('runSetup writing over a resource', () => { + function ownResource(): WizardState { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'resource' + state.own.creating = true + state.review.resourceName = 'db' + state.own.fields = { + host: 'h', + port: 5432, + dbname: 'd', + user: 'u', + password: 'p', + sslmode: 'require' + } + return state + } + + beforeEach(() => { + vi.clearAllMocks() + existsVariableMock.mockResolvedValue(false) + getVariableMock.mockRejectedValue(new Error('not found')) + getSettingsMock.mockResolvedValue({ datatable: { datatables: {} } }) + editDataTableConfigMock.mockResolvedValue(undefined) + testDataTableConnectionMock.mockResolvedValue({ can_create_table: true }) + }) + + it('refuses a resource edited since this run claimed it', async () => { + resourceEditedAt('2026-01-02T00:00:00Z') + const result = await runSetup(ownResource(), { + workspace: 'w', + onProgress: () => {}, + // Claimed when it looked like this; someone has written to it since. + claims: [{ kind: 'resource' as const, path: 'f/team/db', mark: '2026-01-01T00:00:00Z' }], + username: 'alice', + createdProjects: [] + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('f/team/db') + }) +}) + +describe('runSetup writing over its own secret', () => { + const ownDb = (): WizardState => { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'resource' + state.own.creating = true + state.review.resourceName = 'db' + state.own.fields = { + host: 'h', + port: 5432, + dbname: 'd', + user: 'u', + password: 'p', + sslmode: 'require' + } + return state + } + + beforeEach(() => { + vi.clearAllMocks() + getResourceMock.mockRejectedValue(new Error('not found')) + getSettingsMock.mockResolvedValue({ datatable: { datatables: {} } }) + editDataTableConfigMock.mockResolvedValue(undefined) + testDataTableConnectionMock.mockResolvedValue({ can_create_table: true }) + }) + + // The same person editing the variable in another tab leaves `edited_by` unchanged, so an + // author is not enough to tell that write from none. + it('refuses a secret edited since this run claimed it, even by the same user', async () => { + getVariableMock.mockResolvedValue({ edited_by: 'alice', edited_at: '2026-01-02T00:00:00Z' }) + const result = await runSetup(ownDb(), { + workspace: 'w', + onProgress: () => {}, + claims: [{ kind: 'secret' as const, path: 'f/team/db', mark: '2026-01-01T00:00:00Z' }], + username: 'alice', + createdProjects: [] + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('f/team/db') + }) + + // A create whose confirmation also failed records the project name pessimistically. The + // variable it wrote is still its own, and a retry has to be able to reuse the path. + it('reuses the variable a previous attempt wrote when its project was never confirmed', async () => { + getVariableMock.mockResolvedValue({ edited_by: 'alice', edited_at: '2026-01-01T00:00:00Z' }) + listSupabaseProjectsMock.mockResolvedValue([]) + createSupabaseProjectMock.mockResolvedValue({ id: '2', name: 'later' }) + const state = creating() + const result = await runSetup(state, { + ...deps('later'), + claims: [{ kind: 'secret' as const, path: MINTED_PATH, mark: '2026-01-01T00:00:00Z' }] + } as any) + expect(result.error ?? '').not.toContain('was created at') + }) +}) + +// Editing a valid string into an invalid one keeps the fields, so they stay correctable. What +// must not happen is testing or saving those fields while the string on screen says otherwise. +describe('intentComplete with a connection string on screen', () => { + function typed(connectionString: string): WizardState { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'resource' + state.own.creating = true + state.own.form = 'string' + state.own.connectionString = connectionString + state.own.fields = { + host: 'db.example.com', + port: 5432, + dbname: 'mydb', + user: 'u', + password: 'p', + sslmode: 'require' + } + return state + } + + it('refuses a string that will not parse, whatever the fields still hold', () => { + expect(intentComplete(typed('postgres://u:p@db.example.com:5432/mydb'))).toBe(true) + expect(intentComplete(typed('postgres://u:p@db.exa'))).toBe(false) + expect(intentComplete(typed(''))).toBe(false) + }) + + it('is unaffected once the fields are the notation on screen', () => { + const state = typed('nonsense') + state.own.form = 'fields' + expect(intentComplete(state)).toBe(true) + }) +}) + +// Each created project guards its own path. Keeping only the latest let a second attempt at +// another path unlock the first project's password, which Supabase will never show again. +describe('runSetup guarding more than one created project', () => { + beforeEach(() => { + vi.clearAllMocks() + existsVariableMock.mockResolvedValue(false) + nothingThere() + }) + + it('still refuses the first project’s path after a second was created elsewhere', async () => { + listSupabaseProjectsMock.mockResolvedValue([ + { id: '1', name: 'first', organization_id: 'acme' } + ]) + const state = creating() + const result = await runSetup(state, { + ...deps(), + createdProjects: [ + { name: 'first', path: MINTED_PATH }, + { name: 'second', path: 'f/team/other' } + ] + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('first') + expect(createVariableMock).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/lib/components/workspaceSettings/addDataTableModel.ts b/frontend/src/lib/components/workspaceSettings/addDataTableModel.ts new file mode 100644 index 0000000000..e01987c3ad --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/addDataTableModel.ts @@ -0,0 +1,853 @@ +/** + * Everything the "add a data table" wizard collects, and the one function that acts on it. + * + * The wizard writes nothing until the user finishes: steps 1 and 2 gather intent, step 3 + * reviews it, and `runSetup` performs it. That ordering is what lets the review step show + * the resource path before the resource exists. + * + * `runSetup` is also what Try again calls, so every step has to tolerate the results of a + * previous attempt still being there. + */ + +import { + ResourceService, + SettingService, + VariableService, + WorkspaceService, + type TestDataTableConnectionResponse +} from '$lib/gen' +import type { SetupStep } from '../wizards/SetupChecklist.svelte' +import { instanceSetupSteps } from './instanceDbSteps' +import { claim, stillOurs, type Claims } from './setupClaims' +import { probeDatatableConnection } from './datatableProbe' +import { + DEFAULT_SSLMODE, + parsePostgresConnectionString, + unsupportedConnectionParam, + type PostgresConnectionParts +} from '$lib/utils/postgresConnectionString' +import { + createSupabaseProject, + generateDbPassword, + resolveSupabaseConnection, + listSupabaseProjects, + projectOrg, + projectRef, + orgSlug, + supabaseResourceValue, + waitUntilSupabaseHealthy, + DEFAULT_SUPABASE_REGION, + type SupabaseConnectionMode, + type SupabaseOrg, + type SupabaseProject +} from './supabaseProvisioning' + +export type Provider = 'supabase' | 'instance' | 'resource' + +export type WizardState = { + step: 1 | 2 | 3 + provider: Provider | undefined + supabase: { + mode: 'existing' | 'create' + project: SupabaseProject | undefined + password: string + /** + * The whole organization, not its slug: the API is called with the slug, but a slug is a + * random string and the review step has a person reading it. + */ + org: SupabaseOrg | undefined + region: string + projectName: string + connectionMode: SupabaseConnectionMode + } + instance: { mode: 'existing' | 'create'; dbName: string | undefined } + /** + * One list: the workspace's Postgres resources, plus the one about to exist. A + * connection string is not an alternative to a resource, it is how one is written -- + * so `creating` and `resourcePath` are the two ways of answering the same question and + * are never both set. + */ + own: { + resourcePath: string | undefined + creating: boolean + /** Which notation the new resource is being entered in. Same object either way. */ + form: 'string' | 'fields' + connectionString: string + fields: PostgresConnectionParts + /** The resource fields no URI can carry, so they belong to neither notation. */ + advanced: PostgresAdvanced + } + review: { name: string; folder: string; resourceName: string } + /** Result of validating what step 2 collected. Cleared whenever its input changes. */ + probe: { + checking: boolean + report: TestDataTableConnectionResponse | undefined + error: string | undefined + } +} + +export function newWizardState(defaults: { + name: string + projectName: string + folder: string +}): WizardState { + return { + step: 1, + provider: undefined, + supabase: { + // Nothing is chosen yet; the step decides between the two once it knows whether the + // account has any projects. `create` here would be indistinguishable from the user + // having picked "New project", which is what survives a Back out of the step. + mode: 'existing', + project: undefined, + password: '', + org: undefined, + region: DEFAULT_SUPABASE_REGION, + projectName: defaults.projectName, + connectionMode: 'session' + }, + instance: { mode: 'create', dbName: undefined }, + own: { + resourcePath: undefined, + creating: false, + form: 'string', + connectionString: '', + fields: emptyFields(), + advanced: emptyAdvanced() + }, + review: { name: defaults.name, folder: defaults.folder, resourceName: '' }, + probe: { checking: false, report: undefined, error: undefined } + } +} + +export function clearProbe(state: WizardState) { + state.probe = { checking: false, report: undefined, error: undefined } +} + +/** Path of the resource and secret variable the run will write. They share one. */ +export function resourcePathOf(state: WizardState): string { + return `${state.review.folder}/${state.review.resourceName}` +} + +/** True once the branch has everything `runSetup` needs. */ +export function intentComplete(state: WizardState): boolean { + if (state.provider === 'supabase') { + return state.supabase.mode === 'create' + ? !!state.supabase.projectName.trim() && !!state.supabase.org + : !!state.supabase.project && !!state.supabase.password + } + if (state.provider === 'instance') return !!state.instance.dbName?.trim() + if (!state.own.creating) return !!state.own.resourcePath + // Text that will not parse leaves the fields on their last good values, which is what makes + // it correctable -- but the connection on screen is then not the one they describe, and + // testing or saving the old one behind an unparseable string points the data table + // somewhere nobody asked for. + if ( + state.own.form === 'string' && + (!parsePostgresConnectionString(state.own.connectionString) || + unsupportedConnectionParam(state.own.connectionString)) + ) + return false + return !!newResourceParts(state) +} + +/** + * The `postgresql` fields outside the connection-string vocabulary: TLS verification and + * AWS IAM auth. Kept apart from the parts so composing a string cannot appear to drop them. + */ +export type PostgresAdvanced = { + root_certificate_pem: string + /** + * Undefined is meaningful: the backend then verifies only when a root certificate is + * present. Only ever set by an explicit choice. + */ + accept_invalid_certs: boolean | undefined + use_iam_auth: boolean + region: string +} + +function emptyAdvanced(): PostgresAdvanced { + return { + root_certificate_pem: '', + accept_invalid_certs: undefined, + use_iam_auth: false, + region: '' + } +} + +/** Whether anything was set, so a notation that cannot show them can say they apply. */ +export function hasAdvanced(advanced: PostgresAdvanced): boolean { + return ( + !!advanced.root_certificate_pem.trim() || + advanced.accept_invalid_certs !== undefined || + advanced.use_iam_auth || + !!advanced.region.trim() + ) +} + +function emptyFields(): PostgresConnectionParts { + return { + host: '', + port: 5432, + dbname: 'postgres', + user: '', + password: '', + sslmode: DEFAULT_SSLMODE + } +} + +const RESERVED_DB_NAMES = ['template0', 'template1', 'postgres'] +const VALID_DB_NAME = /^[a-zA-Z][a-zA-Z0-9_-]*$/ + +/** + * Why `setup_custom_instance_db` would refuse this name, checked as it is typed. Deliberately + * not exhaustive -- the backend stays the authority, this only catches what the browser + * already knows. Empty is incomplete rather than wrong. + */ +export function instanceDbNameError(name: string, existing: Iterable): string | undefined { + const trimmed = name.trim() + if (!trimmed) return undefined + if (trimmed.length > 63) return 'A database name cannot exceed 63 characters.' + if (!VALID_DB_NAME.test(trimmed)) + return 'Start with a letter, then letters, digits, underscores or hyphens only.' + if (RESERVED_DB_NAMES.includes(trimmed.toLowerCase())) + return `${trimmed} is a reserved PostgreSQL database name.` + if (new Set(existing).has(trimmed)) + return `A database called ${trimmed} already exists on this instance.` + return undefined +} + +const VALID_DATATABLE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$/ + +/** + * Why `edit_datatable_config` would refuse this name, checked as it is typed because the write + * is the *last* step of the run: by the time the backend rejects it a Supabase project may + * have been billed. `existing` are the names already in the workspace. + */ +export function datatableNameError(name: string, existing: Iterable): string | undefined { + const trimmed = name.trim() + if (!trimmed) return undefined + if (new Set(existing).has(trimmed)) + return `A data table called ${trimmed} already exists in this workspace.` + // `validate_datatable_path_segment` runs first on the backend and rejects `..` outright, + // before the charset check the regex below mirrors. + if (trimmed.includes('..')) return "A data table name cannot contain '..'." + if (!VALID_DATATABLE_NAME.test(trimmed)) + return "Start with a letter or digit, then letters, digits, '_', '-' and '.' only — the name has to survive being synced to a git repository." + return undefined +} + +/** + * What the new resource describes. The fields are the connection; a connection string is a way + * of writing one down, parsed into the fields as it is typed. Reading it back out here instead + * would put every gap in the URI grammar between the user and what gets saved. + */ +export function newResourceParts(state: WizardState): PostgresConnectionParts | undefined { + const fields = state.own.fields + return fields.host.trim() && fields.user.trim() ? fields : undefined +} + +/** + * Those parts as a `postgresql` resource value -- the one shape everything downstream sees, + * so nothing after this point knows which notation produced it. The password is the + * caller's: the literal one when testing before anything is saved, a `$var:` reference once + * it has somewhere to live. + */ +export function postgresResourceValue( + parts: PostgresConnectionParts, + password: string, + advanced: PostgresAdvanced +): Record { + return { + host: parts.host, + user: parts.user, + port: parts.port ?? 5432, + dbname: parts.dbname || 'postgres', + sslmode: parts.sslmode || DEFAULT_SSLMODE, + password, + region: advanced.region, + root_certificate_pem: advanced.root_certificate_pem, + use_iam_auth: advanced.use_iam_auth, + // Omitted rather than sent as false: absent is its own state, and the one every + // resource that predates the flag is in. + ...(advanced.accept_invalid_certs !== undefined + ? { accept_invalid_certs: advanced.accept_invalid_certs } + : {}) + } +} + +/** + * The connection value a branch can be validated against before anything is saved. + * Undefined for branches with nothing to validate yet: creating a Supabase project has no + * database to reach, and an instance database does not exist until setup runs. + */ +export function probeValue(state: WizardState): Record | undefined { + if (state.provider !== 'resource' || !state.own.creating) return undefined + const parts = newResourceParts(state) + return parts ? postgresResourceValue(parts, parts.password ?? '', state.own.advanced) : undefined +} + +/** + * Where the Supabase project will live, for the review step to state plainly. Read off + * the project when it already exists, off what was picked when it is about to be created. + */ +export function supabaseSummary(state: WizardState): { org?: string; region?: string } { + if (state.supabase.mode === 'create') + return { org: state.supabase.org?.name, region: state.supabase.region } + const project = state.supabase.project + return { + // The name when the organization is known, its identifier only as a last resort. + org: state.supabase.org?.name ?? (project ? projectOrg(project) : undefined), + region: project?.region + } +} + +export type RunStepKey = + | 'create_project' + | 'wait_healthy' + | 'save_credentials' + | 'setup_instance' + | 'check' + +/** + * The steps this branch will run, in order. The key drives the runner and the title only + * the display, so rewording a step cannot change what it does. + */ +export function plan(state: WizardState): { key: RunStepKey; title: string }[] { + const path = resourcePathOf(state) + const steps: { key: RunStepKey; title: string }[] = [] + if (state.provider === 'supabase') { + if (state.supabase.mode === 'create') { + steps.push({ + key: 'create_project', + title: `Creating ${state.supabase.projectName.trim()} on Supabase` + }) + steps.push({ key: 'wait_healthy', title: 'Waiting for the database to start' }) + } + steps.push({ key: 'save_credentials', title: `Saving credentials to ${path}` }) + } else if (state.provider === 'instance') { + steps.push({ + key: 'setup_instance', + title: `Setting up ${state.instance.dbName} in the Windmill database` + }) + } else if (state.own.creating) { + steps.push({ key: 'save_credentials', title: `Saving the connection to ${path}` }) + } + steps.push({ key: 'check', title: 'Checking Windmill can store data' }) + return steps +} + +/** The same plan as a checklist, all pending. */ +export function planSteps(state: WizardState): SetupStep[] { + return plan(state).map((s) => ({ title: s.title, status: 'pending' })) +} + +/** A Supabase project this session created, and the path holding its only password. */ +export type CreatedProject = { name: string; path: string } + +export type RunDeps = { + workspace: string + /** Required for the Supabase branch. */ + supabaseToken?: string + /** So the settings page's pool reflects a database this run created. */ + onInstanceDbsChanged?: () => Promise + onProgress: (steps: SetupStep[]) => void + /** Session pooling was asked for but could not be read; a direct host was written. */ + onPoolerUnavailable?: (reason: string) => void + /** + * The Supabase project an earlier attempt in this session created. Minting a second password + * over the first one's variable would lose the only copy of credentials Supabase will not + * repeat, so a run that would do that refuses -- but only once it has seen that the project + * is really there, since the name is also recorded when a create could not be confirmed. + */ + createdProjects: CreatedProject[] + /** + * What earlier attempts in this session wrote, and this one may therefore write over again. + * The pre-flight checks the names are free, but the Supabase branch then spends minutes + * provisioning, and every wizard suggests the same `main` -- so a second admin can take the + * name or the path in between. + */ + claims: Claims + /** Stands in as the mark where the object was written but its timestamp could not be read back. */ + username: string +} + +export type RunResult = { + ok: boolean + report?: TestDataTableConnectionResponse + error?: string + /** + * The workspace config still holds this data table. False when the run never got that far, + * and when a refused instance database was taken back out again -- so the name is free and + * the caller must not claim it. + */ + rowWritten?: boolean + /** A row this run had written is gone again, so a claim on the name has to go with it. */ + rowRolledBack?: boolean + /** + * Every project created this session, each guarding the path holding its only password. + * Supabase never shows that password again, so the variable there is the only copy and no + * later attempt may write over it. + */ + createdProjects: CreatedProject[] + /** What this run holds now, for the next attempt to be given back. */ + claims: Claims +} + +/** + * Why a run will not write at a path that already holds a created project's password. Names + * the path the password is actually at, which is not always the one the wizard is pointing at + * now -- the review step can be edited after a failure. + */ +function createdSecretRefusal(projectName: string, passwordPath: string): string { + return `The password of the Supabase project ${projectName}, which this setup created, is stored at ${passwordPath}. Writing here would replace it and Supabase cannot show that password again. Name the project ${projectName} again to carry on with it, or use a different path.` +} + +async function exists(kind: 'variable' | 'resource', workspace: string, path: string) { + return kind === 'variable' + ? VariableService.existsVariable({ workspace, path }) + : ResourceService.existsResource({ workspace, path }) +} + +/** + * Adds the data table to the workspace config, once everything it points at exists. + * `edit_datatable_config` replaces the whole map, so the rest is read back and sent with + * it. Re-runnable: a second attempt overwrites the entry it wrote. + */ +async function writeRow( + deps: RunDeps, + claims: Claims, + name: string, + database: { resource_type: 'postgresql' | 'instance'; resource_path: string } +): Promise { + const settings = await WorkspaceService.getSettings({ workspace: deps.workspace }) + const datatables: Record = { ...(settings.datatable?.datatables ?? {}) } + // Free when the pre-flight looked, taken by the time we write: repointing it here would + // silently hand another admin's data table a database they never chose. + if ( + datatables[name] && + !stillOurs(claims, 'row', name, datatables[name]?.database?.resource_path) + ) { + throw new Error( + `A data table called ${name} was created while this setup was running. Choose another name and try again.` + ) + } + datatables[name] = { ...(datatables[name] ?? {}), database } + await WorkspaceService.editDataTableConfig({ + workspace: deps.workspace, + requestBody: { settings: { datatables }, renames: [], deleted_datatables: [] } + }) + return claim(claims, 'row', name, database.resource_path) +} + +/** + * `removed` — the row this run wrote is gone. `kept` — the undo could not reach the server, so + * it is still there and the caller has to keep saying so. `foreign` — the name now points + * somewhere this run never wrote, so there is nothing of ours to take back. + */ +type Rollback = 'removed' | 'kept' | 'foreign' + +async function removeRow(deps: RunDeps, claims: Claims, name: string): Promise { + try { + const settings = await WorkspaceService.getSettings({ workspace: deps.workspace }) + const datatables: Record = { ...(settings.datatable?.datatables ?? {}) } + // Only take back the row this run put there. Between writing it and probing it, another + // admin can have pointed the same name somewhere else, and deleting that is worse than + // leaving ours behind. + if (!stillOurs(claims, 'row', name, datatables[name]?.database?.resource_path)) return 'foreign' + delete datatables[name] + // Not `deleted_datatables`: that exists to cascade migration bookkeeping and deployment + // records for a data table that was really in use, and this one never got that far. + await WorkspaceService.editDataTableConfig({ + workspace: deps.workspace, + requestBody: { settings: { datatables }, renames: [], deleted_datatables: [] } + }) + return 'removed' + } catch { + return 'kept' + } +} + +/** + * The read answers both questions at once: whether anything is there, and who last wrote it. + * Replacing this run's own work is required for Try again; replacing anyone else's loses a + * generated Supabase password, which Supabase never shows twice. + */ +async function writeSecret( + deps: RunDeps, + claims: Claims, + path: string, + value: string, + description: string +): Promise { + const held = await secretMark(deps, path) + if (held) { + if (!stillOurs(claims, 'secret', path, held)) throw new Error(pathTakenLate('variable', path)) + await VariableService.updateVariable({ + workspace: deps.workspace, + path, + requestBody: { value, is_secret: true } + }) + } else { + await VariableService.createVariable({ + workspace: deps.workspace, + requestBody: { path, value, is_secret: true, description, is_oauth: false } + }) + } + return claim(claims, 'secret', path, (await secretMark(deps, path)) ?? deps.username) +} + +/** + * A revision, not an author: the same person editing the variable in another tab leaves + * `edited_by` unchanged, and that write is no more ours to discard than a stranger's. + * `undefined` when nothing is there. + */ +async function secretMark(deps: RunDeps, path: string): Promise { + // `decryptSecret` defaults to true, and the handler audit-logs a decryption when it does. + // Only the timestamp is wanted, and it is on the response either way -- asking for the + // plaintext records decrypting a secret nothing reads, including someone else's on the + // retry that is about to refuse it. + const held = await VariableService.getVariable({ + workspace: deps.workspace, + path, + decryptSecret: false + }).catch(() => undefined) + return held ? (held.edited_at ?? held.edited_by ?? '') : undefined +} + +function pathTakenLate(kind: 'variable' | 'resource', path: string): string { + return `A ${kind} was created at ${path} while this setup was running. Choose another path and try again.` +} + +async function writeResource( + deps: RunDeps, + claims: Claims, + path: string, + value: Record, + description: string +): Promise { + const held = await resourceMark(deps, path) + if (held) { + if (!stillOurs(claims, 'resource', path, held)) throw new Error(pathTakenLate('resource', path)) + await ResourceService.updateResource({ + workspace: deps.workspace, + path, + requestBody: { value, description } + }) + } else { + await ResourceService.createResource({ + workspace: deps.workspace, + requestBody: { resource_type: 'postgresql', path, value, description } + }) + } + // Read back rather than claim the username: `created_by` survives an update, so it cannot + // tell an edit by somebody else from no edit at all. `edited_at` moves on every write, which + // is what makes the next attempt able to see one that happened in between. + return claim(claims, 'resource', path, (await resourceMark(deps, path)) ?? deps.username) +} + +/** `undefined` when nothing is there. */ +async function resourceMark(deps: RunDeps, path: string): Promise { + const held = await ResourceService.getResource({ workspace: deps.workspace, path }).catch( + () => undefined + ) + return held ? (held.edited_at ?? held.created_by ?? '') : undefined +} + +/** + * Performs what the wizard collected, reporting each step as it goes. + * + * Every step is safe to re-run, because Try again runs the whole plan a second time: + * each one upserts rather than assuming what it creates is absent. + */ +export async function runSetup(state: WizardState, deps: RunDeps): Promise { + const planned = plan(state) + const steps: SetupStep[] = planned.map((s) => ({ title: s.title, status: 'pending' })) + let index = 0 + const advance = ( + status: 'running' | 'done' | 'failed', + description?: string, + substeps?: SetupStep[] + ) => { + steps[index] = { + ...steps[index], + status, + description, + substeps: substeps ?? steps[index].substeps + } + deps.onProgress([...steps]) + } + let rowWritten = false + let rowRolledBack = false + let claims = deps.claims + let createdProjects: CreatedProject[] = [...deps.createdProjects] + /** Records a created project once, so a second attempt cannot displace the first one's guard. */ + const rememberProject = (name: string, at: string) => { + if (!createdProjects.some((p) => p.path === at)) + createdProjects = [...createdProjects, { name, path: at }] + } + const fail = (message: string): RunResult => { + advance('failed', message) + return { + ok: false, + error: message, + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } + + const path = resourcePathOf(state) + const name = state.review.name.trim() + /** + * An earlier attempt stored a created project's password here. Supabase hands that out once + * and every write upserts, so every route back to this path refuses. Each created project + * guards its own path -- checking only the latest unlocked the earlier one's password. + */ + const guardedHere = deps.createdProjects.find((p) => p.path === path) + const instanceName = state.instance.dbName?.trim() ?? '' + + let project = state.supabase.project + let resourcePath = + state.provider === 'resource' && !state.own.creating ? state.own.resourcePath! : path + + for (; index < planned.length; index++) { + advance('running') + try { + if (planned[index].key === 'create_project') { + // The password is generated here and can never be read back from Supabase, so it + // is written to the secret variable before the project that uses it exists. A run + // that dies right after creation is then still repairable; the reverse order + // would strand a billed project nobody holds the password to. + const wanted = state.supabase.projectName.trim() + const inOrg = (name: string) => (p: SupabaseProject) => + p.name === name && (!state.supabase.org || projectOrg(p) === orgSlug(state.supabase.org)) + const projects = await listSupabaseProjects(deps.supabaseToken!) + const existing = projects.find(inOrg(wanted)) + if (existing) { + if (!(await exists('variable', deps.workspace, path))) { + // A project this same session created is the one case where the password is + // held after all, just not here: the path has been edited since. Saying so + // beats telling someone to reset or delete a project that is working. + const elsewhere = deps.createdProjects.find((p) => p.name === wanted) + if (elsewhere) + return fail( + `The password for ${wanted}, which this setup created, is stored at ${elsewhere.path}, not at ${path}. Set the path back to ${elsewhere.path} to carry on with that project.` + ) + return fail( + `A Supabase project called ${wanted} already exists, but Windmill does not hold its password and Supabase cannot return it. Reset the password in Supabase and connect it as an existing project, or delete the project and retry.` + ) + } + project = existing + } else { + // The project has to still exist for its password to be worth protecting: a name + // recorded from a create that could not be confirmed is a false alarm, and + // refusing on it leaves the session with nothing it can do. Matched by name + // across every organization -- a namesake costs a rename, a miss costs the + // password. + const earlier = guardedHere?.name + if (earlier && projects.some((p) => p.name === earlier)) { + return fail(createdSecretRefusal(earlier, guardedHere!.path)) + } + const password = generateDbPassword() + claims = await writeSecret( + deps, + claims, + path, + password, + `Password for the ${wanted} Supabase database` + ) + try { + project = await createSupabaseProject(deps.supabaseToken!, { + name: wanted, + organizationSlug: orgSlug(state.supabase.org!), + region: state.supabase.region, + dbPass: password + }) + // From here the password in `path` is the only copy of a billed project's + // credentials, and every later write to that path upserts. + rememberProject(wanted, path) + } catch (err) { + // A refusal and a lost response look the same from here, and only one of them + // bills. Ask Supabase which it was: a project that turned up is ours, holds the + // password just written, and is what the rest of the run is for. If even that + // cannot be answered -- an expired token answers nothing -- record the name + // anyway, and let the next attempt's own lookup decide whether it was real. + const appeared = await listSupabaseProjects(deps.supabaseToken!).then( + (after) => after.find(inOrg(wanted)), + () => { + rememberProject(wanted, path) + return undefined + } + ) + if (!appeared) throw err + rememberProject(wanted, path) + project = appeared + } + } + } else if (planned[index].key === 'wait_healthy') { + // Minutes of polling with nothing else to show: hang what Supabase reports off the + // step, so the longest wait in the wizard has something behind its chevron. + project = await waitUntilSupabaseHealthy( + deps.supabaseToken!, + projectRef(project!), + (status) => advance('running', status) + ) + } else if (planned[index].key === 'save_credentials') { + if (state.provider === 'supabase') { + if (state.supabase.mode === 'existing') { + if (guardedHere) return fail(createdSecretRefusal(guardedHere.name, path)) + claims = await writeSecret( + deps, + claims, + path, + state.supabase.password, + `Password for the ${project!.name} Supabase database` + ) + } + const connection = await resolveSupabaseConnection( + deps.supabaseToken!, + project!, + state.supabase.connectionMode + ) + if (connection.mode !== state.supabase.connectionMode) + state.supabase.connectionMode = connection.mode + if (connection.unavailable) deps.onPoolerUnavailable?.(connection.unavailable) + claims = await writeResource( + deps, + claims, + path, + supabaseResourceValue(project!, path, connection), + `Supabase project ${project!.name}` + ) + } else { + if (guardedHere) return fail(createdSecretRefusal(guardedHere.name, path)) + const parts = newResourceParts(state)! + claims = await writeSecret( + deps, + claims, + path, + parts.password ?? '', + `Password for the ${parts.host} database` + ) + claims = await writeResource( + deps, + claims, + path, + postgresResourceValue(parts, `$var:${path}`, state.own.advanced), + `Database for the ${name} data table` + ) + } + } else if (planned[index].key === 'setup_instance') { + // The call reports nothing until it returns, so name the checks it is about to run + // with the first one marked in flight; its answer replaces them when it lands. + // Otherwise the longest step in the wizard is a single line that sits there. + advance('running', undefined, instanceSetupSteps(instanceName, undefined, true)) + const status = await SettingService.setupCustomInstanceDb({ + name: instanceName, + requestBody: { tag: 'datatable' } + }) + await deps.onInstanceDbsChanged?.() + const checks = instanceSetupSteps(instanceName, status, false) + if (!status.success) { + advance('failed', status.error ?? 'Setup failed', checks) + return { + ok: false, + error: status.error ?? 'Setup failed', + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } + advance('running', undefined, checks) + } else if (state.provider === 'instance') { + // An instance data table is probed by name, through the very entry being written + // here, so this is the one branch that cannot check first. A database Windmill + // cannot store data in must not stay in the config, so a refusal takes the row + // back out -- leaving it would also block retrying under the same name. + const database = { resource_type: 'instance' as const, resource_path: instanceName } + claims = await writeRow(deps, claims, name, database) + rowWritten = true + const report = await WorkspaceService.testDataTableConnection({ + workspace: deps.workspace, + datatableName: name + }).catch(async (err) => { + // A probe that never answered leaves the same unusable row behind as one that + // answered no -- an unreachable database or a timeout lands here -- so it takes + // the same way out rather than the bare outer catch. + const rollback = await removeRow(deps, claims, name) + rowRolledBack = rollback === 'removed' + // `foreign` means the name is somebody else's now: our row is not there to + // hand back to the collision checks, and a retry must not write over theirs. + rowWritten = rollback === 'kept' + throw err + }) + if (!report.can_create_table) { + const rollback = await removeRow(deps, claims, name) + rowRolledBack = rollback === 'removed' + rowWritten = rollback === 'kept' + advance('failed', 'The database is reachable but its user cannot create tables.') + return { + ok: false, + report, + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } + advance('done') + return { + ok: true, + report, + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } else { + // Checked through the resource, so nothing is written until the database has proved + // it can hold a data table. + const report = await probeDatatableConnection(deps.workspace, `$res:${resourcePath}`) + if (!report.can_create_table) { + advance('failed', 'The database is reachable but its user cannot create tables.') + return { + ok: false, + report, + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } + claims = await writeRow(deps, claims, name, { + resource_type: 'postgresql', + resource_path: resourcePath + }) + rowWritten = true + advance('done') + return { + ok: true, + report, + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } + advance('done') + } catch (err: any) { + return fail(err?.body ?? err?.message ?? String(err)) + } + } + + return { + ok: true, + rowWritten, + rowRolledBack, + claims, + createdProjects + } +} diff --git a/frontend/src/lib/components/workspaceSettings/datatableProbe.ts b/frontend/src/lib/components/workspaceSettings/datatableProbe.ts new file mode 100644 index 0000000000..38368c4b7f --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/datatableProbe.ts @@ -0,0 +1,108 @@ +/** + * What a database lets the data table's role do, answered by the worker rather than by the API + * server. + * + * It runs as a preview job for the same reason `TestConnection` does: a job goes through the + * worker's Postgres executor, so IAM and Azure workload identity authenticate as the worker + * will when a real query runs. A connection opened from the API server proves something about + * the API server, which is a different machine with a different identity. + * + * Postgres composes the suggested statements itself through `format('%I')`, so identifier + * quoting stays where it is already implemented. + */ + +import { JobService, type Preview, type TestDataTableConnectionResponse } from '$lib/gen' +import { tryEvery } from '$lib/utils' + +const PRIVILEGES = `SELECT current_user AS usr, + current_schema() AS sch, + has_schema_privilege(current_schema(), 'CREATE') AS can_create_table, + has_database_privilege(current_database(), 'CREATE') AS can_create_schema, + to_regclass('_wm_migrations') IS NOT NULL AS has_migrations_table, + -- A role whose search_path names no valid schema has a NULL current_schema(), and + -- format('%I', NULL) raises rather than returning NULL, which would fail the whole + -- query on the one case fix_search_path exists to report. + CASE WHEN current_schema() IS NULL THEN NULL + ELSE format('GRANT CREATE ON SCHEMA %I TO %I', current_schema(), current_user) + END AS grant_schema, + format('GRANT CREATE ON DATABASE %I TO %I', current_database(), current_user) AS grant_database, + format('ALTER ROLE %I SET search_path = public', current_user) AS fix_search_path` + +type Row = { + usr?: string + sch?: string | null + can_create_table?: boolean + can_create_schema?: boolean + has_migrations_table?: boolean + grant_schema?: string | null + grant_database?: string | null + fix_search_path?: string | null +} + +/** + * `database` is whatever a Postgres step takes: the resource value, or a `$res:` path the + * worker resolves. Throws with the database's own message when the query fails, and after + * `timeout` when no worker picks the job up. + */ +export async function probeDatatableConnection( + workspace: string, + database: Record | string, + // Longer than the 20s the worker allows its own Postgres connect, or a host that accepts + // the connection and never answers -- a firewall with no rule for the workers, which this + // check exists to catch -- is cancelled first and reported as a missing worker. + timeout = 30000 +): Promise { + const job = await JobService.runScriptPreview({ + workspace, + requestBody: { + path: 'testConnection: datatable', + language: 'postgresql' as Preview['language'], + content: PRIVILEGES, + args: { database } + } + }) + + let completed: Awaited> | undefined = undefined + await tryEvery({ + tryCode: async () => { + completed = await JobService.getCompletedJob({ workspace, id: job }) + }, + timeoutCode: async () => { + await JobService.cancelQueuedJob({ + workspace, + id: job, + requestBody: { reason: 'The connection check did not start' } + }).catch(() => {}) + }, + interval: 500, + timeout + }) + + if (!completed) { + throw new Error( + 'The connection check did not run. Is a worker listening to the postgresql tag available?' + ) + } + const done = completed as { success: boolean; result?: any } + if (!done.success) { + throw new Error(done.result?.error?.message ?? 'Could not connect to the database') + } + + const row: Row = (Array.isArray(done.result) ? done.result[0] : done.result) ?? {} + // Suggested only where the privilege is actually missing; Postgres returns NULL for a + // statement it could not name, which is the case where no grant would help anyway. + const suggested_grants = [ + row.can_create_table ? undefined : (row.grant_schema ?? undefined), + row.can_create_schema ? undefined : (row.grant_database ?? undefined) + ].filter((s): s is string => !!s) + + return { + user: row.usr ?? '', + schema: row.sch ?? null, + can_create_table: !!row.can_create_table, + can_create_schema: !!row.can_create_schema, + migrations_table_exists: !!row.has_migrations_table, + suggested_grants, + suggested_search_path: row.sch ? undefined : (row.fix_search_path ?? undefined) + } +} diff --git a/frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts b/frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts new file mode 100644 index 0000000000..dd465626f4 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts @@ -0,0 +1,89 @@ +import type { CustomInstanceDb } from '$lib/gen' +import { runningFrom, type SetupStep } from '../wizards/SetupChecklist.svelte' + +/** + * The same checks as [`instanceDbSteps`], in the vocabulary the wizard's checklist speaks. + * Nothing is reported until the call returns, so an unreported step is either the failure + * (when the call errored) or simply not reached yet. + */ +export function instanceSetupSteps( + dbname: string, + status: CustomInstanceDb | undefined, + running: boolean +): SetupStep[] { + let firstUnreported = true + const steps = instanceDbSteps(dbname, status).map((step): SetupStep => { + if (step.status === 'OK') return { ...step, status: 'done' } + if (step.status === 'FAIL') return { ...step, status: 'failed' } + if (step.status === 'SKIP') return { ...step, status: 'skipped' } + const failed = firstUnreported && !!status?.error + firstUnreported = false + return { ...step, status: failed ? 'failed' : 'pending' } + }) + return runningFrom(steps, running) +} + +/** + * The checks `setup_custom_instance_db` reports, in the order it runs them. Shared so the + * setup modal and the data table wizard describe the same failure the same way. + */ +export function instanceDbSteps(dbname: string, status: CustomInstanceDb | undefined) { + return [ + { + title: 'Super admin required', + status: status?.logs.super_admin, + description: + 'You need to be a super admin to create a new database in the Windmill PostgreSQL instance' + }, + { + title: 'Retrieve and parse database credentials', + status: status?.logs.database_credentials, + description: + 'Windmill uses the DATABASE_URL or DATABASE_URL_FILE environment variable to connect to the PostgreSQL instance. Make sure it is correctly set' + }, + { + title: 'Database name is valid', + status: status?.logs.valid_dbname, + description: + 'The database name must be alphanumeric (underscores and hyphens allowed) and cannot be named the same as the Windmill database (usually "windmill")' + }, + { + title: + 'Create database' + + (status?.logs.created_database === 'SKIP' ? ' (already exists, skipped)' : ''), + status: status?.logs.created_database, + description: `In the Windmill PostgreSQL instance, run: CREATE DATABASE "${dbname}".` + }, + { + title: `Connect to the ${dbname} database`, + status: status?.logs.db_connect, + description: + "Connect to the newly created database with the default admin user (the one in DATABASE_URL, usually 'postgres') to run the next commands" + }, + { + title: 'Grant permissions to custom_instance_user', + status: status?.logs.grant_permissions, + description: + 'Gives custom_instance_user the required permissions to use the database. custom_instance_user is already created during a migration and has an auto-generated password stored in global_settings.custom_instance_pg_databases.user_pwd. These are the commands : \n\n' + + `GRANT CONNECT ON DATABASE "${dbname}" TO custom_instance_user;\n` + + 'GRANT USAGE ON SCHEMA public TO custom_instance_user;\n' + + 'GRANT CREATE ON SCHEMA public TO custom_instance_user;\n' + + `GRANT CREATE ON DATABASE "${dbname}" TO custom_instance_user;\n` + + 'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' + + ' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO custom_instance_user;\n' + + 'ALTER ROLE custom_instance_user CREATEROLE;' + }, + { + title: 'Grant replication to custom_instance_replication_user', + status: status?.logs.replication_user, + description: + 'Postgres triggers on custom-instance datatables connect as custom_instance_replication_user, whose password is stored in global_settings.custom_instance_replication_pwd. The role is cluster-wide, so it is created on the Windmill PostgreSQL instance rather than on this database : \n\n' + + 'ALTER ROLE custom_instance_replication_user REPLICATION;\n' + + 'GRANT custom_instance_user TO custom_instance_replication_user;\n\n' + + 'Setting REPLICATION requires a superuser on PostgreSQL 15 and older. Managed instances never grant one, so on AWS RDS Windmill falls back to GRANT rds_replication TO custom_instance_replication_user. The database stays usable for datatables if this step fails, but postgres triggers on them do not.' + + (status?.logs.replication_user_error + ? `\n\nError: ${status.logs.replication_user_error}` + : '') + } + ] +} diff --git a/frontend/src/lib/components/workspaceSettings/setupClaims.test.ts b/frontend/src/lib/components/workspaceSettings/setupClaims.test.ts new file mode 100644 index 0000000000..766d2b5281 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/setupClaims.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + anythingClaimed, + claim, + claimsFromJSON, + claimsToJSON, + noClaims, + release, + stillOurs +} from './setupClaims' + +describe('stillOurs', () => { + it('honours a claim whose object has not moved', () => { + const claims = claim(noClaims, 'secret', 'f/team/db', 'alice') + expect(stillOurs(claims, 'secret', 'f/team/db', 'alice')).toBe(true) + }) + + it('refuses when the object was last written by somebody else', () => { + const claims = claim(noClaims, 'secret', 'f/team/db', 'alice') + expect(stillOurs(claims, 'secret', 'f/team/db', 'bob')).toBe(false) + }) + + // Deleted and recreated between two attempts: something is there, it is not ours. + it('refuses when the object is gone', () => { + const claims = claim(noClaims, 'resource', 'f/team/db', 'alice') + expect(stillOurs(claims, 'resource', 'f/team/db', undefined)).toBe(false) + }) + + it('refuses a path this run never claimed', () => { + expect(stillOurs(noClaims, 'secret', 'f/team/db', 'alice')).toBe(false) + }) + + // The secret and the resource are separate objects at one path. + it('keeps the two objects at one path apart', () => { + const claims = claim(noClaims, 'secret', 'f/team/db', 'alice') + expect(stillOurs(claims, 'secret', 'f/team/db', 'alice')).toBe(true) + expect(stillOurs(claims, 'resource', 'f/team/db', 'alice')).toBe(false) + }) + + it('refuses a row repointed since it was written', () => { + const claims = claim(noClaims, 'row', 'main', 'f/team/db') + expect(stillOurs(claims, 'row', 'main', 'f/team/db')).toBe(true) + expect(stillOurs(claims, 'row', 'main', 'someone-elses-db')).toBe(false) + }) +}) + +describe('claims as a set', () => { + it('replaces the mark when the same object is claimed again', () => { + let claims = claim(noClaims, 'row', 'main', 'first') + claims = claim(claims, 'row', 'main', 'second') + expect(claims).toHaveLength(1) + expect(stillOurs(claims, 'row', 'main', 'second')).toBe(true) + }) + + it('gives a claim up so the name is free again', () => { + const claims = release(claim(noClaims, 'row', 'main', 'x'), 'row', 'main') + expect(anythingClaimed(claims)).toBe(false) + }) + + it('carries every claim across the redirect, whatever kinds are held', () => { + let claims = claim(noClaims, 'secret', 'f/team/db', 'alice') + claims = claim(claims, 'resource', 'f/team/db', 'alice') + claims = claim(claims, 'row', 'main', 'f/team/db') + const restored = claimsFromJSON(JSON.parse(JSON.stringify(claimsToJSON(claims)))) + expect(restored).toEqual(claims) + }) + + it('survives a payload that is not claims at all', () => { + expect(claimsFromJSON(undefined)).toEqual(noClaims) + expect(claimsFromJSON([{ kind: 'nonsense', path: 'p', mark: 'm' }])).toEqual(noClaims) + }) +}) diff --git a/frontend/src/lib/components/workspaceSettings/setupClaims.ts b/frontend/src/lib/components/workspaceSettings/setupClaims.ts new file mode 100644 index 0000000000..48f844d6f9 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/setupClaims.ts @@ -0,0 +1,87 @@ +/** + * What a setup run created, and whether it is still there. + * + * Try again re-runs the whole plan, so every write meets what the previous attempt left behind + * and has to answer one question: is the thing at this path the thing I made? Writing over its + * own work is required; writing over another admin's destroys a password Supabase shows once. + * + * A claim therefore carries a **mark** — the discriminator to compare against the object as it + * is now, rather than trusting that whatever sits at a remembered path is ours. + * + * Values, not runes, so the ownership matrix is testable without mounting a component. + */ + +export type ClaimKind = 'secret' | 'resource' | 'row' + +export type Claim = { + kind: ClaimKind + path: string + /** + * Compared against the live object. It has to move whenever anyone else writes: `edited_at` + * for a secret and a resource — an author survives an edit and so cannot tell one from no + * edit at all — and the target for a row. + */ + mark: string +} + +export type Claims = readonly Claim[] + +export const noClaims: Claims = [] + +function sameObject(a: Claim, kind: ClaimKind, path: string): boolean { + return a.kind === kind && a.path === path +} + +/** Re-claiming an object replaces its mark. */ +export function claim(claims: Claims, kind: ClaimKind, path: string, mark: string): Claims { + return [...claims.filter((c) => !sameObject(c, kind, path)), { kind, path, mark }] +} + +export function claimOf(claims: Claims, kind: ClaimKind, path: string): Claim | undefined { + return claims.find((c) => sameObject(c, kind, path)) +} + +/** Given up when a run takes its own object back out, so the path is free again. */ +export function release(claims: Claims, kind: ClaimKind, path: string): Claims { + return claims.filter((c) => !sameObject(c, kind, path)) +} + +/** + * Whether the object now at `path` is the one this run claimed. `observed` is the mark read back + * from the live object; `undefined` means nothing is there. + */ +export function stillOurs( + claims: Claims, + kind: ClaimKind, + path: string, + observed: string | undefined +): boolean { + const held = claimOf(claims, kind, path) + return !!held && observed !== undefined && held.mark === observed +} + +export function anythingClaimed(claims: Claims): boolean { + return claims.length > 0 +} + +/** + * Carried across the full-page redirect the blocked-popup Supabase leg falls back to. No secret + * travels: a mark is a timestamp or a resource path. + */ +export function claimsToJSON(claims: Claims): Claim[] { + return [...claims] +} + +const KINDS: ClaimKind[] = ['secret', 'resource', 'row'] + +export function claimsFromJSON(value: unknown): Claims { + if (!Array.isArray(value)) return noClaims + return value.filter( + (c): c is Claim => + !!c && + typeof c === 'object' && + typeof (c as Claim).path === 'string' && + typeof (c as Claim).mark === 'string' && + KINDS.includes((c as Claim).kind) + ) +} diff --git a/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts b/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts new file mode 100644 index 0000000000..3e11ec2b9d --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts @@ -0,0 +1,107 @@ +import { fromStore } from 'svelte/store' +import { base } from '$lib/base' +import { oauthStore } from '$lib/stores' + +const OAUTH_WINDOW = 'windmill_supabase_oauth' +const CONNECT_URL = `${base}/api/oauth/connect/supabase_wizard` + +/** + * The Supabase authorization leg, driven from a popup. + * + * A full-page redirect unmounts whatever opened it, so a user who stops to create a Supabase + * account lands on their dashboard with nothing left pointing back. Keeping the flow in a + * popup keeps the host on screen, and keeps the window ours to steer: after they sign up we + * send the same popup back through the connect endpoint and consent follows. + */ +export function useSupabaseOauth( + opts: { + onPopupBlocked?: () => void + /** + * Where popups are blocked, navigate this tab instead of opening a new one. Only for + * hosts that can be resumed afterwards -- a caller whose state dies with the page (a + * half-filled form) must leave this off and keep the user where they are. + */ + redirectIfBlocked?: boolean + /** Even the new tab was refused, so the caller has to say so rather than sit loading. */ + onFallbackBlocked?: () => void + /** The window went away without authorizing; the caller can drop its own waiting state. */ + onAbandoned?: () => void + /** + * Authorization came back and the token is in the store. Reported like the failures + * above so a caller does not have to watch `authed` to find out. Fires on any successful + * authorization, this caller's or another's -- every instance listens on the same window + * -- so a caller that acts on it has to know it was the one waiting. + */ + onAuthed?: () => void + } = {} +) { + const oauth = fromStore(oauthStore) + let pending = $state(false) + let win: Window | null = null + let abandonWatch: ReturnType | undefined = undefined + + $effect(() => { + function onMessage(e: MessageEvent) { + if (e.origin !== window.location.origin || e.data?.type !== 'supabase_oauth') return + oauthStore.set(e.data.res) + pending = false + clearInterval(abandonWatch) + win?.close() + opts.onAuthed?.() + } + window.addEventListener('message', onMessage) + return () => { + window.removeEventListener('message', onMessage) + clearInterval(abandonWatch) + } + }) + + /** + * Nothing arrives if the user closes the window, denies consent, or wanders off to create + * an account first -- which is a link this flow deliberately offers. Watch for the window + * going away, so the button comes back instead of staying disabled until a page reload. + */ + function watchForAbandon() { + clearInterval(abandonWatch) + abandonWatch = setInterval(() => { + if (!win || win.closed) { + clearInterval(abandonWatch) + pending = false + opts.onAbandoned?.() + } + }, 500) + } + + return { + get token(): string | undefined { + return oauth.current?.access_token + }, + get authed(): boolean { + return !!oauth.current?.access_token + }, + get pending(): boolean { + return pending + }, + /** Opens (or re-points) the popup, falling back to a new tab where popups are blocked. */ + connect() { + win = window.open(CONNECT_URL, OAUTH_WINDOW, 'width=600,height=820') + if (!win) { + opts.onPopupBlocked?.() + if (opts.redirectIfBlocked) { + window.location.href = CONNECT_URL + return + } + // No `noopener`: the callback hands the token back through `window.opener`, and + // severing that is what would leave the host waiting forever. The URL is our own + // origin, so there is nothing to protect against here. + win = window.open(CONNECT_URL, '_blank') + if (!win) { + opts.onFallbackBlocked?.() + return + } + } + pending = true + watchForAbandon() + } + } +} diff --git a/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts b/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts new file mode 100644 index 0000000000..5040b5ab38 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts @@ -0,0 +1,250 @@ +/** + * Supabase Management API calls, proxied through Windmill's backend. + * + * The Management API sends no access-control-allow-origin, so the browser cannot call it + * directly -- every request below goes through /api/oauth/*, which forwards the user's OAuth + * access token. + */ + +import { DEFAULT_SSLMODE } from '$lib/utils/postgresConnectionString' +import { base } from '$lib/base' +import { oauthStore } from '$lib/stores' +import { get } from 'svelte/store' + +export type SupabaseOrg = { id: string; slug?: string; name: string } + +export type SupabaseProject = { + /** `id` is Supabase's deprecated spelling of `ref`; both are sent today. */ + id?: string + ref?: string + name: string + region: string + status?: string + organization_slug?: string + organization_id?: string + database?: { host: string } +} + +/** One Supavisor endpoint of a project. A project has one per mode and replica. */ +export type SupabasePooler = { + database_type: 'PRIMARY' | 'READ_REPLICA' + pool_mode: 'transaction' | 'session' + db_user: string + db_host: string + db_port: number + db_name: string +} + +export type SupabaseConnectionMode = 'session' | 'direct' + +/** Supabase deprecated `id` in favour of `ref`, and still sends both. */ +export function projectRef(project: SupabaseProject): string { + return project.ref ?? project.id ?? '' +} + +export function projectOrg(project: SupabaseProject): string | undefined { + return project.organization_slug ?? project.organization_id +} + +/** Region codes accepted by region_selection, with the names Supabase shows for them. */ +export const SUPABASE_REGIONS: { code: string; label: string }[] = [ + { code: 'us-east-1', label: 'East US (N. Virginia)' }, + { code: 'us-west-1', label: 'West US (N. California)' }, + { code: 'eu-central-1', label: 'Central EU (Frankfurt)' }, + { code: 'eu-west-1', label: 'West EU (Ireland)' }, + { code: 'eu-west-3', label: 'West EU (Paris)' }, + { code: 'ap-southeast-1', label: 'Southeast Asia (Singapore)' }, + { code: 'ap-northeast-1', label: 'Northeast Asia (Tokyo)' } +] + +export const DEFAULT_SUPABASE_REGION = 'eu-central-1' + +function headers(token: string): HeadersInit { + return { 'Content-Type': 'application/json', 'X-Supabase-Token': token } +} + +async function unwrap(res: Response, what: string): Promise { + if (!res.ok) { + // Supabase access tokens are short-lived while `oauthStore` lasts as long as the tab, so + // a stale one otherwise leaves every caller "authorized" and unable to reach the button + // that would fix it. Forgetting it here is what puts Connect back on screen. + if (res.status === 401) oauthStore.set(undefined) + const body = await res.text() + throw new Error(`${what}: ${supabaseErrorMessage(body) || res.statusText}`) + } + return res.json() +} + +/** + * Supabase answers with `{ message }` or `{ error }` and occasionally plain text. + * Surfacing the raw body puts a JSON blob in front of the user, so unwrap it to + * the sentence inside. + */ +export function supabaseErrorMessage(body: string): string { + try { + const parsed = JSON.parse(body) + return parsed?.message ?? parsed?.error ?? parsed?.msg ?? body + } catch { + return body + } +} + +export async function listSupabaseOrgs(token: string): Promise { + const res = await fetch(`${base}/api/oauth/list_supabase_orgs`, { headers: headers(token) }) + return unwrap(res, 'Could not list your Supabase organizations') +} + +export async function listSupabaseProjects(token: string): Promise { + const res = await fetch(`${base}/api/oauth/list_supabase`, { headers: headers(token) }) + return unwrap(res, 'Could not list your Supabase projects') +} + +/** Plan of one organization, which the list endpoint does not carry. */ +export async function getSupabaseOrgPlan(token: string, slug: string): Promise { + try { + const res = await fetch(`${base}/api/oauth/get_supabase_org/${slug}`, { + headers: headers(token) + }) + if (!res.ok) return undefined + return (await res.json())?.plan + } catch { + return undefined + } +} + +/** organization_slug is what create takes; older payloads only carry an id. */ +export function orgSlug(org: SupabaseOrg): string { + return org.slug ?? org.id +} + +/** + * Supabase never lets a database password be read back, so the only way to know it is to be + * the one who set it: db_pass is an input to project creation. + */ +export function generateDbPassword(): string { + const charset = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' + const values = new Uint32Array(32) + crypto.getRandomValues(values) + return Array.from(values, (v) => charset[v % charset.length]).join('') +} + +export async function createSupabaseProject( + token: string, + args: { name: string; organizationSlug: string; region: string; dbPass: string } +): Promise { + const res = await fetch(`${base}/api/oauth/create_supabase_project`, { + method: 'POST', + headers: headers(token), + body: JSON.stringify({ + name: args.name, + organization_slug: args.organizationSlug, + db_pass: args.dbPass, + // region_selection is { type: 'specific' | 'smartGroup', code }. Neither the published + // docs nor the OpenAPI spec describe it correctly (they give `kind`/`region` and + // `primary`) -- this shape comes from the API's own validation errors, so do not + // "correct" it against the documentation. + region_selection: { type: 'specific', code: args.region } + }) + }) + return unwrap(res, 'Supabase refused to create the project') +} + +/** + * Creation returns immediately with the project still coming up, so the pooler is not + * reachable yet. Poll until Supabase reports it healthy before trying to connect. + */ +export async function waitUntilSupabaseHealthy( + token: string, + projectId: string, + onStatus?: (status: string | undefined) => void, + attempts = 60 +): Promise { + for (let i = 0; i < attempts; i++) { + await new Promise((r) => setTimeout(r, 5000)) + let list: SupabaseProject[] + try { + list = await listSupabaseProjects(token) + } catch (err) { + // A transient failure is worth another poll; an expired token is not -- retrying it + // burns five minutes and then reports a timeout, which names the wrong problem. + if (!get(oauthStore)?.access_token) throw err + continue + } + const project = list?.find?.((p) => projectRef(p) === projectId) + if (project?.status === 'ACTIVE_HEALTHY') return project + onStatus?.(project?.status) + } + throw new Error('Timed out waiting for the project to become reachable') +} + +/** + * The session-mode Supavisor endpoint of the project's primary database. + * + * Which pooler a project sits behind is assigned by Supabase, not derived from its + * region: constructing `aws-0-.pooler.supabase.com` is wrong for every project + * that landed on another one, and the resulting resource never connects. + */ +export async function getSupabasePooler(token: string, projectId: string): Promise { + const res = await fetch(`${base}/api/oauth/get_supabase_pooler/${projectId}`, { + headers: headers(token) + }) + const configs: SupabasePooler[] = await unwrap(res, 'Could not read the connection details') + const primary = configs.filter((c) => c.database_type === 'PRIMARY') + const pooler = primary.find((c) => c.pool_mode === 'session') ?? primary[0] ?? configs[0] + if (!pooler) throw new Error('Supabase returned no connection details for this project') + return pooler +} + +export type SupabaseConnection = { + mode: SupabaseConnectionMode + pooler?: SupabasePooler + /** Why session pooling was asked for and not used. Absent when nothing was given up. */ + unavailable?: string +} + +/** + * The endpoint a project should be reached through, degrading rather than failing. Reading the + * pooler config needs the `database_pooling_config_read` scope, which an instance's OAuth app + * may not have. A direct connection still works where the workers have IPv6, so fall back to + * it and say so. + */ +export async function resolveSupabaseConnection( + token: string, + project: SupabaseProject, + mode: SupabaseConnectionMode +): Promise { + if (mode !== 'session') return { mode } + try { + return { mode, pooler: await getSupabasePooler(token, projectRef(project)) } + } catch (err) { + return { mode: 'direct', unavailable: err instanceof Error ? err.message : String(err) } + } +} + +/** The resource value for a project, given the endpoint it should connect through. */ +export function supabaseResourceValue( + project: SupabaseProject, + passwordVarPath: string, + connection: { mode: SupabaseConnectionMode; pooler?: SupabasePooler } +) { + const direct = connection.mode === 'direct' || !connection.pooler + return { + host: direct + ? (project.database?.host ?? `db.${projectRef(project)}.supabase.co`) + : connection.pooler!.db_host, + user: direct ? 'postgres' : connection.pooler!.db_user, + port: direct ? 5432 : connection.pooler!.db_port, + dbname: direct ? 'postgres' : connection.pooler!.db_name, + // Supabase terminates TLS on every endpoint it hands out, and this connection carries a + // generated password, so there is no reason to leave a plaintext fallback open. + sslmode: DEFAULT_SSLMODE, + password: `$var:${passwordVarPath}`, + // Resource forms fill in every unset property from the schema as soon as they render, + // so a postgresql resource saved without these comes up already modified -- and saves a + // draft -- the first time anyone opens it. Write them here so opening one is a no-op. + // (accept_invalid_certs renders conditionally and is not seeded, so it stays out.) + region: '', + root_certificate_pem: '', + use_iam_auth: false + } +} diff --git a/frontend/src/lib/components/workspaceSettings/utils.svelte.ts b/frontend/src/lib/components/workspaceSettings/utils.svelte.ts index 6925be9c04..55dc531182 100644 --- a/frontend/src/lib/components/workspaceSettings/utils.svelte.ts +++ b/frontend/src/lib/components/workspaceSettings/utils.svelte.ts @@ -1,8 +1,20 @@ import { isCloudHosted } from '$lib/cloud' import { superadmin } from '$lib/stores' +import { getLocalSetting } from '$lib/utils' import { derived } from 'svelte/store' +/** + * Opt-in for the data table setup wizard while it is being tested. Browser-local and read + * once per page: `localStorage.setItem('dataTableWizard', 'true')`, then reload. With it + * off, adding a data table falls back to the inline row in the settings table. + */ +export const DATATABLE_WIZARD_SETTING_NAME = 'dataTableWizard' + +export function isDataTableWizardEnabled(): boolean { + return getLocalSetting(DATATABLE_WIZARD_SETTING_NAME) === 'true' +} + export let isCustomInstanceDbEnabled = derived( [superadmin], ([superadmin_]) => superadmin_ && !isCloudHosted() diff --git a/frontend/src/lib/components/workspaceSettings/wizardParking.ts b/frontend/src/lib/components/workspaceSettings/wizardParking.ts new file mode 100644 index 0000000000..37d031fa3d --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/wizardParking.ts @@ -0,0 +1,63 @@ +/** + * Where popups are blocked the Supabase leg falls back to a full-page redirect, which + * unmounts the wizard. What the user had chosen is parked here and picked back up by the + * settings page when Supabase sends them home. + * + * Kept out of the wizard component so the OAuth callback route can ask whether anything is + * parked without pulling the whole wizard into that page's bundle. + */ + +import type { SupabaseConnectionMode, SupabaseOrg, SupabaseProject } from './supabaseProvisioning' +import type { Claim } from './setupClaims' +import type { CreatedProject } from './addDataTableModel' + +const RESUME_KEY = 'datatable_wizard_resume' + +export type WizardResume = { + name: string + region: string + projectName: string + /** + * What the interrupted run had already created. Without these the resumed run meets its + * own secret variable and resource as somebody else's and refuses to write over them, + * which strands the Supabase project it just paid for. No secret is parked -- these are + * paths, and the password they name is already in the workspace. + */ + resourcePath?: string + /** Everything the run holds, serialised whole so a newly added kind cannot be left behind. */ + claims?: Claim[] + /** Every project created before the redirect, each still guarding its password's path. */ + createdProjects?: CreatedProject[] + /** + * Which side of the step-2 toggle the run was on, and where it was pointed. A run that + * died mid-create otherwise comes back on `existing`, is asked for the password it + * generated and never showed anyone, and looks for its project in whichever organization + * happens to be first. + */ + mode?: 'existing' | 'create' + org?: SupabaseOrg + /** The project that was picked. Without it a resume selects the first in the list, which is + * a different database from the one whose password the user had already typed. */ + project?: SupabaseProject + connectionMode?: SupabaseConnectionMode +} + +/** True while a wizard run is waiting on the Supabase redirect to come back. */ +export function hasParkedWizard(): boolean { + return sessionStorage.getItem(RESUME_KEY) != null +} + +export function parkWizard(state: WizardResume) { + sessionStorage.setItem(RESUME_KEY, JSON.stringify(state)) +} + +export function takeParkedWizard(): WizardResume | undefined { + const raw = sessionStorage.getItem(RESUME_KEY) + sessionStorage.removeItem(RESUME_KEY) + if (!raw) return undefined + try { + return JSON.parse(raw) + } catch { + return undefined + } +} diff --git a/frontend/src/lib/utils/postgresConnectionString.test.ts b/frontend/src/lib/utils/postgresConnectionString.test.ts new file mode 100644 index 0000000000..ae5fc19195 --- /dev/null +++ b/frontend/src/lib/utils/postgresConnectionString.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { + composePostgresConnectionString, + connectionParamRefusal, + parsePostgresConnectionString, + unsupportedConnectionParam +} from './postgresConnectionString' + +// Two callers depend on this producing the same resource value from the same string: +// the resource form's "From connection string", and the data table wizard. +describe('parsePostgresConnectionString', () => { + it('reads every part of a full URI', () => { + expect( + parsePostgresConnectionString('postgres://u:p@db.example.com:6543/mydb?sslmode=require') + ).toEqual({ + user: 'u', + password: 'p', + host: 'db.example.com', + port: 6543, + dbname: 'mydb', + sslmode: 'require' + }) + }) + + it('leaves optional parts undefined rather than empty', () => { + expect(parsePostgresConnectionString('postgresql://u@host/')).toEqual({ + user: 'u', + password: undefined, + host: 'host', + port: undefined, + dbname: undefined, + sslmode: undefined + }) + }) + + it('returns undefined for anything that is not a postgres URI', () => { + expect(parsePostgresConnectionString('mysql://u:p@host/db')).toBeUndefined() + expect(parsePostgresConnectionString('')).toBeUndefined() + }) + + // Verified against psql: `postgres://role:p%40ss@host/db` authenticates as `p@ss`, and an + // unencoded `@` puts the rest of the password in libpq's host too. Reading these any other + // way would make the same string mean something here that it means nowhere else. + it('decodes percent escapes in credentials, as libpq does', () => { + expect(parsePostgresConnectionString('postgres://u:p%40ss@host/db')?.password).toBe('p@ss') + expect(parsePostgresConnectionString('postgres://u%40corp:p@host/db')?.user).toBe('u@corp') + }) +}) + +// The wizard offers the same connection as a string or as fields and switches between them +// by composing and reparsing. A password holding a character the URI reserves is the case +// that breaks silently: it comes back wrong rather than failing to parse. +describe('composePostgresConnectionString', () => { + // `prefer` is libpq's default, so it is the one a composer is tempted to leave out -- and + // the one that silently becomes `require` when the wizard reparses the string and falls + // back to its own default. It is a weaker TLS setting chosen on purpose; it has to survive. + it('keeps an explicit prefer through the round trip', () => { + const parts = { user: 'u', host: 'h', port: undefined, dbname: 'db', sslmode: 'prefer' } + const composed = composePostgresConnectionString(parts) + expect(composed).toContain('sslmode=prefer') + expect(parsePostgresConnectionString(composed)?.sslmode).toBe('prefer') + }) + + // The wizard composes this from fields, so a database name holding a character the URI + // reserves has to survive the toggle. `?` is the one that truncates silently: the parser + // reads everything after it as the query string. + it('round-trips a database name holding reserved characters', () => { + const parts = { user: 'u', host: 'h', dbname: 'sales?archive', sslmode: 'require' } + expect(parsePostgresConnectionString(composePostgresConnectionString(parts))?.dbname).toBe( + 'sales?archive' + ) + }) + + // A literal IPv6 address is all colons, so the URI brackets it and the resource stores it + // bare. Both halves have to agree or the wizard's own toggle produces a string it rejects. + it('brackets an IPv6 host and reads it back bare', () => { + const composed = composePostgresConnectionString({ + user: 'u', + host: '2001:db8::1', + port: 5432, + dbname: 'db' + }) + expect(composed).toContain('@[2001:db8::1]:5432/') + expect(parsePostgresConnectionString(composed)?.host).toBe('2001:db8::1') + expect(parsePostgresConnectionString('postgres://u:p@[2001:db8::1]/db')?.host).toBe( + '2001:db8::1' + ) + }) + + it('round-trips through parse', () => { + const parts = { + user: 'u@corp', + password: 'p@ss/w:rd', + host: 'db.example.com', + port: 6543, + dbname: 'mydb', + sslmode: 'require' + } + expect(parsePostgresConnectionString(composePostgresConnectionString(parts))).toEqual(parts) + }) +}) + +// A parameter the resource has no field for is not a preference that can be dropped: it decides +// where data lands, or how the connection is verified. The check is an allowlist because the +// dangerous ones are precisely the ones a hand-written denylist would miss. +describe('unsupportedConnectionParam', () => { + it('names a parameter that decides where data lands', () => { + expect(unsupportedConnectionParam('postgres://u:p@h/db?options=-csearch_path%3Dtenant')).toBe( + 'options' + ) + expect(unsupportedConnectionParam('postgres://u:p@h/db?search_path=tenant')).toBe('search_path') + }) + + // Dropping these saves a *weaker* connection than the one pasted. + it('names a parameter that decides how the connection is secured or routed', () => { + expect(unsupportedConnectionParam('postgres://u:p@h/db?sslrootcert=system')).toBe('sslrootcert') + expect(unsupportedConnectionParam('postgres://u:p@h/db?channel_binding=require')).toBe( + 'channel_binding' + ) + expect(unsupportedConnectionParam('postgres://u:p@h/db?target_session_attrs=read-write')).toBe( + 'target_session_attrs' + ) + }) + + // The backend applies its own connect timeout, so accepting one and dropping it would make + // `connect_timeout=1` mean a twenty-second wait. + it('names a parameter whose behaviour the backend overrides', () => { + expect(unsupportedConnectionParam('postgres://u:p@h/db?connect_timeout=1')).toBe( + 'connect_timeout' + ) + }) + + // `sslmode=` also occurs inside another parameter's value, and reading it there turns TLS + // off behind a string that never asked for it -- past the allowlist, since the parameter + // actually carrying it is one we accept. + it('reads sslmode by name, not from anywhere it appears in the query', () => { + const disguised = 'postgres://u:p@h/db?application_name=sslmode=disable' + expect(unsupportedConnectionParam(disguised)).toBeUndefined() + expect(parsePostgresConnectionString(disguised)?.sslmode).toBeUndefined() + }) + + // libpq rejects `?SslMode=` as an invalid URI query parameter rather than folding it, so a + // string carrying one does not connect anywhere. Naming it is the honest answer; honouring + // it would save a resource from a URI Postgres itself refuses. + it('refuses a parameter whose name is not the one libpq accepts', () => { + const shouted = 'postgres://u:p@h/db?SslMode=verify-full' + expect(unsupportedConnectionParam(shouted)).toBe('SslMode') + expect(parsePostgresConnectionString(shouted)?.sslmode).toBeUndefined() + }) + + // libpq takes the last of a repeated parameter. Taking the first reads a weaker mode than + // the string actually asks for. + it('takes the last value of a repeated parameter', () => { + expect( + parsePostgresConnectionString('postgres://u:p@h/db?sslmode=disable&sslmode=require')?.sslmode + ).toBe('require') + }) + + it('ignores the one it can store, and the ones that cost nothing', () => { + expect(unsupportedConnectionParam('postgres://u:p@h/db?sslmode=require')).toBeUndefined() + expect(unsupportedConnectionParam('postgres://u:p@h/db?application_name=wm')).toBeUndefined() + expect(unsupportedConnectionParam('postgres://u:p@h/db')).toBeUndefined() + }) +}) + +// One refusal reached the user through two very different causes, and the wrong explanation +// sends them to fix the wrong thing: respelling a parameter this resource cannot store changes +// nothing, and removing one it can store loses what the string asked for. +describe('connectionParamRefusal', () => { + it('blames the spelling only when the parameter is one the resource keeps', () => { + expect(connectionParamRefusal('postgres://u:p@h/db?SslMode=verify-full')).toContain( + 'case-sensitive' + ) + expect(connectionParamRefusal('postgres://u:p@h/db?SslMode=verify-full')).toContain('sslmode') + }) + + it('blames the resource when respelling would not help', () => { + const refusal = connectionParamRefusal('postgres://u:p@h/db?Connect_Timeout=1') + expect(refusal).toContain('cannot store') + expect(refusal).not.toContain('case-sensitive') + }) + + it('says nothing about a string it can save', () => { + expect(connectionParamRefusal('postgres://u:p@h/db?sslmode=require')).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/utils/postgresConnectionString.ts b/frontend/src/lib/utils/postgresConnectionString.ts new file mode 100644 index 0000000000..d684739904 --- /dev/null +++ b/frontend/src/lib/utils/postgresConnectionString.ts @@ -0,0 +1,144 @@ +/** + * `postgres://user:password@host:5432/dbname?sslmode=require` in both directions. + * + * Shared by the resource form and the data table wizard: both turn a pasted + * connection string into a `postgresql` resource value, and the two drifting + * apart would mean the same string produced two different resources. + * + * The wizard offers the same connection as a string or as fields and lets the + * user switch, so parse and compose have to be inverses: whatever one produces, + * the other must read back unchanged. + * + * libpq is the arbiter of what a connection string means, so this follows it rather than + * RFC 3986 where they differ: credentials are split at the *first* `@` -- an unencoded one + * lands in the host for libpq too -- and percent escapes in them are decoded, so `p%40ss` + * authenticates as `p@ss`. + */ + +/** + * The host alternation is what admits IPv6: a literal address is full of colons, so a URI + * has to bracket it (`@[2001:db8::1]:5432/`) and the brackets are what tell the port apart + * from the address. Brackets are stripped on the way in and added back on the way out, so + * what is stored is the bare address a Postgres client wants. + */ +const CONNECTION_STRING = + /postgres(?:ql)?:\/\/(?[^:@]+)(?::(?[^@]+))?@(?\[[^\]]+\]|[^:\/?]+)(?::(?\d+))?\/(?[^\?]+)?/ + +/** + * The query parameters, read the way libpq reads them: names are case-sensitive — `SslMode` is + * rejected outright as an invalid URI query parameter, not folded to `sslmode` — and a name + * repeated takes its last value. One reader for both the parser and the allowlist below, or + * they disagree about what a string says and a name is refused by neither and honoured by + * neither. + */ +function paramsOf(connectionString: string): Map { + const query = connectionString.split('?').slice(1).join('?') + const params = new Map() + if (!query) return params + new URLSearchParams(query).forEach((value, name) => params.set(name, value)) + return params +} + +/** + * A database someone types into Windmill is almost never localhost, so callers ask for TLS + * where libpq would settle for `prefer`. A string that names its own `sslmode` keeps it. + */ +export const DEFAULT_SSLMODE = 'require' + +export type PostgresConnectionParts = { + user: string + password?: string + host: string + port?: number + dbname?: string + sslmode?: string +} + +/** A lone `%` is not an escape, and a password is free to contain one. */ +function decode(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +/** Undefined when the string is not a postgres URI. */ +export function parsePostgresConnectionString( + connectionString: string +): PostgresConnectionParts | undefined { + const match = connectionString.match(CONNECTION_STRING) + if (!match?.groups) return undefined + const { user, password, host, port, dbname } = match.groups + // By parameter name, never by searching the query text: `sslmode=` also occurs inside + // another parameter's *value*, and a substring match there reads someone's + // `application_name=sslmode=disable` as a request to turn TLS off. + const sslmode = paramsOf(connectionString).get('sslmode') + return { + user: decode(user), + password: password ? decode(password) : undefined, + host: host.startsWith('[') ? host.slice(1, -1) : host, + port: port ? Number(port) : undefined, + dbname: dbname ? decode(dbname) : undefined, + sslmode: sslmode || undefined + } +} + +/** The only query parameter the `postgresql` resource has a field for. */ +const REPRESENTABLE_PARAMS = ['sslmode'] + +/** + * Parameters that change nothing about what the connection reaches, how it is secured, or how + * it behaves, so losing them costs the user nothing. `connect_timeout` is deliberately not one + * of them: the backend applies its own fixed timeout, so honouring it is not on offer. + */ +const COSMETIC_PARAMS = ['application_name'] + +/** + * The name of a parameter this string carries that the resource cannot honour. An allowlist, + * not a list of known-bad names: libpq keeps adding parameters, and the ones that matter are + * the ones that would be missed. Dropping one silently saves a connection weaker or simply + * other than the one pasted, behind a probe that reports success. + */ +export function unsupportedConnectionParam(connectionString: string): string | undefined { + for (const name of paramsOf(connectionString).keys()) { + if (!REPRESENTABLE_PARAMS.includes(name) && !COSMETIC_PARAMS.includes(name)) return name + } + return undefined +} + +/** + * Why the string cannot be saved, in the terms the reader needs. Two refusals come out of the + * check above and they call for opposite fixes: a name Postgres does not accept at all, where + * the parameter itself is fine and only its spelling is wrong, and a parameter this resource + * has no field for, where respelling it changes nothing. + */ +export function connectionParamRefusal(connectionString: string): string | undefined { + const name = unsupportedConnectionParam(connectionString) + if (!name) return undefined + const lower = name.toLowerCase() + const storableWhenSpelledRight = + REPRESENTABLE_PARAMS.includes(lower) || COSMETIC_PARAMS.includes(lower) + return storableWhenSpelledRight + ? `Postgres does not accept ${name}: connection parameter names are case-sensitive. Write it as ${lower}.` + : `Windmill cannot store ${name} on a Postgres resource, and ignoring it would connect differently from what this string asks for. Remove it, or set the connection with the fields.` +} + +/** + * Every part that was set is emitted, `sslmode` included. Leaving `prefer` out because it is + * libpq's own default would be shorter, but it does not survive the trip: a caller that + * reparses this string gets `undefined` back and substitutes its own default, which is how an + * explicit `prefer` silently became `require`. Whatever this produces, `parse` must read back. + */ +export function composePostgresConnectionString(parts: PostgresConnectionParts): string { + const credentials = parts.password + ? `${encodeURIComponent(parts.user)}:${encodeURIComponent(parts.password)}` + : encodeURIComponent(parts.user) + const port = parts.port ? `:${parts.port}` : '' + const query = parts.sslmode ? `?sslmode=${parts.sslmode}` : '' + const dbname = parts.dbname ? encodeURIComponent(parts.dbname) : '' + // A bare IPv6 address would put its own colons where the port separator goes. + const host = + parts.host.includes(':') && !parts.host.startsWith('[') ? `[${parts.host}]` : parts.host + return `postgres://${credentials}@${host}${port}/${dbname}${query}` +} diff --git a/frontend/src/routes/oauth/callback_supabase/+page.svelte b/frontend/src/routes/oauth/callback_supabase/+page.svelte index 83bd448a13..ac6776804d 100644 --- a/frontend/src/routes/oauth/callback_supabase/+page.svelte +++ b/frontend/src/routes/oauth/callback_supabase/+page.svelte @@ -5,6 +5,7 @@ import { onMount } from 'svelte' import { OauthService } from '$lib/gen' import { oauthStore } from '$lib/stores' + import { hasParkedWizard } from '$lib/components/workspaceSettings/wizardParking' import CenteredPage from '$lib/components/CenteredPage.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import { Loader2 } from 'lucide-svelte' @@ -15,25 +16,63 @@ let code = page.url.searchParams.get('code') ?? undefined let state = page.url.searchParams.get('state') ?? undefined + /** + * As the wizard's popup there is no page to land on: the tab behind us is still showing the + * flow and is watching for this window to go away. Leaving it open on a full Windmill page + * is what strands the caller's button spinning, and declining consent is a normal outcome, + * not an edge case. + */ + function closeIfPopup(): boolean { + if (!window.opener) return false + window.close() + return true + } + + /** + * Where a failed leg lands when this is not a popup. A parked run has to be handed back its + * own page: nothing else consumes the park, so sending it to `/resources` leaves the run in + * `sessionStorage` to spring the wizard open on some unrelated later visit. + */ + function failureDestination(): string { + return hasParkedWizard() ? '/workspace_settings?tab=windmill_data_tables' : '/resources' + } + onMount(async () => { if (error) { + if (closeIfPopup()) return sendUserToast(`Error trying to fetch projects from windmill: ${error}`, true) - goto('/resources') + goto(failureDestination()) } else if (code && state) { try { const res = await OauthService.connectCallback({ clientName: client_name, requestBody: { code, state } }) + // Opened as the data table wizard's popup: hand the token to the tab that is still + // sitting on the wizard and get out of the way, so nothing has to be resumed. + if (window.opener) { + window.opener.postMessage({ type: 'supabase_oauth', res }, window.location.origin) + window.close() + return + } $oauthStore = res - goto(`/resources?callback=${client_name}`) + // The data table wizard parks its state before redirecting, so it can be resumed + // where it left off. Everything else lands on the resources page, which opens the + // Supabase drawer for this callback. + if (hasParkedWizard()) { + goto(`/workspace_settings?tab=windmill_data_tables&callback=${client_name}`) + } else { + goto(`/resources?callback=${client_name}`) + } } catch (e) { + if (closeIfPopup()) return sendUserToast(`Error parsing the response token, ${e.body}`, true) - goto('/resources') + goto(failureDestination()) } } else { + if (closeIfPopup()) return sendUserToast('Missing code or state as query params', true) - goto('/resources') + goto(failureDestination()) } }) From ed2ff6c5e7755fb32bb7c6fdb015456d8088c701 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 19 Aug 2026 21:25:02 +0200 Subject: [PATCH 3/5] fix: scope git-sync concurrency key per repository (#10767) * [ee] fix: scope git-sync concurrency key per repository Co-Authored-By: Claude Opus 5 (1M context) * test: dedupe git repo resource helper, fail loudly on callback timeout Co-Authored-By: Claude Opus 5 (1M context) * fix: reserve the workspace prefix in the git-sync concurrency key cap Co-Authored-By: Claude Opus 5 (1M context) * test: cover the concurrency-key prefix reservation and the pull lane Co-Authored-By: Claude Opus 5 (1M context) * chore: update ee-repo-ref to dff61d6da80d15f8327af99d322c00cc91f784ff This commit updates the EE repository reference after PR #734 was merged in windmill-ee-private. Previous ee-repo-ref: e50a7eca7d7f8771979485f654831b15de59ec25 New ee-repo-ref: dff61d6da80d15f8327af99d322c00cc91f784ff Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../tests/workspace_dependencies_git_sync.rs | 313 ++++++++++++++++-- backend/windmill-queue/src/jobs.rs | 83 ++++- 3 files changed, 365 insertions(+), 33 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 1b9eb3fe0e..f6575c95db 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -483513b70979aa9497cab869837108d948449984 +dff61d6da80d15f8327af99d322c00cc91f784ff diff --git a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs index a5af874f8c..4e25be1fa5 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs @@ -89,27 +89,33 @@ async fn setup_git_sync_config(db: &Pool, sync_script_path: &str) -> a Ok(()) } -/// Create a git repository resource for testing +/// Create a git repository resource at an arbitrary path. #[allow(dead_code)] -async fn create_git_repo_resource(db: &Pool) -> anyhow::Result<()> { +async fn create_git_repo_resource_at(db: &Pool, path: &str) -> anyhow::Result<()> { sqlx::query( r#" INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) - VALUES ('test-workspace', 'u/test-user/test_git_repo', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user') + VALUES ('test-workspace', $2, $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user') ON CONFLICT (workspace_id, path) DO NOTHING "#, ) .bind(json!({ - "url": "https://github.com/test/test.git", + "url": format!("https://github.com/test/{}.git", path.rsplit('/').next().unwrap_or("test")), "branch": "main", "token": "test-token" })) + .bind(path) .execute(db) .await?; - Ok(()) } +/// Create a git repository resource for testing +#[allow(dead_code)] +async fn create_git_repo_resource(db: &Pool) -> anyhow::Result<()> { + create_git_repo_resource_at(db, "u/test-user/test_git_repo").await +} + /// Create a dummy sync script for testing (with version >= 28103 for debouncing support) #[allow(dead_code)] async fn create_sync_script(db: &Pool, path: &str) -> anyhow::Result { @@ -591,24 +597,58 @@ async fn test_promotion_individual_branch_debounces_per_path( Ok(()) } +/// Poll until `expected` callbacks for `script_path` are queued, or time out. +#[allow(dead_code)] +async fn wait_for_callback_jobs( + db: &Pool, + script_path: &str, + expected: usize, + timeout: Duration, +) -> anyhow::Result<()> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let jobs = + get_deployment_callback_jobs(db, script_path, Duration::from_millis(200)).await?; + if jobs.len() >= expected { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for {expected} deployment callbacks on {script_path}, got {}", + jobs.len() + ); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +/// The concurrency key each queued deployment callback was pushed with, read +/// back through the runnable-settings handle the push stored it under. +#[allow(dead_code)] +async fn get_concurrency_keys( + db: &Pool, + script_path: &str, +) -> anyhow::Result> { + let rows: Vec<(String,)> = sqlx::query_as( + r#" + SELECT COALESCE(cs.concurrency_key, '') + FROM v2_job j + JOIN v2_job_queue q ON q.id = j.id + LEFT JOIN runnable_settings rs ON rs.hash = q.runnable_settings_handle + LEFT JOIN concurrency_settings cs ON cs.hash = rs.concurrency_settings + WHERE j.runnable_path = $1 AND j.kind = 'deploymentcallback' + "#, + ) + .bind(script_path) + .fetch_all(db) + .await?; + Ok(rows.into_iter().map(|(k,)| k).collect()) +} + /// Create a second git repository resource for multi-repo tests. #[allow(dead_code)] async fn create_second_git_repo_resource(db: &Pool) -> anyhow::Result<()> { - sqlx::query( - r#" - INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) - VALUES ('test-workspace', 'u/test-user/test_git_repo_2', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user') - ON CONFLICT (workspace_id, path) DO NOTHING - "#, - ) - .bind(json!({ - "url": "https://github.com/test/test2.git", - "branch": "main", - "token": "test-token-2" - })) - .execute(db) - .await?; - Ok(()) + create_git_repo_resource_at(db, "u/test-user/test_git_repo_2").await } /// Configure git sync with TWO promotion-mode repositories pointing at distinct @@ -747,6 +787,239 @@ async fn test_two_promotion_repos_both_enqueue_callback(db: Pool) -> a Ok(()) } +/// Two repositories, first in workspace-wide mode and then in promotion mode: +/// each repo's sync must get its own concurrency lane in both. Sharing +/// `{workspace}:git_sync` serialises unrelated remotes, and a concurrency-limited +/// job is re-queued to an estimate derived from the key's average duration with no +/// wake-up when the slot frees, so one slow repo delays every other repo by +/// multiples of its own runtime. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_two_repos_get_distinct_concurrency_lanes(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + create_folder(&db, "28103").await?; + create_folder(&db, "target").await?; + // Concurrency settings are written through a filesystem-backed cache keyed by + // their own hash: a hash the cache has seen is assumed to be in the database + // already, so a key any earlier run inserted never lands in this test's fresh + // database and `get_concurrency_keys` cannot read it back. Repo paths unique to + // the run keep every key new. + let run_id: u32 = rand::random(); + let repo_a = format!("u/test-user/lane_repo_a_{run_id}"); + let repo_b = format!("u/test-user/lane_repo_b_{run_id}"); + create_git_repo_resource_at(&db, &repo_a).await?; + create_git_repo_resource_at(&db, &repo_b).await?; + let sync_script_path = "f/28103/test_sync_two_workspace_wide_repos"; + create_sync_script(&db, sync_script_path).await?; + + let git_sync_config = json!({ + "include_type": ["script"], + "include_path": ["**"], + "repositories": [ + { + "script_path": sync_script_path, + "git_repo_resource_path": format!("$res:{repo_a}"), + "use_individual_branch": false, + "group_by_folder": false + }, + { + "script_path": sync_script_path, + "git_repo_resource_path": format!("$res:{repo_b}"), + "use_individual_branch": false, + "group_by_folder": false + } + ] + }); + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + git_sync_config, + "test-workspace" + ) + .execute(&db) + .await?; + + let (client, _port, _server) = init_client(db.clone()).await; + + create_test_script(&client, "f/target/alpha").await?; + wait_for_callback_jobs(&db, sync_script_path, 2, Duration::from_secs(5)).await?; + + let mut conc_keys = get_concurrency_keys(&db, sync_script_path).await?; + conc_keys.sort(); + assert_eq!( + conc_keys, + vec![ + format!("test-workspace:git_sync:{repo_a}"), + format!("test-workspace:git_sync:{repo_b}"), + ], + "each repo must push on its own concurrency lane" + ); + + // Promotion mode keys per branch, but two repos deploying the same object name + // the same branch, so the repo has to be in the key there too. + let promo_script_path = "f/28103/test_sync_two_promotion_lanes"; + create_sync_script(&db, promo_script_path).await?; + let promo_config = json!({ + "include_type": ["script"], + "include_path": ["**"], + "repositories": [ + { + "script_path": promo_script_path, + "git_repo_resource_path": format!("$res:{repo_a}"), + "use_individual_branch": true, + "group_by_folder": false + }, + { + "script_path": promo_script_path, + "git_repo_resource_path": format!("$res:{repo_b}"), + "use_individual_branch": true, + "group_by_folder": false + } + ] + }); + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + promo_config, + "test-workspace" + ) + .execute(&db) + .await?; + + create_test_script(&client, "f/target/beta").await?; + wait_for_callback_jobs(&db, promo_script_path, 2, Duration::from_secs(5)).await?; + + let mut promo_keys = get_concurrency_keys(&db, promo_script_path).await?; + promo_keys.sort(); + assert_eq!( + promo_keys, + vec![ + format!("test-workspace:git_sync:{repo_a}:script:f/target/beta"), + format!("test-workspace:git_sync:{repo_b}:script:f/target/beta"), + ], + "each repo must push its branch on its own concurrency lane" + ); + + Ok(()) +} + +/// Pulls must NOT follow pushes into a per-repo lane. A pull writes workspace +/// objects, so the workspace-wide lane is what stops two repos applying creates, +/// updates and deletes to the same scripts and flows at once — the safety argument +/// for per-repo push lanes rests on this staying put. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_pull_stays_on_the_workspace_lane(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + create_git_repo_resource_at(&db, "u/test-user/pull_repo").await?; + + let repo: windmill_common::workspaces::GitRepositorySettings = serde_json::from_value(json!({ + "git_repo_resource_path": "$res:u/test-user/pull_repo", + "use_individual_branch": false, + "group_by_folder": false + }))?; + + windmill_git_sync::enqueue_git_pull_job( + &db, + "test-workspace", + &repo, + None, + false, + None, + None, + None, + ) + .await?; + + let keys: Vec<(String,)> = sqlx::query_as( + r#" + SELECT ck.key + FROM v2_job j + JOIN concurrency_key ck ON ck.job_id = j.id + WHERE j.workspace_id = 'test-workspace' AND j.kind = 'deploymentcallback' + "#, + ) + .fetch_all(&db) + .await?; + assert_eq!( + keys.iter().map(|(k,)| k.as_str()).collect::>(), + vec!["test-workspace:git_sync"], + "a pull must share the workspace-wide lane with every other repo's pull" + ); + + Ok(()) +} + +/// Putting the repo in the lane made the key long enough to matter: +/// `concurrency_key.key` is VARCHAR(255) and its INSERT runs inside `push`, so an +/// overflowing key fails the push and the sync job is never created at all. A repo +/// path that does not fit must be hashed down instead. +/// +/// A cloud build narrows the budget further — `resolve_concurrency_key` prepends +/// `{workspace_id}/` there — but `cloud` is a separate cargo feature this test +/// binary does not enable, so this covers the un-prefixed budget only. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_long_repo_path_still_enqueues_callback(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + create_folder(&db, "28103").await?; + create_folder(&db, "target").await?; + // Long enough that `{workspace}:git_sync:{repo}` alone exceeds 255. + let long_repo = format!("u/test-user/{}", "x".repeat(228)); + create_git_repo_resource_at(&db, &long_repo).await?; + let sync_script_path = "f/28103/test_sync_long_repo_path"; + create_sync_script(&db, sync_script_path).await?; + + let git_sync_config = json!({ + "include_type": ["script"], + "include_path": ["**"], + "repositories": [{ + "script_path": sync_script_path, + "git_repo_resource_path": format!("$res:{long_repo}"), + "use_individual_branch": false, + "group_by_folder": false + }] + }); + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + git_sync_config, + "test-workspace" + ) + .execute(&db) + .await?; + + let (client, _port, _server) = init_client(db.clone()).await; + + create_test_script(&client, "f/target/alpha").await?; + wait_for_callback_jobs(&db, sync_script_path, 1, Duration::from_secs(5)).await?; + + // The row exists only if the INSERT inside `push` accepted the key. + let keys: Vec<(String,)> = sqlx::query_as( + r#" + SELECT ck.key + FROM v2_job j + JOIN concurrency_key ck ON ck.job_id = j.id + WHERE j.runnable_path = $1 AND j.kind = 'deploymentcallback' + "#, + ) + .bind(sync_script_path) + .fetch_all(&db) + .await?; + assert_eq!( + keys.len(), + 1, + "expected a stored concurrency key, got {keys:?}" + ); + assert!( + keys[0].0.len() <= 255, + "concurrency key must fit the column: {} chars", + keys[0].0.len() + ); + + Ok(()) +} + /// Promotion mode with group_by_folder: items destined for the same per-folder /// branch must share one debounce key so they accumulate into a single sync /// job; scripts in different folders must get distinct keys. diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index b286082b1c..386416458f 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -4489,6 +4489,35 @@ pub async fn custom_debounce_key( .await } +/// Concurrency lane for a git-sync callback: the workspace's lane, narrowed by +/// `suffix` (the repo, plus its branch where one is knowable). +/// +/// Both destination columns are VARCHAR(255) and `concurrency_key.key` is written +/// inside `push`, so an over-long key aborts the push and the sync job is never +/// created — the suffix is hashed rather than allowed to overflow. +/// `reserved_prefix_len` is what a caller-side prefix will consume afterwards. +fn git_sync_concurrency_key( + workspace_id: &str, + suffix: Option, + reserved_prefix_len: usize, +) -> String { + const MAX_CONCURRENCY_KEY_LEN: usize = 255; + let max_key_len = MAX_CONCURRENCY_KEY_LEN.saturating_sub(reserved_prefix_len); + match suffix { + Some(suffix) => { + let full = format!("{workspace_id}:git_sync:{suffix}"); + if full.len() <= max_key_len { + full + } else { + // SHA-256 hex (64) over the whole suffix, so distinct repos stay + // distinct and the result fits any workspace id (VARCHAR(50)). + format!("{workspace_id}:git_sync:{}", calculate_hash(&suffix)) + } + } + None => format!("{workspace_id}:git_sync"), + } +} + pub fn resolve_debounce_key<'b>( unresolved_debounce_key: Option, runnable_path: &Option, @@ -6399,18 +6428,15 @@ async fn push_inner<'c, 'd>( } } JobPayload::DeploymentCallback { path, debouncing_settings, concurrency_key_append } => { - const MAX_CONCURRENCY_KEY_LEN: usize = 255; - let concurrency_key = match concurrency_key_append { - Some(suffix) => { - let full = format!("{workspace_id}:git_sync:{suffix}"); - if full.len() <= MAX_CONCURRENCY_KEY_LEN { - full - } else { - format!("{workspace_id}:git_sync:{}", calculate_hash(&suffix)) - } - } - None => format!("{workspace_id}:git_sync"), - }; + // `resolve_concurrency_key` prepends `{workspace_id}/` on cloud builds + // (compiled into every EE build), so that is what the key must leave room + // for here. + #[cfg(feature = "cloud")] + let reserved_prefix_len = workspace_id.len() + 1; + #[cfg(not(feature = "cloud"))] + let reserved_prefix_len = 0; + let concurrency_key = + git_sync_concurrency_key(workspace_id, concurrency_key_append, reserved_prefix_len); JobPayloadUntagged { runnable_path: Some(path.clone()), job_kind: JobKind::DeploymentCallback, @@ -7840,3 +7866,36 @@ pub async fn get_same_worker_job( )) }) } + +#[cfg(test)] +mod git_sync_concurrency_key_tests { + use super::git_sync_concurrency_key; + + /// The reservation is what keeps `{workspace_id}/` + the key inside the + /// VARCHAR(255) column on cloud builds. Without it, a suffix that fits the + /// bare budget is emitted verbatim and the prefixed insert fails. + #[test] + fn hashes_a_suffix_that_only_fits_before_the_prefix() { + let ws = "some-workspace"; + let suffix: String = format!("u/user/{}", "x".repeat(220)); + let bare = git_sync_concurrency_key(ws, Some(suffix.clone()), 0); + assert!(bare.len() <= 255 && bare.ends_with(&suffix)); + + let reserved = git_sync_concurrency_key(ws, Some(suffix), ws.len() + 1); + assert!( + ws.len() + 1 + reserved.len() <= 255, + "prefixed key must fit the column, got {}", + ws.len() + 1 + reserved.len() + ); + } + + #[test] + fn keeps_distinct_repos_distinct_when_hashed() { + let ws = "w"; + let long = "x".repeat(300); + let a = git_sync_concurrency_key(ws, Some(format!("u/user/a{long}")), 0); + let b = git_sync_concurrency_key(ws, Some(format!("u/user/b{long}")), 0); + assert_ne!(a, b); + assert!(a.len() <= 255 && b.len() <= 255); + } +} From c2deea13b7d5d98e3fc2e0c624b14fd87f2f3341 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 19 Aug 2026 22:33:46 +0200 Subject: [PATCH 4/5] fix(security): a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr) (#10124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr) Privilege escalation: an app/flow/schedule/trigger execution policy's `on_behalf_of` (which a `wm_deployers` member can set) could point at a superadmin email. The resulting job `WM_TOKEN` then passed the email-based superadmin checks, granting instance superadmin. `forbid_superadmin_job_token` only guarded ~15 of ~75 routes. Fix at the token layer: a WM_TOKEN must never satisfy a superadmin gate, regardless of whose email it runs as (sentinel OR a real superadmin). - `ApiAuthed` gains a `job_id` field, stamped once in `AuthCache::get_opt_job_authed` from the resolved token's job_id (correct even on cache hits). - `require_super_admin(db, email)` -> `require_super_admin(db, &ApiAuthed)`, rejects `authed.job_id.is_some()`. `require_super_admin_email` kept for the few internal callers without an ApiAuthed. - `is_super_admin_authed(db, &ApiAuthed)` for the boolean `is_super_admin_email` authorization branches on request handlers (workspace deletion, fork drops, dev-workspace attach/archive, object-storage SSRF exemption, custom dbname, EE GHES + connected repositories, ...). Migrate ~75 sites (OSS + EE). - CUSTOM_INSTANCE_DB reads the *authenticated* job_id, not the caller-supplied `?job_id` query param. Worker-tag check takes a precomputed job-aware `is_super_admin` on the request path. Execution-time on-behalf checks (scheduled/flow worker-tag, Cloud enqueue quota, is_devops_email) are hardened in a follow-up — see docs/followup-onbehalf-execution-privilege-hardening.md. Regression tests: a superadmin-email WM_TOKEN is rejected on `require_super_admin` routes, on `DELETE /workspaces/delete/{w}` (403, workspace preserved), and on the CUSTOM_INSTANCE_DB lookup with no `?job_id` (401); real superadmin tokens still succeed. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: cap devops role at workspace admin and reject reserved on_behalf_of identities Extends the job-token cap with three pieces: - `require_devops_role` takes `&ApiAuthed` and rejects job tokens. `is_devops_email` is true for superadmin emails, so every worker-management, instance-config and service-log route was reachable by the same superadmin `WM_TOKEN` that `require_super_admin` already rejects. - A `job_id` claim that does not parse as a uuid rejects the token rather than resolving to `None`, which would clear the job provenance and uncap it. Applies to the internal JWT and the external `jwt_ext_` path. - Defense in depth at store time: `validate_on_behalf_of` refuses the reserved internal sentinels as an `on_behalf_of` on apps/flows/scripts/schedules/triggers, and app execution refuses a policy carrying one — covering already-persisted and forked-app rows that predate the cap. Deploying on behalf of a real user, including a real superadmin, stays allowed; the cap handles that at execution. Co-Authored-By: Claude Opus 4.8 * fix(mcp): preserve job-token provenance when minting the proxy JWT The MCP endpoint-tool proxy re-mints a JWT from the caller's ApiAuthed to forward the proxied request, but passed job_id: None. A job's WM_TOKEN is capped at workspace admin (GHSA-hfh4-cx4h-3fcr); dropping the job_id here re-minted an uncapped token that satisfies require_super_admin / require_devops_role on the proxied route (e.g. listWorkers exposing worker IPs, job/workspace IDs, and sensitive tags). Carry api_authed.job_id into create_jwt_token. Adds an in-module regression that decodes the forwarded JWT and asserts the job_id is preserved for a job caller and absent for a non-job caller. Reported by Codex CI review (P1) on #10124. Co-Authored-By: Claude Opus 4.8 * fix: cap the admin-or-devops gate at workspace admin for job tokens require_admin_or_devops (the EE critical-alerts endpoints) grants when the caller is a workspace admin OR an instance devops. is_devops_email is true for superadmins, so a WM_TOKEN running on-behalf of a superadmin who is not a member of the target workspace could clear the devops branch and read/ack that workspace's critical alerts (GHSA-hfh4-cx4h-3fcr). This gate takes a bare email, not an ApiAuthed, so the token-layer cap could not see it. Thread the caller's job-token provenance and reject the devops branch for job tokens, matching require_devops_role. The workspace-admin branch stays allowed — that is the cap ceiling. Adds an enterprise-gated regression proving the bypass is closed and a real superadmin token still clears the gate. Found while auditing the PR for bare-email gates the choke-point cap misses. Co-Authored-By: Claude Opus 4.8 * fix: cap instance-global is_admin gates at workspace admin for job tokens Three instance-global routes gate on the caller's own `is_admin` claim, which `ApiAuthed.is_admin` carries into a WM_TOKEN (it is a workspace-admin claim, true for superadmins too). A job token is capped at workspace admin (GHSA-hfh4-cx4h-3fcr), so its is_admin claim must not authorize instance actions on a route with no workspace binding: - `unarchive_workspace` — unarchive an arbitrary workspace by id - `prune_concurrency_group` — delete a global concurrency group - `list_worker_groups` — return unobfuscated `env_vars_static` (may hold secrets) Add job-token-aware `is_instance_admin` / `require_instance_admin` helpers (the same shape as `require_super_admin` / `require_devops_role`) and use them at these three sites. Workspace-scoped `require_admin(authed.is_admin, ...)` gates are intentionally left unchanged — a workspace-admin job token is within the cap there. Regression added covering all three; verified it lets a WM_TOKEN unarchive/leak without the fix and is blocked with it. Reported by Codex CI review (P1) on #10124. Co-Authored-By: Claude Opus 4.8 * fix(mcp): drop orphaned path_field_renames from EndpointTool test helper The merge with main adopted main's mcp path-substitution refactor (#10162), which removed the `path_field_renames` field from `EndpointTool` and its consumer (`substitute_path_params` no longer takes per-field path renames). main's `runner.rs` `ep` test helper still constructed the struct with `path_field_renames: None`, so the workspace test build (cargo test --all, which compiles windmill-mcp's own #[cfg(test)] module under the `server` feature) failed with E0560. A plain `cargo check` does not compile that test module, so it only surfaced in CI's cargo_test. Remove the orphaned field to match the struct. Co-Authored-By: Claude Opus 4.8 * test: describe the sentinel-rejection policy the forged-identity test asserts Co-Authored-By: Claude Fable 5 * fix: complete ApiAuthed initializers in feature-gated tests after merge Co-Authored-By: Claude Fable 5 * fix: stop job tokens minting credentials that shed their provenance Co-Authored-By: Claude Fable 5 * fix: cap the MCP OAuth approval mint at the same elevated-job-token gate Co-Authored-By: Claude Fable 5 * fix: cap the self-service password reset at the elevated-job-token gate Co-Authored-By: Claude Fable 5 * fix: cap app embed/SDK mints and scope widening at the elevated-job-token gate Co-Authored-By: Claude Fable 5 * fix: keep job tokens from destroying the account they run on behalf of Co-Authored-By: Claude Fable 5 * fix: deny job tokens a foreign-workspace admin claim and workspace ejection Co-Authored-By: Claude Fable 5 * docs: keep the follow-up inventory in the PR instead of the repo Co-Authored-By: Claude Fable 5 * fix: make the session workspace status gate job-token aware session_workspace_status derived its superadmin branch from a bare email check, so a job token carrying a superadmin identity resolved the existence of workspaces it has no relationship with rather than seeing them as deleted. Switch to is_super_admin_authed, matching every other instance gate reached from a request ApiAuthed. Co-Authored-By: Claude Opus 5 (1M context) * revert: leave the global concurrency-group listing on the plain admin gate The listing exposes concurrency keys across workspaces, which is metadata rather than a capability, and it 401s rather than degrading. Keep the guard on the prune route next to it, which is the destructive one. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the instance-admin gate on the global concurrency listing The listing spans every workspace's concurrency keys, and the gate rejects only job tokens: the !is_admin branch is the pre-existing check, so workspaced tokens and interactive admins are unaffected. Co-Authored-By: Claude Opus 5 (1M context) * chore: update ee-repo-ref to d30af67d38954f9012f7bad08da23e347344b4c6 This commit updates the EE repository reference after PR #664 was merged in windmill-ee-private. Previous ee-repo-ref: 7870573dbc3360f99bada143f094c67dce0d9e9c New ee-repo-ref: d30af67d38954f9012f7bad08da23e347344b4c6 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: hugocasa Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../tests/fixtures/preserve_on_behalf_of.sql | 5 + backend/tests/postgres_trigger_scope.rs | 1 + backend/tests/preserve_on_behalf_of.rs | 165 +++++ backend/tests/trigger_listener_queries.rs | 1 + backend/tests/wm_token_superadmin_guard.rs | 651 ++++++++++++++++++ backend/tests/worker.rs | 10 +- .../tests/dbt_pinned_graph.rs | 14 +- backend/windmill-api-auth/src/auth.rs | 36 +- backend/windmill-api-auth/src/lib.rs | 141 +++- backend/windmill-api-configs/src/lib.rs | 21 +- backend/windmill-api-flows/src/flows.rs | 41 +- backend/windmill-api-groups/src/groups.rs | 14 +- .../tests/native_triggers.rs | 1 + .../src/concurrency_groups.rs | 14 +- backend/windmill-api-jobs/src/execution.rs | 4 +- backend/windmill-api-schedule/src/lib.rs | 16 +- backend/windmill-api-settings/src/lib.rs | 87 ++- backend/windmill-api-users/src/users.rs | 59 +- backend/windmill-api-workers/src/lib.rs | 14 +- .../src/datatable_migrations.rs | 2 +- .../windmill-api-workspaces/src/workspaces.rs | 54 +- .../src/workspaces_extra.rs | 13 +- backend/windmill-api/src/apps.rs | 49 +- backend/windmill-api/src/db_health.rs | 16 +- backend/windmill-api/src/jobs.rs | 11 +- backend/windmill-api/src/lib.rs | 1 + backend/windmill-api/src/mcp/oauth_server.rs | 8 + backend/windmill-api/src/mcp/utils.rs | 117 +++- backend/windmill-api/src/offboarding.rs | 4 +- backend/windmill-api/src/service_logs.rs | 8 +- backend/windmill-api/src/users.rs | 15 +- backend/windmill-api/src/workspaces.rs | 4 +- backend/windmill-common/src/auth.rs | 78 ++- backend/windmill-common/src/jobs.rs | 8 +- backend/windmill-common/src/lib.rs | 13 +- backend/windmill-common/src/utils.rs | 8 +- backend/windmill-queue/src/schedule.rs | 3 +- backend/windmill-store/src/resources.rs | 13 +- backend/windmill-trigger/src/handler.rs | 17 + backend/windmill-worker/src/worker_flow.rs | 4 +- 41 files changed, 1535 insertions(+), 208 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f6575c95db..1c7d363896 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -dff61d6da80d15f8327af99d322c00cc91f784ff +d30af67d38954f9012f7bad08da23e347344b4c6 diff --git a/backend/tests/fixtures/preserve_on_behalf_of.sql b/backend/tests/fixtures/preserve_on_behalf_of.sql index ff8428a1c5..7c6c4fee57 100644 --- a/backend/tests/fixtures/preserve_on_behalf_of.sql +++ b/backend/tests/fixtures/preserve_on_behalf_of.sql @@ -36,6 +36,11 @@ INSERT INTO password(email, password_hash, login_type, super_admin, verified, na VALUES ('test2@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Test User 2') ON CONFLICT DO NOTHING; +-- Instance devops user (not a superadmin): the tier a token mint must not launder +INSERT INTO password(email, password_hash, login_type, super_admin, devops, verified, name) + VALUES ('devops@windmill.dev', 'not-a-real-hash', 'password', false, true, true, 'Devops User') +ON CONFLICT DO NOTHING; + -- Deployer user (non-admin but in wm_deployers group) INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) VALUES ('deployer@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Deployer User') diff --git a/backend/tests/postgres_trigger_scope.rs b/backend/tests/postgres_trigger_scope.rs index cf98a1e6de..e8ba36ebb9 100644 --- a/backend/tests/postgres_trigger_scope.rs +++ b/backend/tests/postgres_trigger_scope.rs @@ -24,6 +24,7 @@ fn scoped_authed(scopes: Vec<&str>) -> ApiAuthed { is_session_token: false, token_prefix: None, read_only: false, + job_id: None, } } diff --git a/backend/tests/preserve_on_behalf_of.rs b/backend/tests/preserve_on_behalf_of.rs index e696de44ea..2a1f3895ca 100644 --- a/backend/tests/preserve_on_behalf_of.rs +++ b/backend/tests/preserve_on_behalf_of.rs @@ -2810,3 +2810,168 @@ async fn test_schedule_permissions_superadmin_not_in_workspace( Ok(()) } + +// ============================================================================ +// Forged-superadmin on_behalf_of guard (GHSA-hfh4-cx4h-3fcr) +// ============================================================================ + +/// Reserved internal sentinel identities are rejected by name at deploy time on +/// every entity that stores a preserved on_behalf_of. Real identities stay +/// deployable by a `wm_deployers` member — including a real superadmin, and even +/// their email pinned onto an unrelated principal — because that escalation is +/// closed at execution by the job-token cap, not by restricting what is stored. +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_reject_reserved_sentinel_on_behalf_of(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // Reserved internal sentinel (grants is_super_admin at execution by email). + const SENTINEL: &str = "superadmin_secret@windmill.dev"; + // Reserved sentinel matched on permissioned_as. + const SYNC_SENTINEL: &str = "superadmin_sync@windmill.dev"; + // Real instance superadmin, present only in `password` (not a workspace member). + const REAL_SA: &str = "superadmin-external@windmill.dev"; + + // App: deployer cannot pin the sentinel email. + let resp = authed( + client().post(format!("{base}/apps/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_app_with_on_behalf_of( + "u/deployer-user/app_sentinel", + Some("u/original-user"), + Some(SENTINEL), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "deployer must not pin the sentinel as an app on_behalf_of_email: {}", + resp.text().await? + ); + + // App: a real superadmin on_behalf_of is *allowed* at deploy (deployers may + // deploy on behalf of any real user). The escalation is closed at execution + // by the job-token cap, not by restricting what can be stored, so even a + // superadmin email pinned onto an unrelated principal deploys fine here. + let resp = authed( + client().post(format!("{base}/apps/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_app_with_on_behalf_of( + "u/deployer-user/app_real_sa", + Some("u/original-user"), + Some(REAL_SA), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "a real superadmin on_behalf_of is allowed at deploy (capped at execution): {}", + resp.text().await? + ); + + // App: a consistently named real superadmin identity is likewise allowed. + let resp = authed( + client().post(format!("{base}/apps/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_app_with_on_behalf_of( + "u/deployer-user/app_consistent_sa", + Some("u/superadmin-external"), + Some(REAL_SA), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "deployer may preserve a consistently named superadmin identity: {}", + resp.text().await? + ); + + // Flow: deployer cannot pin the sentinel email. + let resp = authed( + client().post(format!("{base}/flows/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_flow_with_on_behalf_of( + "u/deployer-user/flow_sentinel", + Some(SENTINEL), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "deployer must not pin the sentinel as a flow on_behalf_of_email: {}", + resp.text().await? + ); + + // Script: deployer cannot pin the sentinel email. + let resp = authed( + client().post(format!("{base}/scripts/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/deployer-user/script_sentinel", + Some(SENTINEL), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "deployer must not pin the sentinel as a script on_behalf_of_email: {}", + resp.text().await? + ); + + // Schedule: deployer cannot preserve the sync sentinel as permissioned_as. + let resp = authed( + client().post(format!("{base}/scripts/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/deployer-user/sched_guard_script", + None, + false, + )) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + + let resp = authed( + client().post(format!("{base}/schedules/create")), + "DEPLOYER_TOKEN", + ) + .json(&json!({ + "path": "u/deployer-user/schedule_sync_sentinel", + "schedule": "0 0 */6 * * *", + "timezone": "UTC", + "script_path": "u/deployer-user/sched_guard_script", + "is_flow": false, + "enabled": false, + "permissioned_as": SYNC_SENTINEL, + "preserve_permissioned_as": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "deployer must not preserve the sync sentinel as a schedule permissioned_as: {}", + resp.text().await? + ); + + Ok(()) +} diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index 52088557ac..1463138167 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -177,6 +177,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed { is_session_token: false, token_prefix: None, read_only: false, + job_id: None, } } diff --git a/backend/tests/wm_token_superadmin_guard.rs b/backend/tests/wm_token_superadmin_guard.rs index 650cf384d4..8823cb8707 100644 --- a/backend/tests/wm_token_superadmin_guard.rs +++ b/backend/tests/wm_token_superadmin_guard.rs @@ -204,3 +204,654 @@ async fn test_wm_token_cannot_manage_superadmin_users(db: Pool) -> any Ok(()) } + +/// A WM_TOKEN running as a superadmin must be rejected by *any* `require_super_admin` +/// route, not just the handful that call `forbid_superadmin_job_token`. `GET +/// /api/settings/list_global` is gated solely by `require_super_admin`, so it +/// exercises the token-layer guard (GHSA-hfh4-cx4h-3fcr). +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_wm_token_rejected_by_require_super_admin(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api"); + + // The exact token a deployer obtains via an app on_behalf_of pointed at a superadmin. + let sa_wm = wm_token("test@windmill.dev", true).await; + let resp = authed(client().get(format!("{base}/settings/list_global")), &sa_wm) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not reach a require_super_admin route: {}", + resp.text().await? + ); + + // No false positive: a real superadmin API token (no job_id) still reaches it. + let resp = authed( + client().get(format!("{base}/settings/list_global")), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "a real superadmin token must still reach the route: {}", + resp.text().await? + ); + + Ok(()) +} + +/// Direct `is_super_admin_email` authorization gates (not routed through +/// `require_super_admin`) must also reject a superadmin `WM_TOKEN`. Covers the two +/// bypass classes the CI review flagged: destructive `delete_workspace`, and the +/// `CUSTOM_INSTANCE_DB` credential lookup whose guard must read the *authenticated* +/// `job_id`, not the caller-supplied `?job_id` query param (GHSA-hfh4-cx4h-3fcr). +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_wm_token_rejected_by_direct_super_admin_gates( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api"); + + let sa_wm = wm_token("test@windmill.dev", true).await; + + // 1. Global workspace deletion (destructive) — must be forbidden. + let resp = authed( + client().delete(format!("{base}/workspaces/delete/test-workspace")), + &sa_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "superadmin WM_TOKEN must not delete a workspace: {}", + resp.text().await? + ); + // The workspace must still exist. + let exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'test-workspace')") + .fetch_one(&db) + .await?; + assert!( + exists, + "rejected delete must not have removed the workspace" + ); + + // 2. CUSTOM_INSTANCE_DB credential lookup, WITHOUT the ?job_id query param — + // the guard must reject based on the authenticated token's job_id. + let resp = authed( + client().get(format!( + "{base}/w/test-workspace/resources/get_value_interpolated/CUSTOM_INSTANCE_DB/anydb" + )), + &sa_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not resolve CUSTOM_INSTANCE_DB (no creds leak): {}", + resp.text().await? + ); + + Ok(()) +} + +/// The instance-level `devops` role must be capped like superadmin. +/// `is_devops_email` returns true for superadmin emails, so every +/// `require_devops_role` route (worker management, instance config, service logs) +/// is reachable by exactly the same superadmin `WM_TOKEN` unless it is capped too +/// (GHSA-hfh4-cx4h-3fcr). +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_wm_token_rejected_by_require_devops_role(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api"); + + let sa_wm = wm_token("test@windmill.dev", true).await; + let resp = authed( + client().get(format!("{base}/service_logs/list_files")), + &sa_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not reach a require_devops_role route: {}", + resp.text().await? + ); + + // No false positive: a real superadmin API token (no job_id) still reaches it. + let resp = authed( + client().get(format!("{base}/service_logs/list_files")), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "a real superadmin token must still reach the devops route: {}", + resp.text().await? + ); + + // The advisory's own PoC route: the full user directory, gated solely by + // `require_super_admin` with no per-route job-token denylist. + let resp = authed( + client().get(format!("{base}/users/list_as_super_admin")), + &sa_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not list all users: {}", + resp.text().await? + ); + + Ok(()) +} + +/// A job token must not clear an *admin-or-devops* gate via the devops branch. +/// `require_admin_or_devops` (the EE critical-alerts endpoints) grants when the +/// caller is a workspace admin OR an instance `devops`; since `is_devops_email` +/// is true for superadmins, a WM_TOKEN running on-behalf of a superadmin who is +/// NOT a member of the target workspace would otherwise gain workspace-scoped +/// devops access to a workspace it has no admin rights in (GHSA-hfh4-cx4h-3fcr). +/// The workspace-admin branch stays allowed — that is the cap ceiling. +#[cfg(feature = "enterprise")] +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_wm_token_rejected_by_admin_or_devops_gate(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + // superadmin-external is a superadmin but not a member of test-workspace, so + // its workspace-level is_admin is false — the exact exploit precondition. + let sa_wm = wm_token("superadmin-external@windmill.dev", false).await; + let resp = authed(client().get(format!("{base}/critical_alerts")), &sa_wm) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "superadmin WM_TOKEN must not clear the admin-or-devops gate on a workspace it isn't admin of: {}", + resp.text().await? + ); + + // No false positive: the same superadmin's real API token (not a job token) + // still clears the gate via the devops branch. + let resp = authed( + client().get(format!("{base}/critical_alerts")), + "EXTERNAL_SUPERADMIN_TOKEN", + ) + .send() + .await?; + assert_ne!( + resp.status(), + 403, + "a real superadmin token must still clear the admin-or-devops gate: {}", + resp.text().await? + ); + + Ok(()) +} + +/// Instance-global routes with no workspace binding that gate on the caller's own +/// `is_admin` claim must reject a WM_TOKEN — `is_admin` is a workspace-admin claim +/// (also true for superadmins), and a job token is capped at workspace admin, so it +/// must not wield that claim as instance authorization (GHSA-hfh4-cx4h-3fcr). +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_wm_token_rejected_by_instance_admin_gates(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + + // A worker-group config carrying a static env value that must stay masked. + sqlx::query("INSERT INTO config (name, config) VALUES ('worker__wm2082grp', $1)") + .bind(json!({ "env_vars_static": { "LEAKY": "supersecretvalue" } })) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api"); + + // The exact token a deployer obtains via an app on_behalf_of pointed at a + // superadmin: is_admin=true, but carrying a job_id. + let sa_wm = wm_token("test@windmill.dev", true).await; + + // 1. Arbitrary workspace unarchive (mutation on any workspace by id). + let resp = authed( + client().post(format!("{base}/workspaces/unarchive/test-workspace")), + &sa_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not unarchive an arbitrary workspace: {}", + resp.text().await? + ); + + // 2. Global concurrency-group pruning. + let resp = authed( + client().delete(format!("{base}/concurrency_groups/prune/anykey")), + &sa_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "superadmin WM_TOKEN must not prune a global concurrency group: {}", + resp.text().await? + ); + + // 3. The sibling listing spans every workspace's concurrency keys, so it is + // gated the same way as the prune above. + let resp = authed( + client().get(format!("{base}/concurrency_groups/list")), + &sa_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not list global concurrency groups: {}", + resp.text().await? + ); + + // 4. Worker-group config: the static env value must be masked for a job token. + let body = authed( + client().get(format!("{base}/configs/list_worker_groups")), + &sa_wm, + ) + .send() + .await? + .text() + .await?; + assert!( + !body.contains("supersecretvalue"), + "superadmin WM_TOKEN must get the obfuscated worker-group view: {body}" + ); + + // No false positive: a real superadmin API token (no job_id) still sees the + // unobfuscated value — the cap keys off the job token, not the identity. + let body = authed( + client().get(format!("{base}/configs/list_worker_groups")), + "SECRET_TOKEN", + ) + .send() + .await? + .text() + .await?; + assert!( + body.contains("supersecretvalue"), + "a real superadmin token must still see the unobfuscated worker-group config: {body}" + ); + + Ok(()) +} + +/// Capping a `WM_TOKEN` at the gates is only durable if the token cannot trade +/// itself for one without the `job_id` those gates key off. Both credential-minting +/// routes must therefore refuse an elevated job token: `refresh_token` (which mints +/// a database-backed session token and returns it in `Set-Cookie`) and +/// `tokens/create` for the `devops` tier, whose routes are capped just like +/// superadmin's (GHSA-hfh4-cx4h-3fcr). +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_wm_token_cannot_mint_a_provenance_free_credential( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + + // 1. Session refresh: a superadmin-identity job token must not obtain a session + // token, which would authenticate with no job provenance at all. + let sa_wm = wm_token("test@windmill.dev", true).await; + let resp = authed(client().get(format!("{base}/refresh_token")), &sa_wm) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not refresh into a session token: {}", + resp.text().await? + ); + + // No false positive: a real superadmin API token still refreshes. + let resp = authed( + client().get(format!("{base}/refresh_token")), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "a real superadmin token must still refresh: {}", + resp.text().await? + ); + + // 2. Token mint, devops tier: `require_devops_role` rejects this job token, so + // minting one that would pass it by email must be refused too. + let devops_wm = wm_token("devops@windmill.dev", false).await; + let resp = authed(client().post(format!("{base}/tokens/create")), &devops_wm) + .json(&json!({ "label": "from-script" })) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "devops WM_TOKEN must not mint a token: {}", + resp.text().await? + ); + + // 3. Choosing the password of the elevated account it runs as would let the + // holder log in for a session that carries no job provenance at all. + let resp = authed(client().post(format!("{base}/setpassword")), &devops_wm) + .json(&json!({ "password": "hunter2" })) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "devops WM_TOKEN must not set its account password: {}", + resp.text().await? + ); + + Ok(()) +} + +/// The MCP OAuth approval is a third credential mint: the code it stores is +/// exchanged for a database token holding only an email, so an elevated job token +/// approving a client would obtain a credential with no `job_id` and re-enter the +/// API through the gateway uncapped (GHSA-hfh4-cx4h-3fcr). The guard sits in the +/// shared inner fn, ahead of client validation, so it fires without a registered +/// client. +#[cfg(feature = "mcp")] +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_wm_token_cannot_mint_via_mcp_oauth_approval( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api"); + + let approval = json!({ + "client_id": "wm2082-client", + "redirect_uri": "http://localhost/callback", + "scope": "mcp:all", + "state": "s", + "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + "code_challenge_method": "S256", + }); + + let sa_wm = wm_token("test@windmill.dev", true).await; + let resp = authed( + client().post(format!("{base}/w/test-workspace/mcp/oauth/server/approve")), + &sa_wm, + ) + .json(&approval) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not approve an MCP OAuth client: {}", + resp.text().await? + ); + + // The gateway route reaches the same mint and must be capped identically. + let mut gateway_approval = approval.clone(); + gateway_approval["workspace_id"] = json!("test-workspace"); + let resp = authed( + client().post(format!("{base}/mcp/gateway/oauth/server/approve")), + &sa_wm, + ) + .json(&gateway_approval) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not approve through the MCP gateway: {}", + resp.text().await? + ); + + Ok(()) +} + +/// The two links that let a narrowly-scoped mint become a general credential: the +/// sandboxed app embed mint (a 12h database token with no job provenance) and +/// `tokens/update_scopes`, which an unscoped job token could use to clear the +/// scopes of any token sharing its email (GHSA-hfh4-cx4h-3fcr). +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_wm_token_cannot_mint_or_widen_an_app_embed_token( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + + // A sandboxed app the superadmin identity can read — the mint's precondition. + sqlx::query( + "INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms) + VALUES (9001, 'test-workspace', 'u/test-user/embedded', 'Embedded', '{}', + '{\"execution_mode\": \"viewer\", \"sandbox\": true}', '{}')", + ) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO app_version (id, app_id, value, created_by, created_at) + VALUES (9001, 9001, '{\"grid\": []}', 'test-user', NOW())", + ) + .execute(&db) + .await?; + sqlx::query("UPDATE app SET versions = ARRAY[9001::bigint] WHERE id = 9001") + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api"); + + let sa_wm = wm_token("test@windmill.dev", true).await; + let resp = authed( + client().get(format!( + "{base}/w/test-workspace/apps/embed_token/p/u/test-user/embedded" + )), + &sa_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not mint an app embed token: {}", + resp.text().await? + ); + + // Even a token minted some other way must stay narrow: widening is refused. + let resp = authed( + client().post(format!("{base}/users/tokens/update_scopes/SECRET_T")), + &sa_wm, + ) + .json(&json!({ "scopes": serde_json::Value::Null })) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "superadmin WM_TOKEN must not widen a token's scopes: {}", + resp.text().await? + ); + + Ok(()) +} + +/// Destroying the account or credentials of the identity a job runs as is never the +/// runnable's work, and a `wm_deployers` member may point `on_behalf_of` at any real +/// user — so these reject every job token, elevated or not (GHSA-hfh4-cx4h-3fcr). +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_wm_token_cannot_destroy_its_on_behalf_account( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + + // An ordinary member's identity: the cap here does not depend on elevation. + let user_wm = wm_token("test2@windmill.dev", false).await; + + let resp = authed(client().post(format!("{base}/leave_instance")), &user_wm) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "WM_TOKEN must not delete the account it runs as: {}", + resp.text().await? + ); + + // The prefix of that identity's real fixture token, so without the guard the + // delete would land rather than silently match nothing. + let resp = authed( + client().delete(format!("{base}/tokens/delete/SECRET_TOK")), + &user_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "WM_TOKEN must not revoke that identity's tokens: {}", + resp.text().await? + ); + + // Ejecting the identity from a workspace is the same primitive, on both routes + // that expose it (one keyed by username, one by email). + for route in [ + "w/test-workspace/users/leave", + "w/test-workspace/workspaces/leave", + ] { + let resp = authed( + client().post(format!("http://localhost:{port}/api/{route}")), + &user_wm, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "WM_TOKEN must not leave a workspace as {route}: {}", + resp.text().await? + ); + } + let membership: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'", + ) + .fetch_one(&db) + .await?; + assert_eq!(membership, 1, "the workspace membership must survive"); + + // The account and its credentials are untouched, not merely the response refused. + let account_rows: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM password WHERE email = 'test2@windmill.dev'") + .fetch_one(&db) + .await?; + assert_eq!(account_rows, 1, "the password row must survive"); + let token_rows: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM token WHERE email = 'test2@windmill.dev'") + .fetch_one(&db) + .await?; + assert!(token_rows > 0, "the identity's tokens must survive"); + + Ok(()) +} + +/// `load_workspace_authed` grants an admin claim in a workspace the caller may have +/// no relationship with, and carries `job_id` into the result — so deriving it from +/// the on-behalf email would hand a WM_TOKEN admin over every workspace on the +/// instance, and with it the cross-workspace diff (GHSA-hfh4-cx4h-3fcr). +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_wm_token_gets_no_admin_claim_in_a_foreign_workspace( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + + // A workspace the superadmin identity is not a member of. + sqlx::query( + "INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'Other', 'test-user')", + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api"); + + let sa_wm = wm_token("test@windmill.dev", true).await; + let resp = authed( + client().get(format!( + "{base}/w/test-workspace/workspaces/compare/other-workspace" + )), + &sa_wm, + ) + .send() + .await?; + assert_ne!( + resp.status(), + 200, + "superadmin WM_TOKEN must not diff a workspace it does not belong to" + ); + + // No false positive: a real superadmin token still holds the claim. + let resp = authed( + client().get(format!( + "{base}/w/test-workspace/workspaces/compare/other-workspace" + )), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "a real superadmin token must still diff across workspaces: {}", + resp.text().await? + ); + + Ok(()) +} diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 2ecb266787..419be6fdf3 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -5524,25 +5524,25 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool) -> a "bare(test-workspace)".to_string(), ]))); - // test2 is not a superadmin, who would bypass the scope check entirely. - let email = "test2@windmill.dev"; + // A non-superadmin caller (a superadmin would bypass the scope check entirely). + let is_super_admin = false; for (w_id, tag) in [("test-workspace", "bare"), ("test-workspace", "forky")] { assert!( - check_tag_available_for_workspace_internal(&db, w_id, tag, email, None) + check_tag_available_for_workspace_internal(&db, w_id, tag, is_super_admin, None) .await .is_ok(), "{tag} should be available in the workspace it names" ); } assert!( - check_tag_available_for_workspace_internal(&db, fork, "forky", email, None) + check_tag_available_for_workspace_internal(&db, fork, "forky", is_super_admin, None) .await .is_ok(), "a `*` tag must be granted to a fork through its parent lineage" ); assert!( - check_tag_available_for_workspace_internal(&db, fork, "bare", email, None) + check_tag_available_for_workspace_internal(&db, fork, "bare", is_super_admin, None) .await .is_err(), "an unmarked tag must not reach a fork of the workspace it names" diff --git a/backend/windmill-api-assets/tests/dbt_pinned_graph.rs b/backend/windmill-api-assets/tests/dbt_pinned_graph.rs index 82e32797d0..7f4979a96f 100644 --- a/backend/windmill-api-assets/tests/dbt_pinned_graph.rs +++ b/backend/windmill-api-assets/tests/dbt_pinned_graph.rs @@ -32,6 +32,7 @@ fn outsider() -> ApiAuthed { is_session_token: false, token_prefix: None, read_only: false, + job_id: None, } } @@ -417,9 +418,16 @@ async fn an_editor_graph_renders_only_through_its_own_job(db: Pool) { // Through the PATH — which is what the workspace graph and every run of the // deployed version ask for — a buffer parse must not appear at all. It // describes an editor's unsaved state, not what the script owns. - let workspace = asset_graph_for(&admin, WS, UserDB::new(db.clone()), db.clone(), query(), None) - .await - .unwrap(); + let workspace = asset_graph_for( + &admin, + WS, + UserDB::new(db.clone()), + db.clone(), + query(), + None, + ) + .await + .unwrap(); let workspace = serde_json::to_value(&workspace.0).unwrap().to_string(); assert!( !workspace.contains("u/a/wh/analytics/draft"), diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index b3ad811b7f..dead5fecca 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -137,6 +137,20 @@ impl AuthCache { &self, w_id: Option, token: &str, + ) -> Option { + let mut opt_job_authed = self.get_opt_job_authed_inner(w_id, token).await?; + // Single source of truth: mirror the resolved job_id onto the authed so + // every consumer (require_super_admin, ...) sees that this identity came + // from a job's WM_TOKEN, even on an AUTH_CACHE hit whose cached authed + // predates this field. + opt_job_authed.authed.job_id = opt_job_authed.job_id; + Some(opt_job_authed) + } + + async fn get_opt_job_authed_inner( + &self, + w_id: Option, + token: &str, ) -> Option { // In no-auth mode there are no real tokens: resolve directly as the // admin superadmin so direct cache callers (e.g. get_all_runnables, @@ -218,8 +232,21 @@ impl AuthCache { is_session_token, token_prefix: claims.audit_span, read_only: false, + job_id: None, + }; + // Fail closed: a `job_id` claim that does not parse must reject + // the token rather than resolve to `None`, which would clear the + // job provenance and uncap the token (GHSA-hfh4-cx4h-3fcr). + let job_id = match claims.job_id { + Some(j) => match uuid::Uuid::from_str(&j) { + Ok(job_id) => Some(job_id), + Err(_) => { + tracing::error!("JWT auth error: job_id claim is not a uuid"); + return None; + } + }, + None => None, }; - let job_id = claims.job_id.and_then(|j| uuid::Uuid::from_str(&j).ok()); AUTH_CACHE.insert( key, ExpiringAuthCache { @@ -319,6 +346,7 @@ impl AuthCache { is_session_token, token_prefix: Some(safe_token_prefix(token)), read_only, + job_id: None, }) } else { tracing::warn!( @@ -371,6 +399,7 @@ impl AuthCache { is_session_token, token_prefix: Some(safe_token_prefix(token)), read_only, + job_id: None, }) } else { tracing::warn!( @@ -446,6 +475,7 @@ impl AuthCache { is_session_token, token_prefix: Some(safe_token_prefix(token)), read_only, + job_id: None, }) } None if super_admin => { @@ -469,6 +499,7 @@ impl AuthCache { is_session_token, token_prefix: Some(safe_token_prefix(token)), read_only, + job_id: None, }), Err(e) => { tracing::error!( @@ -494,6 +525,7 @@ impl AuthCache { is_session_token, token_prefix: Some(safe_token_prefix(token)), read_only, + job_id: None, }) } } @@ -531,6 +563,7 @@ impl AuthCache { is_session_token: false, token_prefix: Some(safe_token_prefix(token)), read_only: false, + job_id: None, }; Some(OptJobAuthed { authed, job_id: None }) } else { @@ -740,6 +773,7 @@ fn no_auth_admin_authed() -> ApiAuthed { is_session_token: false, token_prefix: None, read_only: false, + job_id: None, } } diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 88eb11093a..ecd193165e 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -73,6 +73,11 @@ pub struct ApiAuthed { pub is_session_token: bool, pub token_prefix: Option, pub read_only: bool, + /// Set when this authed was resolved from a job's `WM_TOKEN`. Such a token's + /// identity is derived from an app/flow `on_behalf_of` that a `wm_deployers` + /// member can point at a superadmin, so it must never be trusted as a global + /// superadmin (`require_super_admin`), GHSA-hfh4-cx4h-3fcr. + pub job_id: Option, } impl ApiAuthed { @@ -159,6 +164,7 @@ impl From for ApiAuthed { is_session_token: false, token_prefix: value.token_prefix, read_only: false, + job_id: None, } } } @@ -247,7 +253,10 @@ impl windmill_mcp::server::McpAuth for ApiAuthed { // ------------ Utility functions ------------ -pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> { +/// Assert the *email* belongs to a superadmin. Prefer [`require_super_admin`], +/// which also rejects job tokens (`WM_TOKEN`); use this only where no `ApiAuthed` +/// is available and the caller has separately guaranteed it is not a job token. +pub async fn require_super_admin_email(db: &DB, email: &str) -> error::Result<()> { let is_admin = is_super_admin_email(db, email).await?; if !is_admin { @@ -259,6 +268,66 @@ pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> { } } +/// Assert the caller is a superadmin acting under their own credentials. +/// +/// A job's `WM_TOKEN` runs as the runnable's `on_behalf_of` identity, which a +/// non-superadmin `wm_deployers` member can point at a superadmin — so a job +/// token must never satisfy a global superadmin gate regardless of whose email +/// it carries (GHSA-hfh4-cx4h-3fcr). A real superadmin needing this from a script +/// uses a dedicated superadmin token instead of `$WM_TOKEN`. +pub async fn require_super_admin(db: &DB, authed: &ApiAuthed) -> error::Result<()> { + if authed.job_id.is_some() { + return Err(Error::NotAuthorized( + "This endpoint cannot be called with a job token ($WM_TOKEN). If a script \ + genuinely needs to do this, create a dedicated superadmin token from the User \ + settings drawer (the 'Tokens' section), store it as a secret, and use that token \ + explicitly instead of $WM_TOKEN." + .to_owned(), + )); + } + require_super_admin_email(db, &authed.email).await +} + +/// Job-token-aware superadmin predicate for the many boolean `is_super_admin_email` +/// authorization branches (workspace deletion, fork drops, SSRF exemptions, ...). +/// A job's `WM_TOKEN` is never a superadmin regardless of whose email it carries +/// (GHSA-hfh4-cx4h-3fcr), so callers naturally fall through to the restricted path. +pub async fn is_super_admin_authed(db: &DB, authed: &ApiAuthed) -> error::Result { + if authed.job_id.is_some() { + return Ok(false); + } + is_super_admin_email(db, &authed.email).await +} + +/// Instance-global admin predicate, job-token-aware. `ApiAuthed::is_admin` is a +/// *workspace*-admin claim (also true for superadmins), and a `WM_TOKEN` is capped +/// at workspace admin (GHSA-hfh4-cx4h-3fcr). Routes with no workspace binding that +/// treat `is_admin` as instance authorization (worker-group config, arbitrary +/// workspace unarchive, global concurrency pruning) must use this instead of the +/// raw `authed.is_admin`, so a job token can't wield a workspace-admin claim as an +/// instance action. Interactive admins are unaffected. +pub fn is_instance_admin(authed: &ApiAuthed) -> bool { + authed.is_admin && authed.job_id.is_none() +} + +/// Hard-gate variant of [`is_instance_admin`] for instance-global routes: rejects +/// a job token (`WM_TOKEN`) explicitly, then requires admin. +pub fn require_instance_admin(authed: &ApiAuthed) -> error::Result<()> { + if authed.job_id.is_some() { + return Err(Error::NotAuthorized( + "This endpoint cannot be called with a job token ($WM_TOKEN): it is an \ + instance-global admin action and a job token is capped at workspace admin. \ + If a script genuinely needs this, create a dedicated token from the User \ + settings drawer and use it explicitly instead of $WM_TOKEN." + .to_owned(), + )); + } + if !authed.is_admin { + return Err(Error::RequireAdmin(authed.username.clone())); + } + Ok(()) +} + /// Forbid sensitive global user/token management when authenticated as a /// superadmin *via a job token* (`WM_TOKEN`). /// @@ -286,6 +355,53 @@ pub async fn forbid_superadmin_job_token( Ok(()) } +/// Forbid *minting a durable credential* from a job token that carries an elevated +/// instance identity (superadmin or `devops`; [`is_devops_email`] covers both). +/// +/// The gates that cap `$WM_TOKEN` key off `ApiAuthed::job_id`, which only a job +/// token carries. A token minted from one is an ordinary database-backed token with +/// no such provenance, so it passes every one of those gates by email alone — the +/// cap would last only until the script exchanged its token for a fresh one +/// (GHSA-hfh4-cx4h-3fcr). Narrower than rejecting all job tokens: a script running +/// as an unprivileged identity has nothing to launder and still mints freely. +pub async fn forbid_elevated_job_token( + db: &DB, + email: &str, + job_id: Option, +) -> error::Result<()> { + if job_id.is_some() && is_devops_email(db, email).await? { + return Err(Error::NotAuthorized( + "A job token ($WM_TOKEN) running as a superadmin or devops user cannot mint a new \ + token, which would carry that identity without the job provenance that caps it. \ + If a script genuinely needs this, create a dedicated token from the User settings \ + drawer (the 'Tokens' section), store it as a secret, and use that token explicitly \ + instead of $WM_TOKEN." + .to_owned(), + )); + } + Ok(()) +} + +/// Forbid an irreversible action against the *account* a job token runs as. +/// +/// A job token borrows an `on_behalf_of` identity to do the runnable's work, and a +/// `wm_deployers` member may point that at any real user. Destroying the account or +/// its credentials is never that work, and unlike the privilege gates the damage +/// does not depend on the identity being elevated — so this rejects every job +/// token, not just superadmin/devops ones (GHSA-hfh4-cx4h-3fcr). +pub fn forbid_job_token_account_destruction(authed: &ApiAuthed) -> error::Result<()> { + if authed.job_id.is_some() { + return Err(Error::NotAuthorized( + "This endpoint cannot be called with a job token ($WM_TOKEN): it would destroy the \ + account or credentials of the identity the job runs as. If this is genuinely \ + intended, do it from the User settings drawer, or with a dedicated token created \ + there and used explicitly instead of $WM_TOKEN." + .to_owned(), + )); + } + Ok(()) +} + pub fn check_scopes(authed: &ApiAuthed, required: F) -> error::Result<()> where F: FnOnce() -> String, @@ -651,10 +767,24 @@ pub fn build_scope_path_filter(authed: &ApiAuthed, domain: &str, action: &str) - ScopePathFilter::Restricted { exact, prefix } } -pub async fn require_devops_role(db: &DB, email: &str) -> error::Result<()> { - let is_devops = is_devops_email(db, email).await?; - - if is_devops { +/// Assert the caller holds the instance-level `devops` role under their own +/// credentials. +/// +/// `devops` is instance-level and [`is_devops_email`] is also true for +/// superadmins, so this gate is reachable by the same job token that +/// [`require_super_admin`] rejects, and is capped the same way +/// (GHSA-hfh4-cx4h-3fcr). +pub async fn require_devops_role(db: &DB, authed: &ApiAuthed) -> error::Result<()> { + if authed.job_id.is_some() { + return Err(Error::NotAuthorized( + "This endpoint cannot be called with a job token ($WM_TOKEN). If a script \ + genuinely needs this, create a dedicated token from the User settings drawer \ + (the 'Tokens' section), store it as a secret, and use that token explicitly \ + instead of $WM_TOKEN." + .to_owned(), + )); + } + if is_devops_email(db, &authed.email).await? { Ok(()) } else { Err(Error::NotAuthorized( @@ -913,6 +1043,7 @@ pub async fn fetch_api_authed_from_permissioned_as( is_session_token: false, token_prefix: authed.token_prefix, read_only: false, + job_id: None, }; API_AUTHED_CACHE.insert( diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs index f776712063..7ad99996d5 100644 --- a/backend/windmill-api-configs/src/lib.rs +++ b/backend/windmill-api-configs/src/lib.rs @@ -23,7 +23,7 @@ use windmill_common::{ DB, }; -use windmill_api_auth::{require_devops_role, ApiAuthed}; +use windmill_api_auth::{is_instance_admin, require_devops_role, ApiAuthed}; pub fn global_service() -> Router { Router::new() @@ -75,7 +75,10 @@ async fn list_worker_groups( } } } - let configs = if !authed.is_admin { + // Worker-group configs are instance-global and expose env_vars_static (may hold + // secrets); a job token (capped at workspace admin) gets the obfuscated view even + // when its identity is a superadmin. See is_instance_admin (GHSA-hfh4-cx4h-3fcr). + let configs = if !is_instance_admin(&authed) { let mut obfuscated_configs: Vec = vec![]; for config in configs_raw { let config_value_opt = config.config.as_object().map(|obj| obj.to_owned()); @@ -117,7 +120,7 @@ async fn get_config( Path(name): Path, Extension(db): Extension, ) -> error::JsonResult> { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; let config = sqlx::query_as!(Config, "SELECT name, config FROM config WHERE name = $1", name) .fetch_optional(&db) @@ -133,7 +136,7 @@ async fn update_config( authed: ApiAuthed, Json(config): Json, ) -> error::Result { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; #[cfg(not(feature = "enterprise"))] let config = if name.starts_with("worker__") { @@ -212,7 +215,7 @@ async fn delete_config( Extension(db): Extension, authed: ApiAuthed, ) -> error::Result { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; let mut tx = db.begin().await?; @@ -280,7 +283,7 @@ async fn native_kubernetes_autoscaling_healthcheck( authed: ApiAuthed, Extension(db): Extension, ) -> Result<(), windmill_autoscaling::kubernetes_integration_ee::KubeError> { - require_devops_role(&db, &authed.email).await.map_err(|e| { + require_devops_role(&db, &authed).await.map_err(|e| { windmill_autoscaling::kubernetes_integration_ee::KubeError::Other(e.to_string()) })?; @@ -317,7 +320,7 @@ async fn list_configs( authed: ApiAuthed, Extension(db): Extension, ) -> error::JsonResult> { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; let configs = sqlx::query_as!(Config, "SELECT name, config FROM config") .fetch_all(&db) .await?; @@ -342,7 +345,7 @@ async fn list_all_workspace_dependencies( authed: ApiAuthed, Extension(db): Extension, ) -> error::JsonResult> { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; let deps = sqlx::query!( r#"SELECT workspace_id, name, language AS "language: windmill_common::scripts::ScriptLang" FROM workspace_dependencies @@ -374,7 +377,7 @@ async fn list_all_dedicated_with_deps( authed: ApiAuthed, Extension(db): Extension, ) -> error::JsonResult> { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; let rows = sqlx::query!( r#"SELECT DISTINCT ON (workspace_id, path) diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 3c37523082..6353cd429a 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -612,8 +612,7 @@ async fn create_flow( // Apply folder default_permissioned_as on create when the caller did not // explicitly preserve a value and the user can preserve. - let explicit_preserve = (nf.on_behalf_of_email.is_some() - || nf.on_behalf_of.is_some()) + let explicit_preserve = (nf.on_behalf_of_email.is_some() || nf.on_behalf_of.is_some()) && nf.preserve_on_behalf_of.unwrap_or(false) && windmill_common::can_preserve_on_behalf_of(&authed); if !explicit_preserve && windmill_common::can_preserve_on_behalf_of(&authed) { @@ -633,16 +632,15 @@ async fn create_flow( check_schedule_conflict(&mut tx, &w_id, &nf.path).await?; let schema_str = nf.schema.and_then(|x| serde_json::to_string(&x.0).ok()); - let resolved_on_behalf_of = - windmill_common::resolve_on_behalf_of( - nf.on_behalf_of_email.as_deref(), - nf.on_behalf_of.as_deref(), - nf.preserve_on_behalf_of.unwrap_or(false), - &authed, - &w_id, - &db, - ) - .await?; + let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( + nf.on_behalf_of_email.as_deref(), + nf.on_behalf_of.as_deref(), + nf.preserve_on_behalf_of.unwrap_or(false), + &authed, + &w_id, + &db, + ) + .await?; // Written beside the principal only while a worker that still reads it may be live. let legacy_on_behalf_of_email = windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db) @@ -1160,16 +1158,15 @@ async fn update_flow( let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?; let is_new_path = nf.path != flow_path; let schema_str = schema.and_then(|x| serde_json::to_string(&x).ok()); - let resolved_on_behalf_of = - windmill_common::resolve_on_behalf_of( - nf.on_behalf_of_email.as_deref(), - nf.on_behalf_of.as_deref(), - nf.preserve_on_behalf_of.unwrap_or(false), - &authed, - &w_id, - &db, - ) - .await?; + let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( + nf.on_behalf_of_email.as_deref(), + nf.on_behalf_of.as_deref(), + nf.preserve_on_behalf_of.unwrap_or(false), + &authed, + &w_id, + &db, + ) + .await?; // Written beside the principal only while a worker that still reads it may be live. let legacy_on_behalf_of_email = windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db) diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index 69b50e4b83..a15b9823d3 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -299,7 +299,7 @@ async fn create_igroup( ) -> Result { use uuid::Uuid; - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let mut tx = db.begin().await?; let normalized_name = convert_name(&ng.name); @@ -464,7 +464,7 @@ async fn update_igroup( Path(name): Path, Json(igroup_update): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let mut tx: Transaction<'_, Postgres> = db.begin().await?; let exists_opt = sqlx::query("SELECT 1 FROM instance_group WHERE name = $1") @@ -656,7 +656,7 @@ async fn delete_igroup( Extension(db): Extension, Path(name): Path, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let mut tx: Transaction<'_, Postgres> = db.begin().await?; // FOR UPDATE: the group row is the group-level mutex, taken before the workspace @@ -970,7 +970,7 @@ async fn add_user_igroup( Path(name): Path, Json(Email { email }): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let mut tx: Transaction<'_, Postgres> = db.begin().await?; @@ -1189,7 +1189,7 @@ async fn remove_user_igroup( Path(name): Path, Json(Email { email }): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let mut tx = db.begin().await?; // FOR UPDATE: the group row is the group-level mutex, taken before the workspace @@ -1330,7 +1330,7 @@ async fn export_igroups( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let mut tx = db.begin().await?; let igroups = sqlx::query_as!( ExportedIGroup, @@ -1366,7 +1366,7 @@ async fn overwrite_igroups( Extension(db): Extension, Json(igroups): Json>, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let mut tx = db.begin().await?; // The import replaces the whole group catalog, so the whole-table lock is its diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index c86ba3154f..fe80e5fdf4 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -62,6 +62,7 @@ fn test_authed() -> ApiAuthed { is_session_token: false, token_prefix: None, read_only: false, + job_id: None, } } diff --git a/backend/windmill-api-jobs/src/concurrency_groups.rs b/backend/windmill-api-jobs/src/concurrency_groups.rs index b3692ca910..4040d41bd2 100644 --- a/backend/windmill-api-jobs/src/concurrency_groups.rs +++ b/backend/windmill-api-jobs/src/concurrency_groups.rs @@ -1,9 +1,8 @@ -use windmill_api_auth::{check_scopes, ApiAuthed}; +use windmill_api_auth::{check_scopes, is_instance_admin, require_instance_admin, ApiAuthed}; use windmill_common::{ db::{UserDB, DB}, error::Error::PermissionDenied, error::{self, JsonResult}, - utils::require_admin, }; use crate::query::{filter_list_completed_query, filter_list_queue_query}; @@ -44,7 +43,9 @@ async fn list_concurrency_groups( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { - require_admin(authed.is_admin, &authed.username)?; + // Instance-global: the listing spans every workspace's concurrency keys, so a job + // token's workspace-admin claim must not reach it (mirrors the prune route below). + require_instance_admin(&authed)?; let concurrency_counts = sqlx::query_as::<_, (String, i64)>( "SELECT concurrency_id, (select COUNT(*) from jsonb_object_keys(job_uuids)) as n_job_uuids FROM concurrency_counter", @@ -67,7 +68,9 @@ async fn prune_concurrency_group( Extension(db): Extension, Path(concurrency_key): Path, ) -> JsonResult<()> { - if !authed.is_admin { + // Global concurrency-group pruning gated on the caller's own is_admin claim, + // so a job token (capped at workspace admin) must not pass. + if !is_instance_admin(&authed) { return Err(PermissionDenied( "Only administrators can delete concurrency groups".to_string(), )); @@ -283,7 +286,8 @@ async fn get_concurrent_intervals( // This second transaction uses the db, so it will fetch information // potentially forbidden to the user. It must be obscured before // returning it - let running_jobs_db: Vec = if lq.success.is_none() && lq.resolved != Some(true) { + let running_jobs_db: Vec = if lq.success.is_none() && lq.resolved != Some(true) + { sqlx::query_as(&sql_q).fetch_all(&db).await? } else { vec![] diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index ebee1bcbbf..fbfd656cdf 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -56,7 +56,9 @@ pub async fn check_tag_available_for_workspace( ) -> error::Result<()> { if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) { let tags = get_scope_tags(authed); - check_tag_available_for_workspace_internal(db, w_id, tag, &authed.email, tags).await + // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags. + let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?; + check_tag_available_for_workspace_internal(db, w_id, tag, is_super_admin, tags).await } else { Ok(()) } diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index ec790ec060..6ae902e984 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -339,6 +339,13 @@ async fn create_schedule( ) .await?; + // Reject a forged superadmin run identity in a preserved permissioned_as + // (the sentinel guard; the email is derived from it so it always belongs). + windmill_common::auth::validate_on_behalf_of( + Some(&resolved_permissioned_as), + Some(&resolved_email), + )?; + let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?; check_path_conflict(&mut tx, &w_id, &ns.path).await?; @@ -571,6 +578,13 @@ async fn edit_schedule( authed.email.clone() }; + // Reject a forged superadmin run identity in a preserved permissioned_as + // (the sentinel guard; the email is derived from it so it always belongs). + windmill_common::auth::validate_on_behalf_of( + Some(&resolved_permissioned_as), + Some(&resolved_email), + )?; + let before = trigger_history::snapshot_row(&mut *tx, "schedule", &w_id, path).await?; let schedule = sqlx::query_as!( @@ -1413,7 +1427,7 @@ async fn set_default_error_handler( Path(w_id): Path, Json(payload): Json, ) -> Result<()> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let (key, value) = match payload.handler_type { HandlerType::Error => { let key = format!("default_error_handler_{}", w_id); diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 7c2453d848..a9bc601591 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -50,7 +50,6 @@ use windmill_common::secret_backend::{ AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, }; use windmill_common::{ - auth::is_super_admin_email, ee_oss::{get_license_plan, LicensePlan}, email_oss::{send_email_plain_text, SMTP_ENABLED}, error::{self, pg_error_message, JsonResult, Result}, @@ -236,7 +235,7 @@ pub async fn test_email( authed: ApiAuthed, Json(test_email): Json, ) -> error::Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; if !SMTP_ENABLED { return Err(error::Error::Generic( axum::http::StatusCode::NOT_IMPLEMENTED, @@ -290,7 +289,7 @@ pub async fn test_s3_bucket( // local-filesystem surface (see validate_object_storage_test). On self-hosted instances the // object store usually lives on the local/private network and all authenticated users are // trusted, so testing there stays unrestricted. Super admins keep the unrestricted path too. - let is_super_admin = is_super_admin_email(&db, &authed.email).await?; + let is_super_admin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?; let restrict = !is_super_admin && *CLOUD_HOSTED; if restrict { validate_object_storage_test(&test_s3_bucket).await?; @@ -590,7 +589,7 @@ async fn get_object_storage_usage( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; Ok(Json(storage_usage::get_status(&db).await?)) } @@ -599,7 +598,7 @@ async fn compute_object_storage_usage( Extension(db): Extension, authed: ApiAuthed, ) -> error::Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; storage_usage::try_start(&db).await?; storage_usage::spawn_compute(db.clone()); Ok(axum::http::StatusCode::ACCEPTED) @@ -610,7 +609,7 @@ async fn run_log_cleanup( Extension(db): Extension, authed: ApiAuthed, ) -> error::Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; log_cleanup::try_start(&db).await?; log_cleanup::spawn_cleanup(db.clone()); Ok(axum::http::StatusCode::ACCEPTED) @@ -621,7 +620,7 @@ async fn log_cleanup_status( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; Ok(Json(log_cleanup::get_status(&db).await?)) } @@ -630,7 +629,7 @@ async fn audit_logs_s3_status( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; Ok(Json(audit_logs_s3::get_status(&db).await?)) } @@ -640,7 +639,7 @@ async fn run_audit_logs_s3_backfill( authed: ApiAuthed, Json(req): Json, ) -> error::Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; if !matches!(get_license_plan().await, LicensePlan::Enterprise) { return Err(error::Error::BadRequest( "Audit log export to object storage is an Enterprise feature".to_string(), @@ -656,7 +655,7 @@ async fn audit_logs_s3_backfill_status( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; Ok(Json(audit_logs_s3_backfill::get_status(&db).await?)) } @@ -670,7 +669,7 @@ pub async fn test_license_key( authed: ApiAuthed, Json(TestKey { license_key }): Json, ) -> error::Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let (_, expired, _offline_meta) = validate_license_key(license_key, Some(&db)).await?; if expired { @@ -691,7 +690,7 @@ pub async fn get_offline_license_status( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let offline = (**windmill_common::ee_oss::LICENSE_OFFLINE_METADATA.load()).clone(); let is_offline = matches!(&offline, Some(m) if m.is_offline()); @@ -717,7 +716,7 @@ pub async fn get_instance_hash( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; #[cfg(feature = "enterprise")] let hash = windmill_common::ee_oss::compute_instance_hash(&db) .await @@ -731,7 +730,7 @@ pub async fn get_local_settings( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let mut settings = serde_json::Map::new(); for key in ENV_SETTINGS.iter() { @@ -790,7 +789,7 @@ pub async fn set_global_setting( Path(key): Path, Json(value): Json, ) -> error::Result<()> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; set_global_setting_internal(&db, key, value.value.unwrap_or(serde_json::Value::Null)).await } @@ -1149,7 +1148,7 @@ async fn get_instance_config( Extension(db): Extension, authed: ApiAuthed, ) -> JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let config = InstanceConfig::from_db(&db) .await .map_err(|e| error::Error::internal_err(e.to_string()))?; @@ -1160,7 +1159,7 @@ async fn get_instance_config_yaml( Extension(db): Extension, authed: ApiAuthed, ) -> error::Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let config = InstanceConfig::from_db(&db) .await .map_err(|e| error::Error::internal_err(e.to_string()))?; @@ -1178,7 +1177,7 @@ async fn set_instance_config( authed: ApiAuthed, Json(desired): Json, ) -> error::Result<()> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let current = InstanceConfig::from_db(&db) .await @@ -1277,7 +1276,7 @@ pub async fn get_global_setting( && key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING && key != WS_BASE_URL_SETTING { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; } let value = sqlx::query!("SELECT value FROM global_settings WHERE name = $1", key) .fetch_optional(&db) @@ -1301,7 +1300,7 @@ async fn github_app_stale_webhooks( Extension(_db): Extension, authed: ApiAuthed, ) -> JsonResult { - require_super_admin(&_db, &authed.email).await?; + require_super_admin(&_db, &authed).await?; #[cfg(all(feature = "enterprise", feature = "private"))] { let stale = windmill_common::git_sync_ee::stale_webhook_repos(&_db).await?; @@ -1318,7 +1317,7 @@ async fn list_global_settings( Extension(db): Extension, authed: ApiAuthed, ) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let settings = sqlx::query_as!(GlobalSetting, "SELECT name, value FROM global_settings") .fetch_all(&db) .await?; @@ -1334,7 +1333,7 @@ async fn list_global_settings() -> JsonResult { } pub async fn send_stats(Extension(db): Extension, authed: ApiAuthed) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; windmill_common::stats_oss::send_stats( &HTTP_CLIENT, &db, @@ -1351,7 +1350,7 @@ async fn restart_worker_group( authed: ApiAuthed, Path(worker_group): Path, ) -> error::Result { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; sqlx::query!( "INSERT INTO notify_event (channel, payload) VALUES ('restart_worker_group', $1)", @@ -1376,7 +1375,7 @@ pub async fn get_stats( Extension(db): Extension, authed: ApiAuthed, ) -> error::JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let stats = windmill_common::stats_oss::get_stats_payload( &db, &windmill_common::stats_oss::SendStatsReason::Manual, @@ -1406,7 +1405,7 @@ pub async fn get_latest_key_renewal_attempt( Extension(db): Extension, authed: ApiAuthed, ) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let last_attempt = sqlx::query!( "SELECT value, created_at FROM metrics WHERE id = $1 ORDER BY created_at DESC LIMIT 1", @@ -1449,7 +1448,7 @@ pub async fn renew_license_key( Query(LicenseQuery { license_key }): Query, authed: ApiAuthed, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let result = windmill_common::ee_oss::renew_license_key( &HTTP_CLIENT, &db, @@ -1495,7 +1494,7 @@ pub async fn test_critical_channels( authed: ApiAuthed, Json(test_critical_channels): Json>, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; #[cfg(feature = "enterprise")] send_critical_alert( @@ -1519,7 +1518,7 @@ pub async fn get_critical_alerts( authed: ApiAuthed, Query(params): Query, ) -> JsonResult { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; windmill_alerting::get_critical_alerts(db, params, None).await } @@ -1535,7 +1534,7 @@ pub async fn acknowledge_critical_alert( authed: ApiAuthed, Path(id): Path, ) -> error::Result { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; windmill_alerting::acknowledge_critical_alert(db, None, id).await } @@ -1549,7 +1548,7 @@ pub async fn acknowledge_all_critical_alerts( Extension(db): Extension, authed: ApiAuthed, ) -> error::Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; windmill_alerting::acknowledge_all_critical_alerts(db, None).await } @@ -1607,7 +1606,7 @@ async fn list_custom_instance_pg_databases( )) })?; - if is_super_admin_email(&db, &authed.email).await? { + if windmill_api_auth::is_super_admin_authed(&db, &authed).await? { // Enrich each database with the list of workspaces referencing it through // either a ducklake catalog or a datatable database whose resource_type is // 'instance'. Not stored in DB to avoid drift. @@ -1657,7 +1656,7 @@ async fn refresh_custom_instance_user_pwd( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult<()> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; windmill_common::utils::refresh_custom_instance_user_pwd(&db).await?; windmill_common::utils::refresh_custom_instance_replication_user_pwd(&db).await?; Ok(Json(())) @@ -1696,7 +1695,7 @@ async fn setup_custom_instance_pg_database_inner( dbname: &str, logs: &mut CustomInstanceDbLogs, ) -> Result<()> { - require_super_admin(db, &authed.email).await?; + require_super_admin(db, &authed).await?; logs.super_admin = "OK".to_string(); let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; logs.database_credentials = "OK".to_string(); @@ -1812,7 +1811,7 @@ async fn drop_custom_instance_pg_database( Extension(db): Extension, Path(dbname): Path, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; windmill_common::drop_custom_instance_database(&db, &dbname).await?; @@ -1835,7 +1834,7 @@ pub async fn test_secret_backend( authed: ApiAuthed, Json(settings): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; windmill_common::secret_backend::test_vault_connection(&settings, Some(&db)).await?; @@ -1855,7 +1854,7 @@ pub async fn migrate_secrets_to_vault( authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let report = windmill_common::secret_backend::migrate_secrets_to_vault(&db, &settings).await?; @@ -1875,7 +1874,7 @@ pub async fn migrate_secrets_to_database( authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let report = windmill_common::secret_backend::migrate_secrets_to_database(&db, &settings).await?; @@ -1892,7 +1891,7 @@ pub async fn test_azure_kv_backend( authed: ApiAuthed, Json(settings): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; windmill_common::secret_backend::test_azure_kv_connection(&settings).await?; @@ -1908,7 +1907,7 @@ pub async fn migrate_secrets_to_azure_kv( authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let report = windmill_common::secret_backend::migrate_secrets_to_azure_kv(&db, &settings).await?; @@ -1925,7 +1924,7 @@ pub async fn migrate_secrets_from_azure_kv( authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let report = windmill_common::secret_backend::migrate_secrets_from_azure_kv(&db, &settings).await?; @@ -1940,7 +1939,7 @@ pub async fn test_aws_sm_backend( authed: ApiAuthed, Json(settings): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; windmill_common::secret_backend::test_aws_sm_connection(&settings).await?; Ok("Successfully connected to AWS Secrets Manager".to_string()) } @@ -1952,7 +1951,7 @@ pub async fn migrate_secrets_to_aws_sm( authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let report = windmill_common::secret_backend::migrate_secrets_to_aws_sm(&db, &settings).await?; Ok(Json(report)) } @@ -1964,7 +1963,7 @@ pub async fn migrate_secrets_from_aws_sm( authed: ApiAuthed, Json(settings): Json, ) -> JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let report = windmill_common::secret_backend::migrate_secrets_from_aws_sm(&db, &settings).await?; Ok(Json(report)) @@ -2063,7 +2062,7 @@ async fn sync_cached_resource_types( authed: ApiAuthed, Query(SyncResourceTypesQuery { name }): Query, ) -> error::Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; use windmill_common::worker::HUB_RT_CACHE_DIR; let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR); diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 67e1721109..0c83d5f6ec 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -27,7 +27,10 @@ use axum::{ Json, Router, }; use hyper::{header::LOCATION, StatusCode}; -use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin, OptJobAuthed}; +use windmill_api_auth::{ + forbid_elevated_job_token, forbid_job_token_account_destruction, forbid_superadmin_job_token, + require_super_admin, OptJobAuthed, +}; use windmill_common::usernames::{ generate_instance_wide_unique_username, get_instance_username_or_create_pending, }; @@ -427,7 +430,7 @@ async fn list_addable_instance_users( Path(w_id): Path, Query(AddableInstanceUsersQuery { search, per_page }): Query, ) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let per_page = per_page.unwrap_or(10).clamp(1, 100); // An absent search yields '%%', which matches every row. let search = format!( @@ -500,7 +503,7 @@ async fn list_users_as_super_admin( Query(pagination): Query, Query(ActiveUsersOnly { active_only }): Query, ) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let per_page = pagination.per_page.unwrap_or(10000).max(1); let offset = (pagination.page.unwrap_or(1).max(1) - 1) * per_page; @@ -1231,6 +1234,7 @@ async fn join_workspace<'c>( } async fn leave_instance(Extension(db): Extension, authed: ApiAuthed) -> Result { + forbid_job_token_account_destruction(&authed)?; let mut tx = db.begin().await?; sqlx::query!("DELETE FROM password WHERE email = $1", &authed.email) .execute(&mut *tx) @@ -1471,7 +1475,7 @@ async fn update_user( Extension(db): Extension, Json(eu): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; let mut tx = db.begin().await?; @@ -1647,7 +1651,7 @@ async fn delete_user( Path(email_to_delete): Path, Extension(db): Extension, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; let mut tx = db.begin().await?; @@ -1728,7 +1732,7 @@ async fn change_user_email( Extension(db): Extension, Json(ce): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; // The target is matched verbatim (accounts predating email normalization can hold uppercase), @@ -2297,12 +2301,10 @@ async fn change_user_email( // Read back inside the transaction: the address is derived at dispatch through a cache // that nothing else evicts, so without this a job pushed in the next 60s would resolve // the old address and with it the wrong superadmin flag and instance groups. - let memberships = sqlx::query_scalar!( - "SELECT workspace_id FROM usr WHERE email = $1", - &new_email - ) - .fetch_all(&mut *tx) - .await?; + let memberships = + sqlx::query_scalar!("SELECT workspace_id FROM usr WHERE email = $1", &new_email) + .fetch_all(&mut *tx) + .await?; tx.commit().await?; @@ -2604,7 +2606,7 @@ async fn set_login_type( OptJobAuthed { job_id, .. }: OptJobAuthed, Json(et): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; let mut tx = db.begin().await?; @@ -2747,6 +2749,18 @@ async fn refresh_token( authed: ApiAuthed, cookies: Cookies, ) -> Result { + // The session token minted below is database-backed and carries no job provenance, + // so a job token that exchanged itself for one would shed the `job_id` every + // `$WM_TOKEN` cap keys off (GHSA-hfh4-cx4h-3fcr). Only a browser session refreshes. + if authed.job_id.is_some() { + return Err(Error::NotAuthorized( + "This endpoint cannot be called with a job token ($WM_TOKEN). If a script \ + genuinely needs a token of its own, create a dedicated token from the User \ + settings drawer (the 'Tokens' section), store it as a secret, and use that \ + token explicitly instead of $WM_TOKEN." + .to_string(), + )); + } if let Some(thresh_s) = query.if_expiring_in_less_than_s { let t_hash = windmill_common::auth::hash_token(&token); let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &t_hash, thresh_s) @@ -2890,7 +2904,7 @@ async fn create_token( OptJobAuthed { job_id, .. }: OptJobAuthed, Json(token_config): Json, ) -> Result<(StatusCode, String)> { - forbid_superadmin_job_token(&db, &authed.email, job_id).await?; + forbid_elevated_job_token(&db, &authed.email, job_id).await?; check_token_create_rate_limit(&authed.username)?; // `username_override_from_label` trusts a server-minted label to name the entity acting, @@ -2934,7 +2948,7 @@ async fn impersonate( } else { Some(&token) }; - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; if new_token.impersonate_email.is_none() { @@ -3089,6 +3103,7 @@ async fn delete_token( authed: ApiAuthed, Path(token_prefix): Path, ) -> Result { + forbid_job_token_account_destruction(&authed)?; let mut tx = db.begin().await?; let tokens_deleted: Vec = sqlx::query_scalar( @@ -3133,6 +3148,11 @@ async fn update_token_scopes( Path(token_prefix): Path, Json(req): Json, ) -> Result { + // Widening is what makes a narrowly-scoped mint (app embed, raw-app SDK, MCP + // OAuth) recoverable as a general credential: a job token is unscoped, so the + // caller check below would let it clear the scopes of any token sharing its + // email (GHSA-hfh4-cx4h-3fcr). + forbid_elevated_job_token(&db, &authed.email, authed.job_id).await?; windmill_api_auth::ensure_scopes_within_caller(&authed, req.scopes.as_deref())?; let mut tx = db.begin().await?; @@ -3261,6 +3281,7 @@ async fn leave_workspace( Path(w_id): Path, authed: ApiAuthed, ) -> Result { + forbid_job_token_account_destruction(&authed)?; let mut tx = db.begin().await?; sqlx::query!( "DELETE FROM usr WHERE workspace_id = $1 AND username = $2", @@ -3402,11 +3423,11 @@ struct WorkspaceUsernameInfo { username: String, } async fn get_instance_username_info( - ApiAuthed { email, .. }: ApiAuthed, + authed: ApiAuthed, Path(user_email): Path, Extension(db): Extension, ) -> JsonResult { - require_super_admin(&db, &email).await?; + require_super_admin(&db, &authed).await?; let mut tx = db.begin().await?; let instance_username = match sqlx::query_scalar!( "SELECT username FROM password WHERE email = $1", @@ -3476,7 +3497,7 @@ async fn export_global_users( authed: ApiAuthed, OptJobAuthed { job_id, .. }: OptJobAuthed, ) -> JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; let mut tx = db.begin().await?; let users = sqlx::query_as!( @@ -3516,7 +3537,7 @@ async fn overwrite_global_users( OptJobAuthed { job_id, .. }: OptJobAuthed, Json(users): Json>, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; let mut tx = db.begin().await?; sqlx::query!("DELETE FROM password") diff --git a/backend/windmill-api-workers/src/lib.rs b/backend/windmill-api-workers/src/lib.rs index f33e12470f..6ee55ed003 100644 --- a/backend/windmill-api-workers/src/lib.rs +++ b/backend/windmill-api-workers/src/lib.rs @@ -103,7 +103,7 @@ async fn list_worker_pings( Extension(user_db): Extension, Query(query): Query, ) -> JsonResult> { - let has_devops_role = require_devops_role(&db, &authed.email).await.is_ok(); + let has_devops_role = require_devops_role(&db, &authed).await.is_ok(); if *HIDE_WORKERS_FOR_NON_ADMINS && !has_devops_role { return Ok(Json(vec![])); } @@ -159,7 +159,7 @@ async fn exists_workers_with_tags( // When TAGS_ARE_SENSITIVE is enabled, filter tags based on workspace visibility if *TAGS_ARE_SENSITIVE { - let has_devops_role = require_devops_role(&db, &authed.email).await.is_ok(); + let has_devops_role = require_devops_role(&db, &authed).await.is_ok(); if !has_devops_role { if let Some(ref workspace) = tags_query.workspace { // This route is global, so the workspace is an unauthorized query param: check @@ -229,7 +229,7 @@ async fn get_custom_tags( return Ok(Json(all_tags)); } if *TAGS_ARE_SENSITIVE { - let has_devops_role = require_devops_role(&db, &authed.email).await.is_ok(); + let has_devops_role = require_devops_role(&db, &authed).await.is_ok(); if !has_devops_role { return Ok(Json(vec![])); } @@ -268,7 +268,7 @@ async fn get_queue_metrics( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; let queue_metrics = sqlx::query_as!( QueueMetric, @@ -293,7 +293,7 @@ async fn get_queue_counts( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; let queue_counts = windmill_common::queue::get_queue_counts(&db).await; Ok(Json(queue_counts)) } @@ -302,7 +302,7 @@ async fn get_queue_running_counts( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; let queue_running_counts = windmill_common::queue::get_queue_running_counts(&db).await; Ok(Json(queue_running_counts)) } @@ -327,7 +327,7 @@ async fn get_workspace_fairness_events( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { - require_devops_role(&db, &authed.email).await?; + require_devops_role(&db, &authed).await?; // No cloud-host gate — workspace fairness is an Enterprise feature // available on any multi-tenant EE deployment. Non-EE / non-enabled diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index 5b1dde395a..14c6d49132 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -777,7 +777,7 @@ async fn datatable_migrations_status( /// Only workspace admins and super admins may opt a data table in or out of /// migrations. async fn require_datatable_migrations_manager(db: &DB, authed: &ApiAuthed) -> Result<()> { - if authed.is_admin || require_super_admin(db, &authed.email).await.is_ok() { + if authed.is_admin || require_super_admin(db, &authed).await.is_ok() { Ok(()) } else { Err(Error::BadRequest( diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 9bc4890fe6..b25b4a18f5 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -7,8 +7,8 @@ */ use windmill_api_auth::{ - build_scope_path_predicate, check_scopes, require_devops_role, require_is_writer, - require_super_admin, ApiAuthed, + build_scope_path_predicate, check_scopes, require_devops_role, require_instance_admin, + require_is_writer, require_super_admin, ApiAuthed, }; use windmill_api_users::users::WorkspaceInvite; use windmill_common::email_oss::send_email_if_possible; @@ -2846,7 +2846,7 @@ async fn create_pg_database( windmill_common::validate_dbname(&req.target_dbname)?; // Non-superadmin: restrict dbname to wm_fork_ prefix - if !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? { + if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { if !req.target_dbname.starts_with("wm_fork_") { return Err(Error::BadRequest( "Non-superadmin users can only create databases with names starting with 'wm_fork_'" @@ -2963,7 +2963,7 @@ async fn import_pg_database( resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?; if let Some(ref override_dbname) = req.target_dbname_override { - if !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? { + if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { if !override_dbname.starts_with("wm_fork_") { return Err(Error::BadRequest( "Non-superadmin users can only override target dbname with names starting with 'wm_fork_'" @@ -3037,7 +3037,7 @@ async fn edit_ducklake_config( Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; - let is_superadmin = require_super_admin(&db, &email).await.is_ok(); + let is_superadmin = require_super_admin(&db, &authed).await.is_ok(); // Lake names end up interpolated in `ATTACH 'ducklake://'`, // generated maintenance SQL and the reserved maintenance schedule path @@ -3130,11 +3130,11 @@ async fn edit_datatable_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, email, .. }: ApiAuthed, + ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(mut new_config): Json, ) -> Result { require_admin(is_admin, &username)?; - let is_superadmin = require_super_admin(&db, &email).await.is_ok(); + let is_superadmin = require_super_admin(&db, &authed).await.is_ok(); let mut tx = db.begin().await?; @@ -4783,7 +4783,7 @@ async fn set_encryption_key( Path(w_id): Path, Json(request): Json, ) -> Result<()> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; if !WORKSPACE_KEY_REGEXP.is_match(request.new_key.as_str()) { return Err(Error::BadRequest( @@ -4939,7 +4939,7 @@ async fn get_workspace_as_superadmin( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let workspace = sqlx::query_as!( Workspace, "SELECT @@ -4970,9 +4970,8 @@ async fn list_workspaces_as_super_admin( Extension(db): Extension, Extension(user_db): Extension, Query(pagination): Query, - ApiAuthed { email, .. }: ApiAuthed, ) -> JsonResult> { - require_devops_role(&db, &email).await?; + require_devops_role(&db, &authed).await?; let (per_page, offset) = paginate(pagination); let mut tx = user_db.begin(&authed).await?; @@ -5044,7 +5043,7 @@ struct SessionWorkspaceStatusRequest { /// lingering, so it is deliberately not treated as unreachable. async fn session_workspace_status( Extension(db): Extension, - ApiAuthed { email, .. }: ApiAuthed, + authed: ApiAuthed, Json(req): Json, ) -> JsonResult> { if req.workspace_ids.len() > 1000 { @@ -5052,7 +5051,8 @@ async fn session_workspace_status( "Too many workspace ids (max 1000)".to_string(), )); } - let is_superadmin = windmill_common::auth::is_super_admin_email(&db, &email).await?; + let email = &authed.email; + let is_superadmin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?; let rows = sqlx::query!( // A missing workspace row must be caught before the membership arm: for a // superadmin the two arms below both fall through, and a hard-deleted workspace @@ -5254,7 +5254,7 @@ async fn create_workspace( Json(nw): Json, ) -> Result { if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; } #[cfg(not(feature = "enterprise"))] @@ -6837,7 +6837,7 @@ async fn create_workspace_fork_branch( } if *DISABLE_WORKSPACE_FORK { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; } if let RuleCheckResult::Blocked(msg) = check_user_against_rule( &w_id, @@ -7249,7 +7249,7 @@ async fn create_workspace_fork( _check_nb_of_workspaces(&db).await?; if *DISABLE_WORKSPACE_FORK { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; } if let RuleCheckResult::Blocked(msg) = check_user_against_rule( &parent_workspace_id, @@ -7592,7 +7592,7 @@ async fn attach_dev_workspace( .fetch_optional(&db) .await? .unwrap_or(false); - if !is_admin_of_dev && !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? { + if !is_admin_of_dev && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::PermissionDenied(format!( "Attaching workspace '{dev_w_id}' as a dev requires being an admin of it (or a superadmin)" ))); @@ -8095,9 +8095,7 @@ async fn archive_workspace( .fetch_optional(&db) .await? .unwrap_or(false); - if !is_prod_admin - && !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? - { + if !is_prod_admin && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::PermissionDenied(format!( "Archiving dev workspace '{w_id}' requires being an admin of its parent prod workspace '{prod}' (or a superadmin)" ))); @@ -8172,6 +8170,7 @@ async fn leave_workspace( Path(w_id): Path, authed: ApiAuthed, ) -> Result { + windmill_api_auth::forbid_job_token_account_destruction(&authed)?; let mut tx = db.begin().await?; sqlx::query!( "DELETE FROM usr WHERE workspace_id = $1 AND email = $2", @@ -8201,7 +8200,9 @@ async fn unarchive_workspace( Path(w_id): Path, authed: ApiAuthed, ) -> Result { - require_admin(authed.is_admin, &authed.username)?; + // Global route (unarchives any workspace by id) gated on the caller's own + // is_admin claim, so it must reject a job token — see require_instance_admin. + require_instance_admin(&authed)?; // Unarchiving re-activates a soft-deleted workspace, so it must respect the // same CE workspace-count cap as creating one. The archived workspace is @@ -10222,7 +10223,7 @@ async fn compare_workspaces( // source AND the fork (superadmin satisfies both), which guarantees full // visibility of every item on every side. `fork_authed.is_admin` already folds // in superadmin; `authed.is_admin` (source side) does not, so OR it in. - let is_super_admin = windmill_common::auth::is_super_admin_email(&db, &authed.email).await?; + let is_super_admin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?; let sees_all_items = is_super_admin || (authed.is_admin && fork_authed.is_admin); let all_ahead_items_visible = all_ahead_items_visible || sees_all_items; let all_behind_items_visible = all_behind_items_visible || sees_all_items; @@ -10624,8 +10625,11 @@ async fn load_workspace_authed( .await .map_err(|e| Error::internal_err(e.to_string()))?; - let is_super_admin = - windmill_common::auth::is_super_admin_email(db, &base_authed.email).await?; + // Job-aware: this grants an admin claim in a workspace the caller may have no + // relationship with, and `job_id` is carried into the result — so a `WM_TOKEN` + // whose on-behalf identity is a superadmin would hold admin everywhere + // (GHSA-hfh4-cx4h-3fcr). It then falls through to its real membership below. + let is_super_admin = windmill_api_auth::is_super_admin_authed(db, base_authed).await?; let user_row = sqlx::query!( "SELECT username, is_admin, operator FROM usr @@ -10650,6 +10654,7 @@ async fn load_workspace_authed( is_session_token: base_authed.is_session_token, token_prefix: base_authed.token_prefix.clone(), read_only: base_authed.read_only, + job_id: base_authed.job_id, }); }; @@ -10681,6 +10686,7 @@ async fn load_workspace_authed( is_session_token: base_authed.is_session_token, token_prefix: base_authed.token_prefix.clone(), read_only: base_authed.read_only, + job_id: base_authed.job_id, }) } diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 55f7c4f15d..1968437e40 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -21,7 +21,6 @@ use windmill_audit::ActionKind; use windmill_common::worker::CLOUD_HOSTED; use windmill_common::{ - auth::is_super_admin_email, db::UserDB, error::{Error, Result}, utils::require_admin, @@ -43,14 +42,14 @@ pub(crate) async fn change_workspace_id( Extension(db): Extension, Json(rw): Json, ) -> Result { - if *CLOUD_HOSTED && !is_super_admin_email(&db, &authed.email).await? { + if *CLOUD_HOSTED && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::BadRequest( "This feature is not available on the cloud".to_string(), )); } if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; } else { require_admin(authed.is_admin, &authed.username)?; } @@ -929,7 +928,7 @@ pub(crate) async fn delete_workspace( let mut tx = db.begin().await?; if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?) - && !is_super_admin_email(&db, &authed.email).await? + && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::PermissionDenied( "Deleting this workspace requires being the fork's owner or a superadmin".to_string(), @@ -1297,7 +1296,7 @@ pub async fn drop_forked_datatable_databases( let is_fork = workspace_is_fork(&db, &w_id).await?; let mut tx = db.begin().await?; if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?) - && !is_super_admin_email(&db, &authed.email).await? + && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::PermissionDenied( "Dropping forked datatable databases requires being the fork's owner or a superadmin" @@ -1454,7 +1453,7 @@ pub async fn drop_forked_ducklake_namespaces( let is_fork = workspace_is_fork(&db, &w_id).await?; let mut tx = db.begin().await?; if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?) - && !is_super_admin_email(&db, &authed.email).await? + && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::PermissionDenied( "Dropping forked ducklake namespaces requires being the fork's owner or a superadmin" @@ -1959,7 +1958,7 @@ async fn require_prod_admin_for_dev_workspace( .fetch_optional(db) .await? .unwrap_or(false); - if !is_prod_admin && !is_super_admin_email(db, &authed.email).await? { + if !is_prod_admin && !windmill_api_auth::is_super_admin_authed(db, &authed).await? { return Err(Error::PermissionDenied(format!( "Destroying dev workspace '{w_id}' or its data requires being an admin of its parent prod workspace '{prod}' (or a superadmin)" ))); diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 3b0fc60d79..4a581443ad 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -84,7 +84,7 @@ use windmill_object_store::object_store_reexports::{Attribute, Attributes}; use windmill_store::resources::get_resource_value_interpolated_internal; use windmill_api_auth::{ - create_token_internal, ensure_scopes_within_caller, forbid_superadmin_job_token, NewToken, + create_token_internal, ensure_scopes_within_caller, forbid_elevated_job_token, NewToken, OptJobAuthed, }; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; @@ -1338,7 +1338,9 @@ async fn mint_raw_app_sdk_token( ) -> Result<(String, chrono::DateTime)> { // This credential outlives the request, so an ephemeral job token must not be // able to launder itself into one — the reason `users/tokens/create` refuses. - forbid_superadmin_job_token(db, &authed.email, job_id).await?; + // The minted scopes do not contain it: `users/tokens/update_scopes` can widen + // any token of the same email. + forbid_elevated_job_token(db, &authed.email, job_id).await?; // An embed token represents untrusted app JS; it must not bootstrap a // broader SDK credential (same guard as `mint_app_embed_token`). if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) { @@ -1411,7 +1413,7 @@ pub async fn build_embed_token_response( _ => (None, None), } } else if policy.sandbox { - let resp = mint_app_embed_token(db, w_id, app_path, opt_authed).await?; + let resp = mint_app_embed_token(db, w_id, app_path, opt_authed, job_id).await?; (resp.token, resp.expiration) } else { (None, None) @@ -1535,8 +1537,13 @@ pub async fn mint_app_embed_token( w_id: &str, app_path: &str, opt_authed: Option<&ApiAuthed>, + job_id: Option, ) -> Result { let token_and_exp = if let Some(authed) = opt_authed { + // This credential outlives the request and its narrow scopes are not the + // boundary — `users/tokens/update_scopes` can widen any same-email token — + // so an elevated job token must not mint one (GHSA-hfh4-cx4h-3fcr). + forbid_elevated_job_token(db, &authed.email, job_id).await?; // An app embed token represents untrusted app JS in the sandboxed iframe; it // must never reach this mint path to renew itself. The 12h expiry is the // blast-radius cap on a leaked embed token, and `ensure_scopes_within_caller` @@ -2186,6 +2193,14 @@ async fn create_app_internal<'a>( } } + // Reject a forged superadmin run identity in the (possibly preserved) policy. + // Done on the non-RLS pool before the transaction below, like the resolution + // above, to avoid holding a second connection while `tx` is checked out. + windmill_common::auth::validate_on_behalf_of( + app.policy.on_behalf_of.as_deref(), + app.policy.on_behalf_of_email.as_deref(), + )?; + let mut tx = user_db.clone().begin(&authed).await?; let path = app.path.clone(); if &app.path == "" { @@ -3074,6 +3089,22 @@ async fn update_app_internal<'a>( check_scopes(&authed, || format!("apps:write:{}", npath))?; } + // Reject a forged superadmin run identity in a preserved policy. Mirror the + // `should_preserve` gate below (only a preserved value is caller-controlled; + // otherwise the policy is rewritten to the deployer's own identity) and run + // it on the non-RLS pool before the transaction to avoid a second connection. + if let Some(npolicy) = ns.policy.as_ref() { + let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false) + && windmill_common::can_preserve_on_behalf_of(&authed) + && npolicy.on_behalf_of.is_some(); + if should_preserve { + windmill_common::auth::validate_on_behalf_of( + npolicy.on_behalf_of.as_deref(), + npolicy.on_behalf_of_email.as_deref(), + )?; + } + } + let mut tx = user_db.clone().begin(&authed).await?; // `app_version.raw_app` is set by whichever endpoint writes the version, so a @@ -5109,6 +5140,18 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> { ) })? .to_string(); + // Defence in depth against a policy that already carries a forged superadmin + // sentinel (deployed before validation existed, or copied verbatim by a + // workspace fork): the sentinels are internal-only and never a legitimate app + // run identity, so refuse to execute rather than mint a superadmin token. + if windmill_common::auth::is_reserved_on_behalf_of_identity( + Some(&permissioned_as), + Some(&email), + ) { + return Err(Error::BadRequest( + "app on_behalf_of is a reserved internal identity and cannot be executed".to_string(), + )); + } Ok((permissioned_as, email)) } diff --git a/backend/windmill-api/src/db_health.rs b/backend/windmill-api/src/db_health.rs index 0b2e86567b..02b2efd182 100644 --- a/backend/windmill-api/src/db_health.rs +++ b/backend/windmill-api/src/db_health.rs @@ -197,10 +197,10 @@ struct SlowQueriesQuery { } async fn get_db_health( - ApiAuthed { email, .. }: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult { - require_super_admin(&db, &email).await?; + require_super_admin(&db, &authed).await?; let (database_size, connection_pool, table_maintenance, slow_queries, datatables) = tokio::try_join!( fetch_database_size(&db), @@ -220,11 +220,11 @@ async fn get_db_health( } async fn get_db_health_jobs( - ApiAuthed { email, .. }: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Query(query): Query, ) -> JsonResult { - require_super_admin(&db, &email).await?; + require_super_admin(&db, &authed).await?; let scan_limit = query.scan_limit.unwrap_or(10_000).clamp(1_000, 1_000_000); @@ -237,20 +237,20 @@ async fn get_db_health_jobs( } async fn get_slow_queries( - ApiAuthed { email, .. }: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Query(query): Query, ) -> JsonResult> { - require_super_admin(&db, &email).await?; + require_super_admin(&db, &authed).await?; let sort = query.sort.unwrap_or(SlowQuerySort::Total); Ok(Json(fetch_slow_queries(&db, sort).await?)) } async fn reset_slow_queries( - ApiAuthed { email, .. }: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, ) -> windmill_common::error::Result { - require_super_admin(&db, &email).await?; + require_super_admin(&db, &authed).await?; sqlx::query("SELECT pg_stat_statements_reset()") .execute(&db) .await diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 584c44bbec..513341b6c1 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -26,8 +26,6 @@ use tokio::io::AsyncReadExt; use tower::ServiceBuilder; use url::Url; use windmill_common::assets::AssetUsageAccessType; -#[cfg(all(feature = "enterprise", feature = "instance_smtp"))] -use windmill_common::auth::is_super_admin_email; use windmill_common::auth::TOKEN_PREFIX_LEN; #[cfg(feature = "run_inline")] use windmill_common::client::AuthedClient; @@ -2621,7 +2619,7 @@ async fn send_email_with_instance_smtp( let is_handler_job = authed.email == EMAIL_ERROR_HANDLER_USER_EMAIL || authed.email == SCHEDULE_ERROR_HANDLER_USER_EMAIL; - if !is_handler_job && !is_super_admin_email(&db, &authed.email).await? { + if !is_handler_job && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::NotAuthorized( "Only super admin or whitelisted token can access email workspace error handler feature" .to_string(), @@ -8904,7 +8902,7 @@ async fn add_batch_jobs( Path((w_id, n)): Path<(String, i32)>, Json(batch_info): Json, ) -> error::JsonResult> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let ( hash, @@ -10880,11 +10878,11 @@ struct TagCount { } async fn count_by_tag( - ApiAuthed { email, .. }: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Query(query): Query, ) -> JsonResult> { - require_super_admin(&db, &email).await?; + require_super_admin(&db, &authed).await?; let horizon = query.horizon_secs.unwrap_or(3600); // Default to 1 hour if not specified let counts = sqlx::query_as!( @@ -11618,6 +11616,7 @@ mod approval_view_gate_tests { is_session_token: false, token_prefix: None, read_only: false, + job_id: None, } } diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index d2e0ea8e78..9963219993 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -374,6 +374,7 @@ async fn inject_agent_authed( is_session_token: false, token_prefix: None, read_only: false, + job_id: None, }, job_id: None, }); diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs index c5142c3031..d7c8c35586 100644 --- a/backend/windmill-api/src/mcp/oauth_server.rs +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -18,6 +18,7 @@ use windmill_common::{ }; use crate::db::ApiAuthed; +use windmill_api_auth::forbid_elevated_job_token; use windmill_mcp::parse_mcp_scopes; /// Token expiration for MCP OAuth tokens (1 week in seconds) @@ -763,6 +764,13 @@ async fn oauth_approve_inner( workspace_id: &str, form: ApprovalForm, ) -> Result> { + // The code approved here is exchanged for a database token carrying only this + // email, so an elevated job token would launder its identity into a credential + // with no `job_id` — and the MCP gateway would then re-enter the API uncapped + // (GHSA-hfh4-cx4h-3fcr). Guarded at the shared inner fn: both the workspaced and + // the gateway approve route reach the exchange through here. + forbid_elevated_job_token(db, &authed.email, authed.job_id).await?; + // Verify user is a member of the workspace let is_member = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)", diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index 3af0498ff3..6dd2823959 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -634,11 +634,22 @@ pub async fn create_http_request( .map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?; let scopes = jwt_scopes_for_proxied_route(api_authed.scopes.as_deref(), method, parsed.path())?; - // Add authorization header + // Add authorization header. Carry the caller's job provenance into the proxy + // JWT: a job's WM_TOKEN is capped at workspace admin (GHSA-hfh4-cx4h-3fcr), and + // dropping `job_id` here would re-mint an uncapped token that satisfies + // require_super_admin / require_devops_role on the proxied route. let authed = Authed::from(api_authed.clone()); - let token = create_jwt_token(authed, workspace_id, 3600, None, None, None, scopes) - .await - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let token = create_jwt_token( + authed, + workspace_id, + 3600, + api_authed.job_id, + None, + None, + scopes, + ) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); // Add body if present @@ -1102,4 +1113,102 @@ mod tests { Some("((o.path = 'f/a_b' OR o.path LIKE 'f/a\\_b/%' ESCAPE '\\'))".to_string()) ); } + + fn test_api_authed(job_id: Option) -> ApiAuthed { + ApiAuthed { + email: "admin@windmill.dev".to_string(), + username: "admin".to_string(), + is_admin: true, + is_operator: false, + groups: vec![], + folders: vec![], + scopes: None, + username_override: None, + username_override_is_token_label: false, + is_session_token: false, + token_prefix: None, + read_only: false, + job_id, + } + } + + /// Capture the `Authorization` header of the single request `create_http_request` + /// proxies, decode the minted JWT, and return its `job_id` claim. + async fn proxied_jwt_job_id(caller: &ApiAuthed) -> Option { + use axum::{extract::State, routing::get, Router}; + use std::sync::{Arc, Mutex}; + use windmill_common::auth::JWTAuthClaims; + + // The internal JWT secret must be non-empty for encode/decode to round-trip. + windmill_common::jwt::JWT_SECRET.store(Arc::new("mytestsecret".to_string())); + + let captured: Arc>> = Arc::new(Mutex::new(None)); + let app = Router::new() + .route( + "/", + get( + |State(state): State>>>, + headers: axum::http::HeaderMap| async move { + if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) { + *state.lock().unwrap() = + Some(auth.to_str().unwrap_or_default().to_string()); + } + "ok" + }, + ), + ) + .with_state(captured.clone()); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let url = format!("http://{addr}/"); + create_http_request("GET", &url, "test-workspace", caller, None) + .await + .expect("proxied request should succeed"); + + server.abort(); + + let header = captured + .lock() + .unwrap() + .clone() + .expect("no auth header captured"); + let token = header.strip_prefix("Bearer ").unwrap().to_string(); + let jwt = token + .strip_prefix("jwt_") + .expect("expected an internal jwt_ token"); + let claims: JWTAuthClaims = windmill_common::jwt::decode_with_internal_secret(jwt) + .await + .unwrap(); + claims.job_id + } + + /// Regression for GHSA-hfh4-cx4h-3fcr: the MCP proxy must carry the caller's + /// job provenance into the JWT it mints, otherwise a job's WM_TOKEN — capped at + /// workspace admin — would be re-minted uncapped and pass require_super_admin / + /// require_devops_role on the proxied route (e.g. listWorkers). + #[tokio::test] + async fn create_http_request_preserves_job_id_provenance() { + let job_id = uuid::Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef); + assert_eq!( + proxied_jwt_job_id(&test_api_authed(Some(job_id))).await, + Some(job_id.to_string()), + "a job-token caller's job_id must be preserved in the proxied JWT" + ); + } + + /// The mirror invariant: a non-job caller must not gain a spurious job_id (which + /// would wrongly cap a legitimate interactive/superadmin MCP token). + #[tokio::test] + async fn create_http_request_keeps_non_job_caller_unstamped() { + assert_eq!( + proxied_jwt_job_id(&test_api_authed(None)).await, + None, + "a non-job caller must not be stamped with a job_id" + ); + } } diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index f7f5831d0f..0d9529c0ce 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -463,7 +463,7 @@ pub(crate) async fn global_offboard_preview( Extension(db): Extension, Path(email): Path, ) -> JsonResult { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let workspaces = sqlx::query!( "SELECT workspace_id, username FROM usr WHERE email = $1", @@ -492,7 +492,7 @@ pub(crate) async fn offboard_global_user( Path(email): Path, Json(req): Json, ) -> Result> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; let workspaces = sqlx::query!( diff --git a/backend/windmill-api/src/service_logs.rs b/backend/windmill-api/src/service_logs.rs index b1902f7c1a..99dc7c41a8 100644 --- a/backend/windmill-api/src/service_logs.rs +++ b/backend/windmill-api/src/service_logs.rs @@ -43,12 +43,12 @@ pub struct LogFile { pub json_fmt: bool, } async fn list_files( - ApiAuthed { email, .. }: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Query(pagination): Query, Query(lq): Query, ) -> JsonResult> { - require_devops_role(&db, &email).await?; + require_devops_role(&db, &authed).await?; let (per_page, offset) = windmill_common::utils::paginate(pagination); let mut sqlb = sql_builder::SqlBuilder::select_from("log_file") @@ -89,13 +89,13 @@ async fn list_files( } async fn get_log_file( - ApiAuthed { email, .. }: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Path(path): Path, ) -> windmill_common::error::Result { use windmill_common::tracing_init::TMP_WINDMILL_LOGS_SERVICE; - require_devops_role(&db, &email).await?; + require_devops_role(&db, &authed).await?; let path = path.to_path(); if path.contains("..") { return Err(Error::BadRequest("Invalid path".to_string())); diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 3ac80d6b00..37946f68a4 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -21,7 +21,9 @@ use axum::{ }; use hyper::StatusCode; use serde::Deserialize; -use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin}; +use windmill_api_auth::{ + forbid_elevated_job_token, forbid_superadmin_job_token, require_super_admin, +}; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::audit::AuditAuthor; @@ -115,7 +117,7 @@ async fn list_ext_jwt_tokens( Extension(db): Extension, Query(query): Query, ) -> Result>> { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; let (per_page, offset) = windmill_common::utils::paginate(windmill_common::utils::Pagination { page: query.page, @@ -146,7 +148,10 @@ async fn set_password( OptJobAuthed { job_id, .. }: OptJobAuthed, Json(ep): Json, ) -> Result { - forbid_superadmin_job_token(&db, &authed.email, job_id).await?; + // Choosing the password of the elevated account this job runs as is a credential + // mint by another name: logging in with it yields a session with no `job_id` + // (GHSA-hfh4-cx4h-3fcr). + forbid_elevated_job_token(&db, &authed.email, job_id).await?; let email = authed.email.clone(); crate::users_oss::set_password(db, argon2, authed, &email, ep).await } @@ -159,7 +164,7 @@ async fn set_password_of_user( OptJobAuthed { job_id, .. }: OptJobAuthed, Json(ep): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; crate::users_oss::set_password(db, argon2, authed, &email, ep).await } @@ -176,7 +181,7 @@ async fn rename_user( Extension(db): Extension, Json(ru): Json, ) -> Result { - require_super_admin(&db, &authed.email).await?; + require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; let mut tx = db.begin().await?; diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 1e26a20e05..23ce68fb80 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -214,7 +214,7 @@ pub async fn get_critical_alerts( authed: ApiAuthed, Query(params): Query, ) -> JsonResult { - require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, &db).await?; + require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?; crate::utils::get_critical_alerts(db, params, Some(w_id)).await } @@ -230,7 +230,7 @@ pub async fn acknowledge_critical_alert( Path((w_id, id)): Path<(String, i32)>, authed: ApiAuthed, ) -> Result { - require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, &db).await?; + require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?; crate::utils::acknowledge_critical_alert(db, Some(w_id), id).await } diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 8dfc3fc324..09887455e6 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -325,6 +325,49 @@ pub async fn is_super_admin_email<'c>(db: impl sqlx::PgExecutor<'c>, email: &str Ok(is_admin) } +/// The three reserved internal identities that grant instance-superadmin at +/// execution: `superadmin_secret@` / `superadmin_notification@` (matched on the +/// email) and `superadmin_sync@` (matched on `permissioned_as`). They belong to +/// no real user, so a stored `on_behalf_of` (app policy, flow/script, +/// schedule, trigger) must never carry one as either field — it would be a +/// forged superadmin run identity. Mirror of the `is_super_admin` derivation in +/// [`fetch_authed_from_permissioned_as_inner`]. +pub fn is_reserved_on_behalf_of_identity( + permissioned_as: Option<&str>, + on_behalf_of_email: Option<&str>, +) -> bool { + const RESERVED: [&str; 3] = [ + SUPERADMIN_SECRET_EMAIL, + SUPERADMIN_NOTIFICATION_EMAIL, + SUPERADMIN_SYNC_EMAIL, + ]; + [permissioned_as, on_behalf_of_email] + .into_iter() + .flatten() + .any(|v| RESERVED.contains(&v)) +} + +/// Guard a caller-supplied `on_behalf_of` before it is persisted on a deployable +/// object (app policy, flow/script, schedule, trigger): reject the reserved +/// internal sentinels, which no legitimate deploy ever carries. The actual +/// escalation is closed at execution by the job-token cap in +/// [`require_super_admin`] — even a superadmin `on_behalf_of` yields a token +/// capped at workspace admin — so this is a cheap, non-breaking early guard, not +/// the primary defense. It deliberately does *not* restrict deploying on behalf +/// of a real user (including a real superadmin, e.g. git-sync of +/// superadmin-authored content), which is the intended `wm_deployers` capability. +pub fn validate_on_behalf_of( + permissioned_as: Option<&str>, + on_behalf_of_email: Option<&str>, +) -> Result<()> { + if is_reserved_on_behalf_of_identity(permissioned_as, on_behalf_of_email) { + return Err(Error::BadRequest( + "on_behalf_of cannot be a reserved internal identity".to_string(), + )); + } + Ok(()) +} + pub async fn is_devops_email(db: &DB, email: &str) -> Result { if is_super_admin_email(db, email).await? { return Ok(true); @@ -727,8 +770,11 @@ pub mod aws { #[cfg(test)] mod tests { - use super::is_user_token; + use super::{is_reserved_on_behalf_of_identity, is_user_token}; use super::{job_token_remaining_lifetime_secs, JWTAuthClaims, JOB_TOKEN_REFRESH_MARGIN_SECS}; + use crate::users::{ + SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SYNC_EMAIL, + }; fn job_jwt(exp_offset_secs: i64) -> String { let claims = JWTAuthClaims { @@ -769,6 +815,36 @@ mod tests { assert!(job_token_remaining_lifetime_secs("").is_none()); } + #[test] + fn reserved_on_behalf_of_identity_matches_every_sentinel_in_either_field() { + // Matched on the email (secret / notification) or on permissioned_as (sync). + assert!(is_reserved_on_behalf_of_identity( + None, + Some(SUPERADMIN_SECRET_EMAIL) + )); + assert!(is_reserved_on_behalf_of_identity( + None, + Some(SUPERADMIN_NOTIFICATION_EMAIL) + )); + assert!(is_reserved_on_behalf_of_identity( + Some(SUPERADMIN_SYNC_EMAIL), + None + )); + // A sentinel smuggled as a raw-email permissioned_as (schedules/triggers + // derive the email from it) is caught too. + assert!(is_reserved_on_behalf_of_identity( + Some(SUPERADMIN_SECRET_EMAIL), + None + )); + // Ordinary identities pass. + assert!(!is_reserved_on_behalf_of_identity(None, None)); + assert!(!is_reserved_on_behalf_of_identity( + Some("u/alice"), + Some("alice@example.com") + )); + assert!(!is_reserved_on_behalf_of_identity(Some("g/team"), None)); + } + #[test] fn user_tokens_are_editable() { assert!(is_user_token(None)); // no label diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 3546381885..5541615838 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -10,7 +10,6 @@ use tokio::io::AsyncReadExt; pub use windmill_types::jobs::*; use crate::{ - auth::is_super_admin_email, client::AuthedClient, db::{AuthedRef, UserDbWithAuthed, DB}, error::{self, to_anyhow, Error}, @@ -335,11 +334,14 @@ lazy_static::lazy_static! { ).unwrap_or(false); } +// `is_super_admin` is passed in (not derived from an email here) so callers can +// make it job-token-aware: a job's WM_TOKEN must never count as superadmin +// (GHSA-hfh4-cx4h-3fcr). See `is_super_admin_authed` at the request wrapper. pub async fn check_tag_available_for_workspace_internal( db: &DB, w_id: &str, tag: &str, - email: &str, + is_super_admin: bool, scope_tags: Option>, ) -> error::Result<()> { let mut is_tag_in_scope_tags = None; @@ -372,7 +374,7 @@ pub async fn check_tag_available_for_workspace_internal( _ => {} } - if !is_super_admin_email(db, email).await? { + if !is_super_admin { if scope_tags.is_some() && is_tag_in_scope_tags.is_some() { return Err(Error::BadRequest(format!( "Tag {tag} is not available in your scope" diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index b72d0ae1d9..7129ae0e48 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -29,10 +29,9 @@ use sqlx::{Acquire, Postgres}; pub mod agent_workers; pub mod apps; pub mod assets; -pub mod azure_workload_identity; -pub mod dbt_manifest; pub mod audit; pub mod auth; +pub mod azure_workload_identity; #[cfg(feature = "benchmark")] pub mod bench; pub mod cache; @@ -44,6 +43,7 @@ mod db_entra_ee; #[cfg(all(feature = "enterprise", feature = "private"))] mod db_iam_ee; pub mod db_params; +pub mod dbt_manifest; pub mod deploy_origin; #[cfg(feature = "private")] pub mod deployment_requests_ee; @@ -236,6 +236,10 @@ pub async fn resolve_on_behalf_of( if !(preserve && can_preserve_on_behalf_of(authed)) { return reject_unenqueueable(users::username_to_permissioned_as(authed.username())); } + // Reserved superadmin sentinels are rejected by name, before resolution: the lookups + // below only reject them while no account holds their address, and the runtime grants + // superadmin on these emails by string comparison alone. + auth::validate_on_behalf_of(on_behalf_of, on_behalf_of_email)?; let permissioned_as = match on_behalf_of { Some(permissioned_as) => { // The principal wins, but a caller that also names a contradictory address has a @@ -1760,7 +1764,10 @@ pub async fn on_behalf_of_from_permissioned_as( // processes, so a cached read would keep minting jobs under an address the account no longer // holds for up to a minute after it moves. let email = users::get_email_from_permissioned_as_uncached(permissioned_as, w_id, db).await?; - Ok(Some(jobs::OnBehalfOf { email, permissioned_as: permissioned_as.to_string() })) + Ok(Some(jobs::OnBehalfOf { + email, + permissioned_as: permissioned_as.to_string(), + })) } impl ScriptHashInfo { diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index db0de177db..f2ae31bcc4 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -330,10 +330,16 @@ pub async fn require_admin_or_devops( is_admin: bool, username: &str, email: &str, + // True when the caller is a job token (`$WM_TOKEN`). `devops` is instance-level + // and `is_devops_email` is true for superadmins, so a job token whose on_behalf_of + // a `wm_deployers` member pointed at a superadmin would otherwise clear the devops + // branch on a workspace it isn't admin of (GHSA-hfh4-cx4h-3fcr). Workspace admin + // (`is_admin`) stays allowed — that is the cap ceiling. + is_job_token: bool, db: &DB, ) -> Result<()> { if !is_admin { - if !is_devops_email(db, email).await? { + if is_job_token || !is_devops_email(db, email).await? { return Err(Error::RequireAdmin(username.to_string())); } } diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 10f4a2b408..fa495c9cd2 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -513,11 +513,12 @@ pub async fn push_scheduled_job<'c>( }; if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) { + let is_super_admin = windmill_common::auth::is_super_admin_email(db, &email).await?; check_tag_available_for_workspace_internal( db, &schedule.workspace_id, &tag, - &email, + is_super_admin, None, // no token for schedules so no scopes so no scope_tags ) .warn_after_seconds_with_sql(1, "check_tag_available_for_workspace_internal".to_string()) diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 421d147bdf..0f209b0381 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -13,7 +13,7 @@ use std::sync::LazyLock; use windmill_api_auth::{ build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, - require_super_admin, ApiAuthed, Tokened, + require_super_admin_email, ApiAuthed, Tokened, }; use windmill_common::db::DB; use windmill_common::per_minute_counter::PerMinuteCounter; @@ -724,7 +724,16 @@ pub async fn get_resource_value_interpolated_internal<'a>( ) -> Result> { // This is a special syntax to help debugging custom instance databases if let Some(dbname) = path.strip_prefix("CUSTOM_INSTANCE_DB/") { - require_super_admin(db_with_opt_authed.db(), &db_with_opt_authed.email()).await?; + // A job's WM_TOKEN must never reach this superadmin-only path even if it + // runs on behalf of a superadmin (GHSA-hfh4-cx4h-3fcr). Read the job + // provenance from the *authenticated* identity, never the caller-supplied + // `job_id` param (which comes from an untrusted query string). + if db_with_opt_authed.authed().and_then(|a| a.job_id).is_some() { + return Err(Error::NotAuthorized( + "CUSTOM_INSTANCE_DB cannot be resolved from a job token ($WM_TOKEN)".to_string(), + )); + } + require_super_admin_email(db_with_opt_authed.db(), &db_with_opt_authed.email()).await?; let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; pg_creds.dbname = dbname.to_string(); let pg_creds = serde_json::to_value(&pg_creds) diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 00d8cbf828..c68fa7fce6 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -572,6 +572,14 @@ async fn create_trigger( } } + // Reject a forged superadmin run identity in a preserved permissioned_as + // (the sentinel guard; a trigger's email is derived from it at execution). + let resolved_permissioned_as = new_trigger.base.resolve_permissioned_as(&authed); + windmill_common::auth::validate_on_behalf_of( + Some(&resolved_permissioned_as), + None, + )?; + let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( new_trigger.base.permissioned_as.as_deref(), new_trigger.base.preserve_permissioned_as.unwrap_or(false), @@ -825,6 +833,15 @@ async fn update_trigger( let new_path = edit_trigger.base.path.to_string(); let labels = edit_trigger.base.labels.clone(); + + // Reject a forged superadmin run identity in a preserved permissioned_as + // (the sentinel guard; a trigger's email is derived from it at execution). + let resolved_permissioned_as = edit_trigger.base.resolve_permissioned_as(&authed); + windmill_common::auth::validate_on_behalf_of( + Some(&resolved_permissioned_as), + None, + )?; + let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( edit_trigger.base.permissioned_as.as_deref(), edit_trigger.base.preserve_permissioned_as.unwrap_or(false), diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 42246c6358..bd39afa8c7 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -4421,11 +4421,13 @@ async fn push_next_flow_job( .as_deref() .filter(|t| !t.is_empty() && *t != flow_job.tag.as_str()) { + let is_super_admin = + windmill_common::auth::is_super_admin_email(db, email).await?; check_tag_available_for_workspace_internal( db, &flow_job.workspace_id, tag_str, - email, + is_super_admin, None, // no token for flow substeps so no scopes so no scope_tags ) .warn_after_seconds_with_sql( From f6645af77e09df669f28a3e4a6c13ae630ebf85e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 19 Aug 2026 22:37:06 +0200 Subject: [PATCH 5/5] fix: explain the 6-field cron format when a schedule is rejected (#10768) * fix: explain the 6-field cron format when a schedule is rejected Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019mUJd8ZRXkzbhmHkZryYoE * fix: phrase the cron hint as a prepend, not an equivalent schedule Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019mUJd8ZRXkzbhmHkZryYoE * fix: withhold the cron example where v1 shifts the weekday Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019mUJd8ZRXkzbhmHkZryYoE * fix: withhold the cron example for any restricted weekday on v1 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019mUJd8ZRXkzbhmHkZryYoE --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-common/src/utils.rs | 132 +++++++++++++++++++++++++-- 1 file changed, 123 insertions(+), 9 deletions(-) diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index f2ae31bcc4..62d47a34d7 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -915,6 +915,66 @@ pub enum ScheduleType { Cron(cron::Schedule), } +/// croner reads the leading seconds field as optional or required depending on these flags, +/// so anything asking whether an expression parses has to ask it the way the caller did. +fn croner_parser(schedule_str: &str, seconds_required: bool) -> Cron { + let mut croner = Cron::new(schedule_str); + if seconds_required { + croner.with_seconds_required(); + } else { + croner.with_seconds_optional(); + } + croner +} + +/// Probes an expression this module synthesized rather than one that was submitted, so it +/// goes around `from_str`, whose failure path logs at ERROR level. +fn parses_as_cron(schedule_str: &str, version: Option<&str>, seconds_required: bool) -> bool { + match version { + Some("v1") | None => cron::Schedule::from_str(schedule_str).is_ok(), + Some(_) => panic::catch_unwind(AssertUnwindSafe(|| { + croner_parser(schedule_str, seconds_required) + .parse() + .is_ok() + })) + .unwrap_or(false), + } +} + +/// Both cron parsers reject the standard 5-field crontab syntax without naming the missing +/// leading seconds field, and croner even advertises five fields as valid while we parse +/// with seconds required. The hint belongs to that seconds-required parse alone: croner does +/// accept five fields once seconds are optional, which is how the worker re-reads a schedule. +fn six_fields_hint(schedule_str: &str, version: Option<&str>, seconds_required: bool) -> String { + let fields = schedule_str.split_whitespace().collect::>(); + if !seconds_required || fields.len() >= 6 { + return String::new(); + } + // A restricted weekday is where v1 parts ways with crontab: it numbers weekdays from + // Sunday=1, and it intersects day-of-month with day-of-week where crontab unions them. + // Once the weekday is unrestricted the remaining fields carry their crontab meaning, so + // that is the only case on v1 where a concrete expression can be handed back. + let v1_weekday_restricted = matches!(version, Some("v1") | None) + && fields.get(4).is_some_and(|dow| *dow != "*" && *dow != "?"); + let with_seconds = format!("0 {}", fields.join(" ")); + let example = if fields.len() == 5 + && !v1_weekday_restricted + && parses_as_cron(&with_seconds, version, seconds_required) + { + format!( + " The 5-field crontab syntax is not accepted; prepend a seconds field, e.g. '{}'.", + with_seconds + ) + } else { + String::new() + }; + format!( + "\nWindmill cron expressions have 6 fields and start with seconds: \ + 'sec min hour day-of-month month day-of-week'.{}", + example + ) +} + impl ScheduleType { pub fn find_next( &self, @@ -953,19 +1013,17 @@ impl ScheduleType { schedule_str, e ); - Error::BadRequest(format!("cron: {}", e)) + Error::BadRequest(format!( + "cron: {}{}", + e, + six_fields_hint(schedule_str, version, seconds_required) + )) }) } Some("v2") | Some(_) => { // Use Croner for v2 let schedule_type_result = panic::catch_unwind(AssertUnwindSafe(|| { - let mut croner = Cron::new(schedule_str); - if seconds_required { - croner.with_seconds_required(); - } else { - croner.with_seconds_optional(); - }; - croner.parse() + croner_parser(schedule_str, seconds_required).parse() })) .map_err(|_| { tracing::error!( @@ -981,7 +1039,11 @@ impl ScheduleType { schedule_str, e ); - Error::BadRequest(format!("cron: {}", e)) + Error::BadRequest(format!( + "cron: {}{}", + e, + six_fields_hint(schedule_str, version, seconds_required) + )) }) }); @@ -1563,6 +1625,58 @@ pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String { mod tests { use super::*; + /// A 5-field crontab line is the most common way to get a schedule rejected, and both + /// parsers report it in terms a crontab user cannot act on, so the seconds field and the + /// equivalent expression must reach the caller for v1 and v2 alike. + #[test] + fn five_field_cron_error_names_the_seconds_field() { + for version in [None, Some("v1"), Some("v2")] { + let err = ScheduleType::from_str("0 2 * * *", version, true) + .err() + .expect("5-field cron must be rejected") + .to_string(); + assert!(err.contains("6 fields"), "{version:?}: {err}"); + assert!( + err.contains("prepend a seconds field, e.g. '0 0 2 * * *'."), + "{version:?}: {err}" + ); + } + } + + /// On v1 a restricted weekday means something else than it does in the crontab line being + /// rewritten: `1` is Sunday there, and a weekday alongside a day-of-month intersects + /// instead of unions. Neither can be handed back as an expression to use; croner reads + /// both the crontab way, so the same inputs keep their example. + #[test] + fn restricted_weekday_example_is_withheld_on_v1_only() { + for schedule in ["0 2 * * 1", "0 2 1 * MON"] { + let v1 = ScheduleType::from_str(schedule, None, true) + .err() + .expect("5-field cron must be rejected") + .to_string(); + assert!(v1.contains("6 fields"), "{schedule}: {v1}"); + assert!(!v1.contains("e.g."), "{schedule}: {v1}"); + + let v2 = ScheduleType::from_str(schedule, Some("v2"), true) + .err() + .expect("5-field cron must be rejected") + .to_string(); + assert!( + v2.contains(&format!("prepend a seconds field, e.g. '0 {schedule}'.")), + "{schedule}: {v2}" + ); + } + } + + #[test] + fn cron_error_on_other_arities_is_left_alone() { + let err = ScheduleType::from_str("0 0 2 * * bogus", Some("v2"), true) + .err() + .expect("invalid cron must be rejected") + .to_string(); + assert!(!err.contains("6 fields"), "{err}"); + } + /// A worker that restarts must land on the exact same name to reclaim its `worker_ping` /// row, while still never colliding with the other workers of its own process. The /// suffix must also stay a single `-` segment, which is what the interactive shell tag