Missing config is a client/config error, not a server fault. Restores the
BadRequest class lost when hub_token() was extracted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(forks): defer trigger/schedule state to parent for clean git merge
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(forks): read parent trigger/schedule state on non-RLS pool for complete substitution
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(forks): read schedule fork-ness on non-RLS pool; clarify mutator-rule wording
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
slug/project_slug are caller-controlled and were interpolated straight into
the Hub request path; a crafted value (e.g. ../../admin) could reach an
unintended Hub endpoint after URL normalization. Validate against the
frontend charset (lowercase alphanumerics + hyphens, 3-50 chars) in the four
handlers that put the slug in the path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The workspace-switch effect only reset load-derived state, so phase,
draftItems, recordings, hub/bundle metadata, hubVersion, deploymentStatus,
effectiveSlug and hubItemIds survived a switch — a draft built in one
workspace could publish its items/slug under the next workspace's auth.
Reset the full publish session on switch. Also drop explanatory comments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hub publish endpoints ignored the {workspace} path and addressed the Hub
project purely by client-supplied project_slug, forwarding with an
instance-wide HUB_DEV_TOKEN. Any workspace admin could mutate or export
another workspace's Hub project by passing its slug.
Stamp the server-trusted workspace from the path onto every forwarded
request as source_id (body for mutations, query param for export) so the
Hub can enforce that the targeted project belongs to the calling
workspace. Requires the matching Hub-side source_id ownership check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an optional path field to the publish bodies and a GET
/projects/{slug}/export route that proxies the hub export (admin-only,
authenticated with HUB_DEV_TOKEN).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add POST /w/{workspace}/hub/raw_apps/{id}/embed forwarding to the Hub so a
shared (public) raw app's external_embed_url can be set/cleared. null is
forwarded (not skipped) so unpublish clears the embed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Matches the windmillhub rename: every body now carries `project_slug`
instead of `workspace_slug`, the draft creation forwards to `/projects`,
and the resource_types/resources/triggers proxies hit
`/projects/{slug}/...`. `HubWorkspaceBody` becomes `HubProjectBody`. The
instance-side `Path(workspace)` extractor and the `workspace` URL
parameter stay because that's still the source tenant's identifier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the existing /scripts, /flows, /apps thin proxies. Forwards
{ triggers, workspace_slug } to Hub's POST /workspaces/[slug]/triggers
bulk-replace endpoint, with the same require_admin + HUB_DEV_TOKEN
guardrails. Lets the frontend push trigger stubs in a single round-trip
after the items they reference have landed on the Hub.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add ApiAuthed + require_admin to all hub publish handlers; previously any
workspace-authenticated session could trigger Hub-side writes attributed
to the URL workspace via the shared HUB_DEV_TOKEN.
- Track per-kind trigger fetch failures (triggerLoadErrors) so an EE-gated
or transiently failing trigger service no longer silently maps to "0
triggers"; UI surfaces an amber badge listing the missing kinds and a
toast warns the operator before publish.
- Add workspaceLoadSeq cancellation so the parallel loadWorkspace +
loadTriggers stop bleeding stale data when the workspace switches mid
load.
- Drop the silent effectiveSlug fallback to sanitizeSlug(hubName) when
the Hub response can't be parsed; abort the publish instead so items
don't land under a slug the Hub never locked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Forward workspace resource stubs (path + type) to the hub's
/workspaces/{slug}/resources endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New workspaced router /api/w/:ws/hub/* forwarding to the Hub:
- POST /publish_draft → POST {HUB}/workspaces (slug/name/summary/readme)
- POST /scripts → POST {HUB}/scripts/add (workspace_slug + content)
- POST /flows | /apps | /raw_apps → corresponding hub endpoints
- POST /scripts/:ask_id/recording, /flows/:flow_id/recording → recording uploads
Auth uses HUB_DEV_TOKEN env var (dev shortcut). All bodies are
serde-typed; the helper forward_to_hub centralises the HTTP call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(jobs): enforce anonymous-only guard on `only_result` job updates
The `jobs_u/getupdate/{id}` and `jobs_u/getupdate_sse/{id}` endpoints
accept `only_result=true`. In that branch, `get_job_update_data` queried
the result solely by (workspace_id, job_id) and skipped the
`created_by == "anonymous"` check that the non-only_result path and
adjacent unauthenticated endpoints apply. An unauthenticated requester
who learned a private job UUID could therefore retrieve that job's
output.
Hoist the guard to the top of `get_job_update_data` so both branches are
covered.
Fixes WIN-1980
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: fold `created_by` check into existing only_result queries
Avoids the extra `SELECT created_by` round-trip per call by joining
`v2_job` once in the two queries that handled the unauth path and
checking inline. Behavior is identical to the prior commit; the SSE
polling loop now does one query per poll instead of two for
unauthenticated callers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: cache anonymous_verified across SSE polls
Replace the LEFT JOIN approach with an upfront `SELECT created_by`
guarded by a new `&mut bool anonymous_verified` parameter that mirrors
`early_return_suppressed`. The SSE polling loop now performs the auth
check exactly once per stream rather than per poll, and the data SQL
reverts to its original form so authenticated callers pay no extra
cost. `created_by` cannot change after job creation, so caching the
verification across polls is safe.
Cost matrix:
- Authed (any path): 0 extra queries
- Unauthed one-shot: 1 extra query (unavoidable)
- Unauthed SSE: 1 extra query at stream start, 0 per poll
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: scope anonymous check to only_result branch
The non-only_result branch already enforces the `created_by` check via
its main query, so a top-level hoisted check duplicated work for
unauthenticated default-path callers. Move the check inside the
`if only_result.unwrap_or(false)` block — exactly where the bypass
lives — and leave the non-only_result path untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path
The AI proxy handler accepts an X-Resource-Path header to override the
configured workspace AI provider. When supplied, the handler loaded the
resource value from the resource table using the root DB pool with no
resources:read scope check, so any authenticated workspace user could
point X-Resource-Path at a restricted AI resource (e.g. one in a folder
they cannot read) and the proxy would use that resource's provider
credentials for the outbound AI request.
For user-supplied resource paths, now require resources:read:{path}
scope and fetch the resource through user_db.begin(&authed) so RLS
enforces the same folder/group boundary as the resource API. The RLS-
scoped $var: resolution stays in place as defense in depth. The
admin-configured workspace/instance ai_config path is unchanged.
Fixes WIN-1971
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ai): regression test for X-Resource-Path RLS enforcement
Cover all four cases:
- non-admin pointing X-Resource-Path at a restricted resource is rejected
- non-admin pointing it at a resource they own still works
- admin can point it at any resource
- workspace-configured proxy flow (no X-Resource-Path) is unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: authenticate slack callback payload with per-workspace hmac
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: regression tests for unauthenticated slack callback decryption
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: verify slack submission signature before resume + close workspace oracle
Addresses review: verify private_metadata HMAC before handle_resume_action so a
tampered/unsigned submission is rejected up front, and map get_workspace_key
failure to the generic 401 so the status code is not a workspace-existence oracle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: domain-separate slack payload hmac from resume-secret hmac
Both MAC families key Hmac<Sha256> on the same per-workspace key; resume secrets
are distributed to approvers in resume URLs, so add a fixed domain tag
(slack_payload_v1) to the slack payload MAC to make the two non-interchangeable
by construction rather than by byte-layout coincidence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: prevent token label collision bypassing job read access control
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: regression tests for token label collision job read access
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: bind job-read override fast-path to permissioned_as_email
Replaces the reserved-label / label-* exclusion approach: webhook-/http-/email-
labels are created through the public token API by the trigger panels, so they
cannot be reserved, and blocking label-* regressed legitimate re-reads. Instead
the username_override fast-path now requires the job's permissioned_as_email
(non-forgeable, never derived from the label) to equal the caller's email.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(otel): propagate inbound W3C traceparent to job spans
Capture the inbound traceparent header at the run endpoints
(WebhookArgs::to_args_from_format) into a reserved _wm_traceparent arg key
(gated on OTEL_TRACING_ENABLED), riding the args jsonb like
_ENTRYPOINT_OVERRIDE. At pickup, create_span_with_name attaches a span link
from the job's worker span to the originating distributed trace, so a job
triggered by an instrumented service is connected to the caller's trace
while keeping its UUID-derived trace id (trace-by-job-id unaffected).
The link/parse logic lives in the EE otel modules; this OSS side only
captures the header and calls the (no-op outside EE) hook. Companion EE PR
required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: bump ee-repo-ref to inbound-trace-propagation EE branch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agents): don't attribute work to specific customers in repo content
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(otel): relocate job + script spans into the inbound trace
Builds on the captured _wm_traceparent: the worker job span is re-parented on
the inbound caller context, the script subprocess's TRACEPARENT env is the
inbound context (so its spans join the caller's trace), and the context is
propagated to flow steps so the whole flow relocates. Carried to the worker via
a new LogContext.inbound_traceparent field. Non-inbound jobs are unchanged.
Adds a relocation integration test. Companion EE PR required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: bump ee-repo-ref to inbound-trace-propagation relocate commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(otel): harden inbound traceparent capture
Address review feedback:
- strip any caller-supplied _wm_traceparent from args/extra before stashing the
header-captured value, so the reserved key is Windmill-controlled only
- valid_w3c_traceparent: reject version ff and require lowercase hex, so we don't
forward an inbound header that downstream OTel parsers would reject
- clarify that the capture helper does not validate the W3C format (done at use)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to 2c7964460327fab5e3a27c0f74b8d6f26ab7f79a
This commit updates the EE repository reference after PR #604 was merged in windmill-ee-private.
Previous ee-repo-ref: 8fc04fb105dc49769205f7174d551a0d134d1bec
New ee-repo-ref: 2c7964460327fab5e3a27c0f74b8d6f26ab7f79a
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
* fix: distinguish canceled jobs in runs
* fix: order status=failure|canceled by completed_at to use partial index
The new `status` query param replaced the legacy `success=false` filter on
the Runs page, but the ORDER BY switch in list_completed_jobs_query only
flipped to v2_job_completed.completed_at for success==Some(false). With
status=failure|canceled (and success=None), the query fell back to ordering
by v2_job.created_at, which the partial index
ix_v2_job_completed_failure_workspace (workspace_id, completed_at DESC WHERE
status IN ('failure','canceled')) cannot serve.
EXPLAIN ANALYZE on 500k rows (1% failure/canceled): ordering by completed_at
uses the partial index (~150 buffers, 0.3ms); ordering by created_at scans
the v2_job created_at index and probes/discards 99% of rows via the join
(~49k buffers, 31ms). Switch the ordering to completed_at for
failure/canceled so the partial index serves both filtering and ordering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: trim order-by regression test to the failure/canceled case
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: only treat canceled as a terminal status icon for completed jobs
Guard the canceled branch in JobStatusIcon and getJobStatusKind with
`'success' in job` so a job that is still running while being canceled keeps
its running icon/favicon until it completes, instead of immediately showing
the gray Canceled state. Also clarify the openapi `status` param is an exact
match (status=success excludes skipped, unlike success=true).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp): resolve MCP resource token via caller RLS + SSRF-guard url
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp): clone user_db for oauth2 refresh and drop advisory ids from comments
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp): disable redirects on MCP client to prevent SSRF bypass
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(backend): authorize single-job read endpoints by job/flow visibility
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(jobs): share read links + cached access checks for run visibility
- Cache the job read-access RLS probe (size-bounded LRU keyed by the caller's
authz-relevant identity + job id; no TTL since job-side inputs are immutable).
- Inherit visibility along the full parent_job chain so any flow you can see lets
you read its (deeply nested) steps.
- Share read links: GET /jobs/job_view_token/{id} mints a stateless
HMAC(workspace_key, job_id) token (only if the caller can read the job); the
token grants an authenticated member read of that job and its flow subtree via a
?view_token query param or X-View-Token header. Run page gains a Share button and
honors a ?view_token link.
- Denied-but-existing reads now return 403 with guidance to request a share link
(vs 404 for non-existent), and the run page renders that case with instructions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(jobs): address PR review — scope-tag check on mint, constant-time view-token verify
- P1 (Codex): get_job_view_token now enforces the caller's if_jobs:filter_tags
scope before minting, so a tag-scoped token can't mint a transferable link for a
job outside its tags. Adds a scoped-token regression test (allowed + denied).
- Constant-time view-token verification (HmacSha256::verify_slice) instead of
comparing hex strings (Claude/Pi nit).
- get_completed_job_result: an authed reader passing an invalid suspended-secret
triple now falls through to the normal visibility gate instead of erroring out
(Claude nit); unauthenticated callers still rejected.
- Length-prefix the read-access cache key fields so no input values can collide.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(api): add job_view_token to openapi spec; use generated client in run page
Addresses Codex review nit: the new GET /jobs/job_view_token/{id} endpoint was
missing from openapi.yaml (the source the frontend client is generated from). Adds
the path + operationId getJobViewToken, and switches the run page's Share button
from a raw fetch to JobService.getJobViewToken.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(frontend): carry view_token on share-link downloads
Addresses Codex review: download actions bypass the request interceptor that adds
X-View-Token (downloadViaClient uses raw fetch; cookie-mode downloads use plain
hrefs), so a share-link viewer got 403 downloading logs/results/args. Append the
view_token query param to the job download paths (result/logs/args/flow-all-logs)
via a new appendViewToken() helper, covering both client-fetch and href modes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(jobs): enforce tag scope in require_job_read_access (view-token use side)
Addresses Codex P1: the view_token use-side bypassed if_jobs:filter_tags on
handlers that don't tag-filter their data query (result_by_id,
get_flow_job_debug_info, get_otel_traces) — a tag-scoped token could use someone
else's valid share token to read out-of-scope job data. Move the tag-scope check
into require_job_read_access (runs before any created_by/view_token/RLS grant), so
it applies uniformly to every gated handler; removes the now-redundant explicit
check in get_job_view_token. Adds a use-side regression test (scoped token + valid
out-of-scope view_token denied on otel/result_by_id; in-scope still allowed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(frontend): include workspace in share read link
Addresses Codex P1: the copied share URL omitted the workspace. The token is
signed with the run's workspace key and the logged layout only switches
$workspaceStore when the URL carries workspace=, so a recipient whose persisted
active workspace differs would open the link against the wrong workspace and the
token would fail validation. Pin workspace= alongside view_token in the link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(jobs): authorize get_result_maybe get_started branch for queued jobs
Addresses Codex P1: get_completed_job_result_maybe only gated when a completed row
existed; with ?get_started=true a non-reader reached the fallback branch and got
started:true for a running private job. Now fetches created_by and authorizes
(created_by/view_token/RLS, or anonymous for unauth) before disclosing
running-state; a non-existent job still returns started:false (leaks nothing).
Adds a regression test with a queued (no completed row) private job.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The instance username-conflict resolver rewrote
workspace_runnable_dependencies.app_path to the new user path before the
app row itself was renamed, violating fk_workspace_runnable_dependencies_app_path.
That FK is ON UPDATE CASCADE, so renaming the app already propagates the new
path; the manual rewrite was redundant and mis-ordered. Any user owning an app
under u/<username>/ with a tracked runnable dependency hit HTTP 500 and could
not have their username conflict resolved.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(apps): make public apps opt into cross-origin isolation via wm_coep
Public app pages served at /public/* and custom paths /a/* were not
getting the COEP/COOP/CORP headers, so they were blocked when embedded
as an iframe inside a cross-origin-isolated page (e.g. another raw app,
which sets Cross-Origin-Embedder-Policy: require-corp). A nested
document loaded into a require-corp context must itself set COEP for
the iframe to load.
Rather than applying the isolation headers to all public pages (which
would also force COEP on classic apps and break subresources without
CORP, e.g. external image URLs or embeds), public apps now opt in via
a `wm_coep` query param on the embed URL:
<iframe src="https://<domain>/public/<ws>/<secret>?wm_coep=on">
The app publish drawer gains a URL/Embed toggle: "URL" shows the plain
shareable link (param-free), "Embed" shows a ready-to-copy iframe
snippet with wm_coep baked in, so the flag is discoverable exactly when
embedding and absent otherwise.
`wm_coep` is consumed internally and stripped from the app `query`
context so it doesn't collide with app-defined params. Only params we
own are stripped (an explicit set), not the whole `wm_` prefix.
Fixes GIT-884
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* nit
* nit
* fix(apps): only bake wm_coep into embed snippet for raw apps
AppEditorHeaderDeploy is shared by the classic (AppEditorHeader) and raw
(RawAppEditorHeader) deploy drawers. The embed snippet unconditionally
appended ?wm_coep=on, which for a classic/low-code app forces COEP
require-corp on the document and breaks no-CORP cross-origin subresources
(external <img> in AppImage/AppStatCard/AppNavbar, {@html} embeds in
AppHtml, CDN import() in AppCustomComponent) — the exact regression the
opt-in design avoids.
Add a `rawApp` prop (default false); the raw header passes rawApp. The
flag is appended only for raw apps; classic apps get a plain iframe
snippet, and the wm_coep helper text is shown only for raw apps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
CLI deploys (sync push, set-permissioned-as) went through the same
create/update endpoints as a UI "deploy from draft", which delete the
draft at that path. That silently wiped teammates' in-progress drafts on
every push. Add a transient skip_draft_deletion deploy flag (mirroring
deployment_message) that the CLI sets; the backend then skips the
DELETE FROM draft for scripts, flows, and apps. UI deploys are unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_token_using_oauth resolved the AI OAuth resource's token_url and
POSTed to it without any SSRF validation, while base_url is validated in
get_base_url. A workspace member with resources:write could point
token_url at an internal/metadata address (e.g. 169.254.169.254),
turning the server into an authenticated blind SSRF probe.
Validate the resolved token_url with validate_url_for_ssrf before the
request, gated behind the same ALLOW_PRIVATE_AI_BASE_URLS opt-in as
base_url so private AI deployments keep working consistently for both
URL fields. ALLOW_PRIVATE_AI_BASE_URLS is now pub so windmill-api can
reuse it instead of re-parsing the env var.
* fix(ai): disable redirect following on AI proxy client to close SSRF
The AI proxy validates the configured base_url against SSRF rules but the
shared HTTP client followed up to 10 redirects without revalidating the
hops, so a public base_url could 3xx the server into a private/internal
address (e.g. the Docker socket or cloud metadata). Disable redirect
following so the validated host is the only one the server connects to.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ai): remove heavy redirect SSRF integration test
Drop the integration-test-level regression for redirect following; it
spins up a full API server + DB for a one-line client-config change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): don't follow symlinks when reading service log files
Defense in depth on top of the existing `..` path-traversal check in
the get_log_file handler: reject the request if the final path
component is a symlink, so a planted symlink in the logs directory
cannot be used to read arbitrary files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): authorize and harden the jobs_u get_log_file endpoint
The unauthenticated jobs_u get_log_file endpoint served any job's log
file to anyone who knew the job UUID, with no authorization. Gate it the
same way as get_job_logs: look up the job (the log directory name is the
job id) filtered by workspace and the caller's scope tags, and only allow
non-logged-in callers to read logs of jobs created by the anonymous user.
Also add defense in depth: refuse to read through a symlink so a planted
symlink in the logs directory cannot be used to exfiltrate arbitrary files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`announce_server_started` writes a `server_heartbeat:{INSTANCE_NAME}` row
on each startup. INSTANCE_NAME is a fresh random string per process, so
the row is never updated again and a new row is inserted on every
restart, growing background_task_state unboundedly.
Add an hourly monitor task that deletes server_heartbeat:* rows older
than 7 days. Older rows cannot influence check_any_server_started (which
only considers heartbeats refreshed after the restart was initiated), so
they are safe to prune.
Fixes WIN-1990.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(jobs): enforce anonymous-only guard on `only_result` job updates
The `jobs_u/getupdate/{id}` and `jobs_u/getupdate_sse/{id}` endpoints
accept `only_result=true`. In that branch, `get_job_update_data` queried
the result solely by (workspace_id, job_id) and skipped the
`created_by == "anonymous"` check that the non-only_result path and
adjacent unauthenticated endpoints apply. An unauthenticated requester
who learned a private job UUID could therefore retrieve that job's
output.
Hoist the guard to the top of `get_job_update_data` so both branches are
covered.
Fixes WIN-1980
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: fold `created_by` check into existing only_result queries
Avoids the extra `SELECT created_by` round-trip per call by joining
`v2_job` once in the two queries that handled the unauth path and
checking inline. Behavior is identical to the prior commit; the SSE
polling loop now does one query per poll instead of two for
unauthenticated callers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: cache anonymous_verified across SSE polls
Replace the LEFT JOIN approach with an upfront `SELECT created_by`
guarded by a new `&mut bool anonymous_verified` parameter that mirrors
`early_return_suppressed`. The SSE polling loop now performs the auth
check exactly once per stream rather than per poll, and the data SQL
reverts to its original form so authenticated callers pay no extra
cost. `created_by` cannot change after job creation, so caching the
verification across polls is safe.
Cost matrix:
- Authed (any path): 0 extra queries
- Unauthed one-shot: 1 extra query (unavoidable)
- Unauthed SSE: 1 extra query at stream start, 0 per poll
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: scope anonymous check to only_result branch
The non-only_result branch already enforces the `created_by` check via
its main query, so a top-level hoisted check duplicated work for
unauthenticated default-path callers. Move the check inside the
`if only_result.unwrap_or(false)` block — exactly where the bypass
lives — and leave the non-only_result path untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path
The AI proxy handler accepts an X-Resource-Path header to override the
configured workspace AI provider. When supplied, the handler loaded the
resource value from the resource table using the root DB pool with no
resources:read scope check, so any authenticated workspace user could
point X-Resource-Path at a restricted AI resource (e.g. one in a folder
they cannot read) and the proxy would use that resource's provider
credentials for the outbound AI request.
For user-supplied resource paths, now require resources:read:{path}
scope and fetch the resource through user_db.begin(&authed) so RLS
enforces the same folder/group boundary as the resource API. The RLS-
scoped $var: resolution stays in place as defense in depth. The
admin-configured workspace/instance ai_config path is unchanged.
Fixes WIN-1971
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ai): regression test for X-Resource-Path RLS enforcement
Cover all four cases:
- non-admin pointing X-Resource-Path at a restricted resource is rejected
- non-admin pointing it at a resource they own still works
- admin can point it at any resource
- workspace-configured proxy flow (no X-Resource-Path) is unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>