fix(backend): authorize single-job read endpoints by job/flow visibility (#9416)

* 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>
This commit is contained in:
Ruben Fiszel
2026-06-03 00:10:16 +02:00
committed by GitHub
parent fefa8e438d
commit 89a7a37776
16 changed files with 1604 additions and 52 deletions
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT id AS \"id!\" FROM chain",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": [
null
]
},
"hash": "ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50"
}
+192
View File
@@ -0,0 +1,192 @@
-- Fixture for the single-job read authorization regression test
-- (see tests/jobs_read_auth.rs).
--
-- Users available from `base`:
-- test-user (admin, token SECRET_TOKEN)
-- test-user-2 (User, token SECRET_TOKEN_2) -- owner of the secret script
-- test-user-3 (User, token SECRET_TOKEN_3) -- the unprivileged "viewer"
--
-- test-user-3 is NOT a member of any folder/group granting access to
-- `u/test-user-2/...`, so under the same RLS as `jobs/list` they cannot see any
-- of these jobs unless they created them.
-- A tag-scoped token for test-user-2 (who can read both VICTIM (tag 'deno') and
-- the flow (tag 'flow')). The `if_jobs:filter_tags:deno` modifier restricts it to
-- the 'deno' tag, so it must NOT be able to mint a share token for the 'flow' job.
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
encode(sha256('SCOPED_DENO_TOKEN'::bytea), 'hex'), 'SCOPED_DEN', 'SCOPED_DENO_TOKEN',
'test2@windmill.dev', 'scoped deno token', false,
ARRAY['jobs:read', 'if_jobs:filter_tags:deno']
);
-- RUNNING job: queued (no completed row) and owned by test-user-2. Used to check
-- that `completed/get_result_maybe?get_started=true` authorizes before disclosing
-- running-state to a non-reader.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'77777777-7777-7777-7777-777777777777', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/running_secret', 'deno', true
);
INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, tag) VALUES
('77777777-7777-7777-7777-777777777777', 'test-workspace', '2023-01-01 00:00:00', true, 'deno');
-- 1. VICTIM job: a completed run of test-user-2's private script, e.g. produced
-- by a public HTTP trigger. `created_by` is the route identity (test-user-2),
-- NOT the viewer; `permissioned_as`/`runnable_path` sit in test-user-2's
-- namespace; `visible_to_owner` is true. Its args + result carry secrets.
-- Pre-fix, test-user-3 could read all of these by UUID.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/secret_script', 'deno', true,
'{"secret": "LEAK_TEST_ARGS"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 1000,
'success'::job_status, '{"secret": "RESULT_SECRET"}'
);
INSERT INTO public.job_logs (job_id, workspace_id, logs) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 'secret logs LEAK_TEST_LOGS');
-- 2. APP-style job: run by the viewer (test-user-3) on behalf of an app whose
-- policy executes as test-user-2. `created_by` is the launching viewer, but
-- `permissioned_as`/`runnable_path` are the app owner's and
-- `visible_to_owner` is false (apps hide their component runs from the runs
-- list). This is the case that must KEEP working after the fix: the viewer
-- polls their own component result by UUID.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'test-workspace', 'test-user-3',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/app_component', 'deno', false,
'{"app_arg": "ok"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'test-workspace', 1000,
'success'::job_status, '{"app_result": "visible_to_launcher"}'
);
-- 3. ANONYMOUS job: a public-trigger run whose creator is `anonymous`. Reading
-- it without authentication must keep working (unchanged behavior).
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'cccccccc-cccc-cccc-cccc-cccccccccccc', 'test-workspace', 'anonymous',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/public_trigger', 'deno', true,
'{"public": "arg"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'cccccccc-cccc-cccc-cccc-cccccccccccc', 'test-workspace', 1000,
'success'::job_status, '{"public": "result"}'
);
-- 4. FLOW + STEP: test-user-3 has *read* access to folder `shared` (extra_perms),
-- so they can see flow `f/shared/flow1` (run by test-user-2) even though they
-- did not launch it. The flow's STEP job runs the inner script
-- `u/test-user-2/inner_secret` (test-user-3 has NO direct ACL on it) and is
-- not in their list. Visibility must be INHERITED from the flow root: being
-- able to see the flow means being able to inspect its steps (the flow-run UI
-- fetches each step by id). This guards against the fix over-blocking.
INSERT INTO public.folder (workspace_id, name, display_name, owners, extra_perms, created_by)
VALUES ('test-workspace', 'shared', 'Shared Folder', '{"u/test-user-2"}',
'{"u/test-user-3": false}', 'test-user-2');
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/shared/flow1', 'flow', true
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'test-workspace', 1000,
'success'::job_status, '{"flow": "done"}'
);
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner,
parent_job, root_job, flow_innermost_root_job, args
) VALUES (
'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/inner_secret', 'deno', true,
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'dddddddd-dddd-dddd-dddd-dddddddddddd',
'dddddddd-dddd-dddd-dddd-dddddddddddd', '{"step_arg": "x"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'test-workspace', 1000,
'success'::job_status, '{"step": "STEP_RESULT_INHERITED"}'
);
-- 5. DEEP NESTING / MIDDLE-LAYER VISIBILITY: top flow `f/secret/top` is NOT
-- visible to test-user-3; it has a sub-flow step `f/shared/mid` that IS visible
-- (folder `shared`); and that sub-flow has its own leaf step running
-- `u/test-user-2/deep_secret` (not visible). The leaf's `root_job` points at the
-- *outermost* top (not visible), so visibility must come from the *intermediate*
-- sub-flow the user can see — which requires walking the full parent chain, not
-- just [self, root].
INSERT INTO public.folder (workspace_id, name, display_name, owners, extra_perms, created_by)
VALUES ('test-workspace', 'secret', 'Secret Folder', '{"u/test-user-2"}', '{}', 'test-user-2');
-- top flow (not visible to test-user-3)
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'ffffffff-ffff-ffff-ffff-ffffffffffff', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/secret/top', 'flow', true
);
-- intermediate sub-flow (visible via folder `shared`), child of top
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner,
parent_job, root_job, flow_innermost_root_job
) VALUES (
'99999999-9999-9999-9999-999999999999', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/shared/mid', 'flow', true,
'ffffffff-ffff-ffff-ffff-ffffffffffff', 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'ffffffff-ffff-ffff-ffff-ffffffffffff'
);
-- leaf step of the sub-flow; runnable not visible, root_job = outermost top (not visible)
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner,
parent_job, root_job, flow_innermost_root_job
) VALUES (
'88888888-8888-8888-8888-888888888888', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/deep_secret', 'deno', true,
'99999999-9999-9999-9999-999999999999', 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'99999999-9999-9999-9999-999999999999'
);
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
('ffffffff-ffff-ffff-ffff-ffffffffffff', 'test-workspace', 1000, 'success'::job_status,
'{"top": "TOP_SECRET_RESULT"}'),
('99999999-9999-9999-9999-999999999999', 'test-workspace', 1000, 'success'::job_status,
'{"mid": "MID_RESULT"}'),
('88888888-8888-8888-8888-888888888888', 'test-workspace', 1000, 'success'::job_status,
'{"deep": "DEEP_STEP_INHERITED"}');
+512
View File
@@ -0,0 +1,512 @@
//! Regression test for the single-job read authorization bypass.
//!
//! The single-job read endpoints (`/jobs_u/get`, `/completed/get`,
//! `/completed/get_result`, `/get_args`, `/get_logs`, `/getupdate`, ...) fetch a
//! job through the root DB handle, filtered only by job id + workspace. That is
//! required for the unauthenticated approval / public-trigger / anonymous-job
//! flows, but for a *logged-in* user it meant any workspace member — including a
//! plain viewer with no ACL on the runnable — could read another user's job
//! args/result/logs simply by obtaining the job UUID, even though the same job is
//! hidden from them in `jobs/list` (RLS-filtered) and the underlying script
//! returns 404.
//!
//! The fix (`require_job_read_access`) gates the authenticated case: a caller may
//! read a job they created (covers app components / webhooks / their own runs)
//! or one visible to them under the same RLS as `jobs/list` (admins bypass);
//! otherwise 404. Unauthenticated access is unchanged (anonymous jobs only).
//!
//! This test pins down, against the `jobs_read_auth` fixture:
//! - a viewer is denied the victim job's full record / result / result_maybe /
//! args / logs / live update by UUID, and the secret never appears in the
//! body (the core fix; pre-fix these returned 200 with the secret),
//! - the job's owner and an admin can still read it (no over-blocking),
//! - the "app component" affordance survives: a viewer who *launched* a job
//! (created_by) running as someone else's identity can still read its result,
//! - unauthenticated behavior is unchanged: anonymous jobs readable, the
//! non-anonymous victim job rejected.
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const VICTIM: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const APP_JOB: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
const ANON_JOB: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc";
const FLOW_JOB: &str = "dddddddd-dddd-dddd-dddd-dddddddddddd";
const STEP_JOB: &str = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee";
// Deep nesting: top (not visible) -> mid (visible via folder) -> deep leaf.
const TOP_SECRET_FLOW: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff";
const DEEP_LEAF_JOB: &str = "88888888-8888-8888-8888-888888888888";
// A queued/running job (no completed row) owned by test-user-2.
const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";
// Secrets that must never leak to an unauthorized viewer.
const RESULT_SECRET: &str = "RESULT_SECRET";
const ARGS_SECRET: &str = "LEAK_TEST_ARGS";
const LOGS_SECRET: &str = "LEAK_TEST_LOGS";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
async fn get(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCode, String) {
let mut req = client().get(format!("{base}/{path}"));
if let Some(token) = token {
req = req.header("Authorization", format!("Bearer {token}"));
}
let resp = req.send().await.expect("request");
let status = resp.status();
let body = resp.text().await.expect("body");
(status, body)
}
#[sqlx::test(fixtures("base", "jobs_read_auth"))]
async fn test_single_job_read_authorization(db: Pool<Postgres>) -> 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/jobs_u");
// result_by_id / get_otel_traces live on the authed `/jobs` service, not `/jobs_u`.
let authed_base = format!("http://localhost:{port}/api/w/test-workspace/jobs");
// The endpoints that return the victim job's sensitive data by UUID.
let endpoints = [
("get", format!("get/{VICTIM}")),
("completed/get", format!("completed/get/{VICTIM}")),
(
"completed/get_result",
format!("completed/get_result/{VICTIM}"),
),
(
"completed/get_result_maybe",
format!("completed/get_result_maybe/{VICTIM}"),
),
("get_args", format!("get_args/{VICTIM}")),
("get_logs", format!("get_logs/{VICTIM}")),
(
"get_completed_logs_tail",
format!("get_completed_logs_tail/{VICTIM}"),
),
("get_flow_all_logs", format!("get_flow_all_logs/{VICTIM}")),
(
"completed/get_timing",
format!("completed/get_timing/{VICTIM}"),
),
("getupdate", format!("getupdate/{VICTIM}?only_result=true")),
];
// ---- CORE REGRESSION: the viewer (test-user-3) is denied on every endpoint
// and no secret ever appears in the body. Pre-fix these returned 200
// and leaked the secret.
for (name, path) in &endpoints {
let (status, body) = get(&base, path, Some("SECRET_TOKEN_3")).await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must get 403 on {name} (got {status}): {body}"
);
for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] {
assert!(
!body.contains(secret),
"viewer response for {name} leaked `{secret}`: {body}"
);
}
}
// The 403 for an existing-but-forbidden job carries actionable guidance
// (request a share link), distinguishing it from a plain not-found.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
assert!(
body.to_lowercase().contains("share"),
"403 body should guide the user to request a share link: {body}"
);
// A genuinely non-existent job is a 404, not a 403 — existence is only disclosed
// for jobs that actually exist in the workspace.
let missing = "00000000-0000-4000-8000-000000000000";
let (status, _) = get(
&base,
&format!("completed/get_result/{missing}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"a non-existent job must be 404, not 403 (got {status})"
);
// ---- NO OVER-BLOCKING: the job's owner (test-user-2) can read its result.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner must still read their own job result (got {status}): {body}"
);
assert!(
body.contains(RESULT_SECRET),
"owner result must contain the value: {body}"
);
// ---- ADMIN BYPASS: an admin (test-user) can read any job in the workspace.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}"),
Some("SECRET_TOKEN"),
)
.await;
assert!(
status.is_success(),
"admin must read any job (got {status}): {body}"
);
assert!(body.contains(RESULT_SECRET), "admin result body: {body}");
// ---- APP AFFORDANCE: a viewer who LAUNCHED a job (created_by = viewer) that
// runs as another identity (permissioned_as = test-user-2,
// visible_to_owner = false) can still read its result. This is the app
// component-polling path; the fix must not break it.
let (status, body) = get(
&base,
&format!("completed/get_result/{APP_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"launcher must read a job they created even without ACL on the runnable (got {status}): {body}"
);
assert!(
body.contains("visible_to_launcher"),
"launcher should get the result they polled: {body}"
);
// ---- AUTHED `/jobs` endpoints in the same class: result_by_id (flow node
// result) and get_otel_traces (job telemetry). The viewer must be denied
// the victim by UUID. The auth gate runs before result/trace resolution,
// so 404 here is the gate, not incidental resolution failure.
let (status, body) = get(
&authed_base,
&format!("result_by_id/{VICTIM}/somenode"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must get 403 on result_by_id (got {status}): {body}"
);
assert!(!body.contains(RESULT_SECRET), "result_by_id leaked: {body}");
let (status, body) = get(
&authed_base,
&format!("get_otel_traces/{VICTIM}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must get 403 on get_otel_traces (got {status}): {body}"
);
// ---- FLOW VISIBILITY INHERITANCE: test-user-3 has folder ACL on the flow
// `f/shared/flow1` (run by test-user-2) but did NOT launch it, and has no
// ACL on the step's inner runnable `u/test-user-2/inner_secret`. They must
// still be able to (a) read the flow they can see, and (b) inspect its
// step result — visibility is inherited from the flow root. A naive
// "same as list" gate would 404 the step and break the flow-run UI.
let (status, body) = get(
&base,
&format!("completed/get_result/{FLOW_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"viewer with folder ACL must read the flow they can see (got {status}): {body}"
);
let (status, body) = get(
&base,
&format!("completed/get_result/{STEP_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"viewer must inspect a step of a flow they can see, even without ACL on the step's runnable (got {status}): {body}"
);
assert!(
body.contains("STEP_RESULT_INHERITED"),
"step result should be returned via flow-root inheritance: {body}"
);
// ---- DEEP NESTING / MIDDLE-LAYER VISIBILITY: the deep leaf's root_job is the
// top flow (NOT visible to test-user-3), but an intermediate sub-flow
// (f/shared/mid) IS visible. Reading the leaf must succeed via that middle
// ancestor — i.e. the full parent chain is walked, not just [self, root].
let (status, body) = get(
&base,
&format!("completed/get_result/{DEEP_LEAF_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"deep leaf must be readable via a visible intermediate sub-flow (got {status}): {body}"
);
assert!(
body.contains("DEEP_STEP_INHERITED"),
"deep leaf result should be returned via mid-ancestor visibility: {body}"
);
// ...but the top flow itself, in a folder the viewer cannot read, stays denied.
let (status, body) = get(
&base,
&format!("completed/get_result/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"top flow in an unreadable folder must stay denied (got {status}): {body}"
);
// ---- UNAUTHENTICATED, unchanged: an anonymous-created job is readable
// without a token (public trigger / public app result polling).
let (status, body) = get(&base, &format!("completed/get_result/{ANON_JOB}"), None).await;
assert!(
status.is_success(),
"anonymous job must remain readable unauthenticated (got {status}): {body}"
);
// ---- UNAUTHENTICATED, unchanged: the non-anonymous victim job is rejected
// for an unauthenticated caller (400, the pre-existing guard).
let (status, body) = get(&base, &format!("completed/get_result/{VICTIM}"), None).await;
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"unauthenticated access to a non-anonymous job must stay rejected (got {status}): {body}"
);
assert!(
!body.contains(RESULT_SECRET),
"unauth body must not leak: {body}"
);
// ---- SHARE READ LINK (view_token) ----
// The owner (test-user-2) mints a share token for the victim job.
let (status, mint_body) = get(
&authed_base,
&format!("job_view_token/{VICTIM}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner must be able to mint a share token (got {status}): {mint_body}"
);
let token = mint_body.trim().trim_matches('"').to_string();
assert!(
token.starts_with(VICTIM),
"token must encode the job id: {token}"
);
// The viewer (no ACL) can now read the victim job via the share link.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}?view_token={token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"view_token must grant the viewer read of the shared job (got {status}): {body}"
);
assert!(
body.contains(RESULT_SECRET),
"shared job result must be returned with a valid view_token: {body}"
);
// ...and its args/logs too (whole detail page).
let (status, _) = get(
&base,
&format!("get_args/{VICTIM}?view_token={token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"view_token must also grant args (got {status})"
);
// The token is scoped: it does NOT authorize an unrelated job.
let (status, _) = get(
&base,
&format!("completed/get_result/{ANON_JOB}?view_token={token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"a victim-scoped token must not authorize a different job (got {status})"
);
// A garbage token is rejected (falls through to the normal 404).
let (status, _) = get(
&base,
&format!("completed/get_result/{VICTIM}?view_token={VICTIM}.deadbeef"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"an invalid view_token must not grant access (got {status})"
);
// A share token authorizes the shared job's whole flow subtree: the owner mints
// for the top secret flow, and the viewer can then read its deep leaf.
let (status, mint_body) = get(
&authed_base,
&format!("job_view_token/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner mints token for top flow (got {status}): {mint_body}"
);
let top_token = mint_body.trim().trim_matches('"').to_string();
let (status, body) = get(
&base,
&format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={top_token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"a flow's share token must authorize its deep descendants (got {status}): {body}"
);
// A viewer who cannot read a job cannot mint a share token for it.
let (status, _) = get(
&authed_base,
&format!("job_view_token/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"a non-reader must not be able to mint a share token (got {status})"
);
// ---- TAG-SCOPED token must not mint a token outside its allowed tags ----
// SCOPED_DENO_TOKEN (test-user-2, scope `if_jobs:filter_tags:deno`) can read both
// VICTIM (tag deno) and FLOW_JOB (tag flow) by RLS, but minting must honor the
// tag scope: allowed for the deno job, denied for the flow job.
let (status, body) = get(
&authed_base,
&format!("job_view_token/{VICTIM}"),
Some("SCOPED_DENO_TOKEN"),
)
.await;
assert!(
status.is_success(),
"tag-scoped token may mint for an in-scope (deno) job (got {status}): {body}"
);
let (status, _) = get(
&authed_base,
&format!("job_view_token/{FLOW_JOB}"),
Some("SCOPED_DENO_TOKEN"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"tag-scoped token must NOT mint for an out-of-scope (flow) job (got {status})"
);
// ---- USE side: a tag-scoped token must not use someone else's valid view_token
// to read an out-of-scope job, even via handlers that don't tag-filter their
// data query (result_by_id, get_otel_traces, get_flow_debug_info). ----
// An unscoped owner mints a valid token for the flow (tag 'flow').
let (status, mint_body) = get(
&authed_base,
&format!("job_view_token/{FLOW_JOB}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner mints flow token (got {status}): {mint_body}"
);
let flow_token = mint_body.trim().trim_matches('"').to_string();
// The deno-scoped token presents that valid flow token to the non-tag-filtered
// endpoints — must still be denied (flow tag is out of its scope).
for path in [
format!("get_otel_traces/{FLOW_JOB}?view_token={flow_token}"),
format!("result_by_id/{FLOW_JOB}/somenode?view_token={flow_token}"),
] {
let (status, _) = get(&authed_base, &path, Some("SCOPED_DENO_TOKEN")).await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"tag-scoped token must not use a view_token to read an out-of-scope job ({path}, got {status})"
);
}
// ...but the deno-scoped token CAN use an in-scope (deno) view_token.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}?view_token={token}"),
Some("SCOPED_DENO_TOKEN"),
)
.await;
assert!(
status.is_success(),
"tag-scoped token may use a view_token for an in-scope (deno) job (got {status}): {body}"
);
// ---- get_result_maybe?get_started=true must authorize before disclosing the
// running-state of a queued (not-yet-completed) private job. ----
// Viewer (no ACL) must be denied rather than told the job is started.
let (status, body) = get(
&base,
&format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must be denied the running-state of a private queued job (got {status}): {body}"
);
assert!(
!body.contains("\"started\""),
"denied response must not disclose started-state: {body}"
);
// The owner still gets the in-progress response.
let (status, body) = get(
&base,
&format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success() && body.contains("\"started\":true"),
"owner must see the running job as started (got {status}): {body}"
);
Ok(())
}
+27
View File
@@ -9318,6 +9318,33 @@ paths:
application/json:
schema: {}
/w/{workspace}/jobs/job_view_token/{id}:
get:
summary: mint a read-only share token for a job
description: >
Returns a stateless `{job_id}.{hmac}` token that grants an authenticated
workspace member read access to this job (and its flow subtree) via a
`view_token` query param or `X-View-Token` header. Only callable by a user
who can already read the job.
operationId: getJobViewToken
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: the share read token
content:
text/plain:
schema:
type: string
/w/{workspace}/flows/list_paths:
get:
summary: list all flow paths
+632 -37
View File
@@ -17,6 +17,7 @@ use itertools::Itertools;
use quick_cache::sync::Cache;
use serde_json::value::RawValue;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
@@ -344,6 +345,10 @@ pub fn workspaced_service() -> Router {
"/result_by_id/{job_id}/{node_id}",
get(get_result_by_id).layer(cors.clone()),
)
.route(
"/job_view_token/{id}",
get(get_job_view_token).layer(cors.clone()),
)
.route("/run/dependencies", post(run_dependencies_job))
.route("/run/dependencies_async", post(run_dependencies_job_async))
.route("/run/flow_dependencies", post(run_flow_dependencies_job))
@@ -426,12 +431,27 @@ struct JsonPath {
pub approver: Option<String>,
}
async fn get_result_by_id(
OptViewToken(view_token): OptViewToken,
authed: ApiAuthed,
tokened: Tokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, flow_id, node_id)): Path<(String, Uuid, String)>,
Query(JsonPath { json_path, .. }): Query<JsonPath>,
) -> windmill_common::error::JsonResult<Box<JsonRawValue>> {
// Reading a node's result requires being able to read the flow itself (the node
// belongs to it). Gate on the flow's visibility (created_by / RLS / root
// inheritance) before resolving via the root DB.
require_job_update_read_access(
&db,
&user_db,
&authed,
&w_id,
&flow_id,
view_token.as_deref(),
)
.await?;
let res =
windmill_queue::get_result_by_id(db.clone(), w_id.clone(), flow_id, node_id, json_path)
.await?;
@@ -441,6 +461,25 @@ async fn get_result_by_id(
Ok(Json(res))
}
/// Mint a stateless "share read link" token for a job. Only a caller who can already
/// read the job (creator / RLS / flow ancestor / admin) may mint it. The returned
/// `{job_id}.{hmac}` is passed back as the `view_token` query param on the run page's
/// reads, granting an authenticated member read of this job and its flow subtree.
async fn get_job_view_token(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<String> {
// No `view_token` here: minting requires the caller's own read access, so a share
// link cannot be used to mint further links. `require_job_read_access` also
// enforces the caller's `if_jobs:filter_tags` scope, so a tag-scoped token can't
// mint a transferable link for a job outside its allowed tags.
require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?;
let hmac = generate_view_token(&w_id, id, &db).await?;
Ok(format!("{id}.{hmac}"))
}
async fn get_root_job(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
@@ -690,9 +729,11 @@ async fn get_scheduled_for(
}
async fn get_flow_job_debug_info(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
tokened_o: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let job = GetQuery::new()
@@ -700,6 +741,18 @@ async fn get_flow_job_debug_info(
.fetch_queued((&db).into(), &id, &w_id)
.await?;
if let Some(job) = job {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&job.created_by,
view_token.as_deref(),
)
.await?;
}
let is_flow = job.is_flow();
if job.is_flow_step || !is_flow {
return Err(error::Error::BadRequest(
@@ -857,10 +910,313 @@ struct GetJobQuery {
pub approval_token: Option<String>,
}
/// Authorize an *authenticated* caller to read a single job's data
/// (full job / args / result / logs / live updates).
///
/// Single-job read endpoints query through the root `DB` (RLS-bypassing), filtered
/// only by job id + workspace (+ token scope tags). That is required for the
/// unauthenticated approval / public-trigger / anonymous-job flows, but for a
/// logged-in user it meant any workspace member — e.g. a viewer with no ACL on the
/// runnable — could read another user's job args/result/logs simply by obtaining the
/// job UUID, even though the same job is hidden from them in `jobs/list`
/// (RLS-filtered) and the underlying script returns 404. (WIN-2026-jobs-read)
///
/// Unauthenticated callers are still handled by each handler's anonymous-job check;
/// this gate applies only when a user is authenticated. Access is granted when:
/// - the caller created the job (`created_by`) — covers app components, webhooks and
/// the caller's own runs, whose `permissioned_as` is the policy identity rather
/// than the caller, so they would otherwise fail the RLS probe; or
/// - the job is visible to the caller under the same RLS as `jobs/list`, probed on
/// `v2_job` via `user_db` (admins BYPASSRLS).
///
/// Optional share-read-link token (validated by [`validate_view_token`]). Read from
/// the `view_token` query parameter — needed for `EventSource`/SSE and direct links,
/// which can't set headers — falling back to the `X-View-Token` header, which lets the
/// frontend attach it to every generated-client request via a single interceptor
/// instead of threading it through each call. Read independently of each handler's own
/// `Query<T>` extractor (axum allows only one typed `Query`).
pub struct OptViewToken(pub Option<String>);
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for OptViewToken {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let from_query = parts.uri.query().and_then(|q| {
serde_urlencoded::from_str::<Vec<(String, String)>>(q)
.ok()
.and_then(|pairs| {
pairs
.into_iter()
.find(|(k, _)| k == "view_token")
.map(|(_, v)| v)
})
});
let token = from_query.or_else(|| {
parts
.headers
.get("x-view-token")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
Ok(OptViewToken(token))
}
}
/// Otherwise returns 404 — matching `scripts/get` and avoiding existence disclosure.
async fn require_job_read_access(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
created_by: &str,
view_token: Option<&str>,
) -> error::Result<()> {
// Tag scope (`if_jobs:filter_tags:`) is an orthogonal hard restriction on a
// scoped token: it must never read a job outside its allowed tags, regardless of
// how authorization is otherwise satisfied (created_by / view token / RLS). Most
// read handlers also tag-filter their data query, but some (result_by_id,
// get_flow_job_debug_info, get_otel_traces) do not, so enforce it here — before
// the grants below — so a share token can't be used to escape the tag scope.
// `get_scope_tags` is `None` for unscoped callers (the common case), so this adds
// no query for normal sessions/tokens.
if let Some(tags) = get_scope_tags(authed) {
let in_scope = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))",
job_id,
w_id,
&tags.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
)
.fetch_one(db)
.await?
== Some(true);
if !in_scope {
return Err(Error::NotFound(format!("Job {job_id} not found")));
}
}
// Fast path: you can always read a job you launched. This is also load-bearing
// for apps — a component job runs as the app policy's `permissioned_as`, but its
// `created_by` is the launching viewer, so the RLS probe below would hide it.
if created_by == authed.username
|| authed
.username_override
.as_deref()
.is_some_and(|u| u == created_by)
{
return Ok(());
}
// Share read link: a valid view token minted by someone with read access grants
// this authenticated member read of the shared job and its flow subtree.
if let Some(token) = view_token {
if validate_view_token(db, w_id, job_id, token).await? {
return Ok(());
}
}
// The probe below (chain walk + an RLS-scoped transaction) is comparatively
// expensive and the same (caller, job) is hit repeatedly — e.g. `getupdate`
// polling of a run you can see but did not launch, or an admin watching many
// runs. Cache the boolean outcome. All job-side inputs to the decision
// (created_by, runnable_path, permissioned_as, visible_to_owner, flow lineage)
// are immutable after creation, and every mutable caller-side input
// (is_admin / username / username_override / groups / folders) is folded into
// the key — so a permission change yields a new key rather than a stale hit, and
// no TTL is needed (size-bounded LRU; mirrors apps' PERMIT_CACHE).
let cache_key = job_read_access_cache_key(authed, w_id, job_id);
let visible = if let Some(visible) = JOB_READ_ACCESS_CACHE.get(&cache_key) {
visible
} else {
// Visibility is inherited along the flow hierarchy: if you can read ANY flow
// that (transitively) contains this job, you can read the job. A step runs as
// its flow's `permissioned_as` but its `runnable_path` is the inner runnable's
// — which the caller may have no direct ACL on — and the flow-run UI fetches
// each step by id, so gating purely on the step's own RLS visibility would
// break inspecting a flow you can see but did not launch. We therefore probe
// RLS visibility of the job OR any of its `parent_job` ancestors (admins
// BYPASSRLS) — the same visibility as `jobs/list`.
let chain_ids = job_ancestor_chain_ids(db, w_id, job_id).await?;
let mut tx = user_db.clone().begin(authed).await?;
let visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
&chain_ids[..],
w_id,
)
.fetch_one(&mut *tx)
.await?
== Some(true);
tx.commit().await?;
JOB_READ_ACCESS_CACHE.insert(cache_key, visible);
visible
};
if visible {
return Ok(());
}
// Denied. Distinguish "the run exists but you lack access" (actionable: ask a
// colleague for a share link) from "no such run", so the UI can guide the user.
// Only authenticated members reach this point and job UUIDs are non-enumerable,
// so disclosing mere existence to a member is an acceptable trade-off for the UX.
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
&[*job_id][..],
w_id,
)
.fetch_one(db)
.await?
== Some(true);
if exists {
Err(Error::PermissionDenied(format!(
"You do not have access to run {job_id}. Ask a user who can see it to open the run and \
share a read-only link with you (the \"Share\" button on the run page)."
)))
} else {
Err(Error::NotFound(format!("Job {job_id} not found")))
}
}
/// Self + every `parent_job` ancestor (intermediate sub-flows up to the top-level
/// root) of `job_id`, resolved via the root DB (flow lineage is not sensitive).
/// Falls back to `[job_id]` if the row is absent so callers still run their probe.
async fn job_ancestor_chain_ids(db: &DB, w_id: &str, job_id: &Uuid) -> error::Result<Vec<Uuid>> {
let chain_ids = sqlx::query_scalar!(
r#"WITH RECURSIVE chain(id, parent_job) AS (
SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2
UNION ALL
SELECT j.id, j.parent_job FROM v2_job j
JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2
)
SELECT id AS "id!" FROM chain"#,
job_id,
w_id,
)
.fetch_all(db)
.await?;
Ok(if chain_ids.is_empty() {
vec![*job_id]
} else {
chain_ids
})
}
/// A share read link token has the form `{shared_job_id}.{hmac}` where `hmac` is
/// [`windmill_common::variables::generate_view_token`] for `shared_job_id`. It grants
/// read of that job and its whole flow subtree, so the run page can present a single
/// link that also renders the flow's steps. Returns true iff the signature is valid
/// AND `accessed_job_id` is the shared job or one of its descendants.
async fn validate_view_token(
db: &DB,
w_id: &str,
accessed_job_id: &Uuid,
token: &str,
) -> error::Result<bool> {
let Some((shared_id_str, provided_hmac)) = token.split_once('.') else {
return Ok(false);
};
let Ok(shared_id) = Uuid::parse_str(shared_id_str) else {
return Ok(false);
};
let Ok(provided_bytes) = hex::decode(provided_hmac) else {
return Ok(false);
};
// Constant-time verification (same domain as `generate_view_token`, mirroring
// `verify_suspended_secret`); avoids the timing side-channel of comparing the
// hex strings with `!=`.
let key = get_workspace_key(w_id, db).await?;
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
mac.update(shared_id.as_bytes());
mac.update(b"view_token");
if mac.verify_slice(&provided_bytes).is_err() {
return Ok(false);
}
if accessed_job_id == &shared_id {
return Ok(true);
}
// The token authorizes the shared job's subtree: accessed must descend from it,
// i.e. the shared job is among accessed's ancestors.
let chain = job_ancestor_chain_ids(db, w_id, accessed_job_id).await?;
Ok(chain.contains(&shared_id))
}
lazy_static::lazy_static! {
/// Caches the result of the `require_job_read_access` RLS visibility probe,
/// keyed by the caller's authorization-relevant identity plus the job id (see
/// [`job_read_access_cache_key`]). No TTL: the cached decision is a pure function
/// of immutable job-side state and the caller-side state encoded in the key, so a
/// permission change re-keys rather than going stale. Size-bounded LRU.
static ref JOB_READ_ACCESS_CACHE: Cache<[u8; 32], bool> = Cache::new(50_000);
}
/// Key for [`JOB_READ_ACCESS_CACHE`]: a SHA-256 over every caller-side input that
/// affects job-read visibility (admin flag, username, username override, the sorted
/// group set, and the sorted folder set the caller has any grant on — RLS reads from
/// all of them) plus the workspace and job id. Sorting makes the key order-independent;
/// each variable-length field is length-prefixed so no choice of input values can make
/// two distinct identities hash equal (e.g. `["a","bc"]` vs `["ab","c"]`).
fn job_read_access_cache_key(authed: &ApiAuthed, w_id: &str, job_id: &Uuid) -> [u8; 32] {
let mut hasher = Sha256::new();
// Length-prefix every variable-length field (u32 BE) to make the encoding injective.
let field = |hasher: &mut Sha256, bytes: &[u8]| {
hasher.update((bytes.len() as u32).to_be_bytes());
hasher.update(bytes);
};
hasher.update([authed.is_admin as u8]);
field(&mut hasher, authed.username.as_bytes());
field(
&mut hasher,
authed.username_override.as_deref().unwrap_or("").as_bytes(),
);
let mut groups: Vec<&str> = authed.groups.iter().map(String::as_str).collect();
groups.sort_unstable();
hasher.update((groups.len() as u32).to_be_bytes());
for g in groups {
field(&mut hasher, g.as_bytes());
}
let mut folders: Vec<&str> = authed.folders.iter().map(|f| f.0.as_str()).collect();
folders.sort_unstable();
hasher.update((folders.len() as u32).to_be_bytes());
for f in folders {
field(&mut hasher, f.as_bytes());
}
field(&mut hasher, w_id.as_bytes());
hasher.update(job_id.as_bytes());
hasher.finalize().into()
}
/// [`require_job_read_access`] for callers (job-update poll / SSE) that haven't
/// already loaded `created_by` — fetches it (root DB, by id+workspace) first.
async fn require_job_update_read_access(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
view_token: Option<&str>,
) -> error::Result<()> {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
w_id,
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
require_job_read_access(db, user_db, authed, w_id, job_id, &created_by, view_token).await
}
async fn get_job(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(GetJobQuery { no_logs, no_code, approval_token }): Query<GetJobQuery>,
) -> error::Result<Response> {
@@ -903,6 +1259,23 @@ async fn get_job(
let mut job = get.fetch(&db, &id, &w_id).await?;
job.fetch_outstanding_wait_time(&db).await?;
// A valid approval token is itself the capability; otherwise an authenticated
// caller must pass the same visibility as `jobs/list` (see `require_job_read_access`).
if !has_valid_approval_token {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
job.created_by(),
view_token.as_deref(),
)
.await?;
}
}
log_job_view(
&db,
opt_authed.as_ref(),
@@ -1477,8 +1850,10 @@ async fn get_logs_from_disk(
}
async fn get_completed_job_logs_tail(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::JsonResult<String> {
let tags = opt_authed
@@ -1501,7 +1876,18 @@ async fn get_completed_job_logs_tail(
.await?;
if let Some(record) = record {
if opt_authed.is_none() && record.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
} else if record.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -1519,9 +1905,11 @@ struct QueryJobLogs {
}
async fn get_job_logs(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(query_job_logs): Query<QueryJobLogs>,
) -> error::Result<Response> {
@@ -1552,7 +1940,18 @@ async fn get_job_logs(
.await?;
if let Some(record) = record {
if opt_authed.is_none() && record.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
} else if record.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -1680,9 +2079,11 @@ async fn resolve_logs_to_string(
}
async fn get_flow_all_logs(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let tags = opt_authed
@@ -1702,7 +2103,18 @@ async fn get_flow_all_logs(
let root_job = not_found_if_none(root_job, "Job", id.to_string())?;
if opt_authed.is_none() && root_job.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&root_job.created_by,
view_token.as_deref(),
)
.await?;
} else if root_job.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -1858,9 +2270,11 @@ async fn get_flow_all_logs(
}
async fn get_args(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> JsonResult<Box<RawValue>> {
let tags = opt_authed
@@ -1879,7 +2293,18 @@ async fn get_args(
.await?;
if let Some(record) = record {
if opt_authed.is_none() && record.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
} else if record.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -1907,7 +2332,18 @@ async fn get_args(
.fetch_optional(&db)
.await?;
let record = not_found_if_none(record, "Job Args", id.to_string())?;
if opt_authed.is_none() && record.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
} else if record.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -2453,7 +2889,7 @@ pub async fn resume_suspended_flow_as_owner(
// --- New approval system endpoints ---
use windmill_common::variables::generate_approval_token;
use windmill_common::variables::{generate_approval_token, generate_view_token};
/// Verify an approval token against the workspace key + job_id.
async fn validate_approval_token(
@@ -7107,8 +7543,10 @@ pub async fn run_job_by_hash_inner(
}
async fn get_log_file(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, file_p)): Path<(String, String)>,
) -> error::Result<Response> {
if file_p.contains("..") {
@@ -7147,7 +7585,18 @@ async fn get_log_file(
.fetch_optional(&db)
.await?
.ok_or_else(|| error::Error::NotFound(format!("Job {job_id} not found")))?;
if opt_authed.is_none() && created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&job_id,
&created_by,
view_token.as_deref(),
)
.await?;
} else if created_by != "anonymous" {
return Err(error::Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -7217,9 +7666,11 @@ async fn get_log_file(
}
async fn get_job_update(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery {
log_offset,
@@ -7232,6 +7683,17 @@ async fn get_job_update(
..
}): Query<JobUpdateQuery>,
) -> JsonResult<JobUpdate> {
if let Some(authed) = opt_authed.as_ref() {
require_job_update_read_access(
&db,
&user_db,
authed,
&w_id,
&job_id,
view_token.as_deref(),
)
.await?;
}
Ok(Json(
get_job_update_data(
&opt_authed,
@@ -7259,9 +7721,11 @@ async fn get_job_update(
}
async fn get_job_update_sse(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery {
log_offset,
@@ -7275,6 +7739,20 @@ async fn get_job_update_sse(
poll_delay_ms,
}): Query<JobUpdateQuery>,
) -> error::Result<Response> {
// Authorize once at connection time; `created_by` cannot change for a given job,
// mirroring the per-stream `anonymous_verified` latch in the streaming loop.
if let Some(authed) = opt_authed.as_ref() {
require_job_update_read_access(
&db,
&user_db,
authed,
&w_id,
&job_id,
view_token.as_deref(),
)
.await?;
}
let (tx, rx) = tokio::sync::mpsc::channel(32);
start_job_update_sse_stream(
@@ -8034,9 +8512,11 @@ async fn list_completed_jobs(
}
async fn get_completed_job<'a>(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let tags = opt_authed
@@ -8051,6 +8531,20 @@ async fn get_completed_job<'a>(
.await?;
let cj = not_found_if_none(job_o, "Completed Job", id.to_string())?;
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&cj.created_by,
view_token.as_deref(),
)
.await?;
}
let response = Json(cj).into_response();
// let extra_log = query_scalar!(
// "SELECT substr(logs, $1) as logs FROM large_logs WHERE workspace_id = $2 AND job_id = $3",
@@ -8081,9 +8575,11 @@ pub struct RawResult {
}
async fn get_completed_job_result(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(JsonPath { json_path, suspended_job, approver, resume_id, secret }): Query<JsonPath>,
) -> error::Result<Response> {
@@ -8128,26 +8624,40 @@ async fn get_completed_job_result(
let mut raw_result = not_found_if_none(result_o, "Completed Job", id.to_string())?;
if opt_authed.is_none() && raw_result.created_by.unwrap_or_default() != "anonymous" {
match (suspended_job, resume_id, approver, secret) {
(Some(suspended_job), Some(resume_id), approver, Some(secret)) => {
let mut parent_job = id;
while parent_job != suspended_job {
let p_job = sqlx::query_scalar!(
"SELECT parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2",
parent_job,
&w_id
)
.fetch_optional(&db)
.await?
.flatten();
if let Some(p_job) = p_job {
parent_job = p_job;
} else {
return Err(Error::BadRequest("Approval secret of suspended job is not a parent of the job whose id's is being searched not found".to_string()));
let created_by = raw_result.created_by.take().unwrap_or_default();
// A valid approval secret for the suspended parent flow grants access to this
// node's result for ANY caller — logged in or not — since the approval page
// renders its form from this result. Try it first. If the secret triple is absent,
// or present but invalid, fall through to normal authorization: an authenticated
// reader with ACL must NOT be blocked just because a stale/garbage secret was
// attached (pre-fix the secret branch was skipped entirely for authed callers),
// while an unauthenticated caller, for whom the secret is the only credential,
// still ends up rejected below.
let approval_secret_ok = match (suspended_job, resume_id, secret) {
(Some(suspended_job), Some(resume_id), Some(secret)) => {
// Walk from `id` up to the claimed suspended parent.
let mut parent_job = id;
let mut reached = true;
while parent_job != suspended_job {
let p_job = sqlx::query_scalar!(
"SELECT parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2",
parent_job,
&w_id
)
.fetch_optional(&db)
.await?
.flatten();
match p_job {
Some(p_job) => parent_job = p_job,
None => {
reached = false;
break;
}
}
verify_suspended_secret(
}
reached
&& verify_suspended_secret(
&w_id,
&db,
suspended_job,
@@ -8155,14 +8665,28 @@ async fn get_completed_job_result(
&QueryApprover { approver, flow_level: None },
secret,
)
.await?
}
_ => {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
))
}
.await
.is_ok()
}
_ => false,
};
if !approval_secret_ok {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
} else if created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
}
}
@@ -8235,9 +8759,11 @@ struct GetCompletedJobQuery {
}
async fn get_completed_job_result_maybe(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(GetCompletedJobQuery { get_started }): Query<GetCompletedJobQuery>,
) -> error::Result<Response> {
@@ -8263,7 +8789,18 @@ async fn get_completed_job_result_maybe(
if let Some(mut res) = result_o {
format_result(res.result_columns.as_ref(), res.result.as_mut());
if opt_authed.is_none() && res.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&res.created_by,
view_token.as_deref(),
)
.await?;
} else if res.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -8286,6 +8823,36 @@ async fn get_completed_job_result_maybe(
})
.into_response())
} else if get_started.is_some_and(|x| x) {
// No completed row yet — the job may be queued/running. Returning its
// running-state still discloses information about a (possibly private) job, so
// authorize first when the job exists. If it doesn't exist, fall through to a
// `started: false` response (which leaks nothing).
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
id,
&w_id
)
.fetch_optional(&db)
.await?;
if let Some(created_by) = created_by {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
} else if created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
));
}
}
let started = sqlx::query_scalar!(
"SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2",
id,
@@ -8320,8 +8887,10 @@ struct JobTiming {
}
async fn get_completed_job_timing(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::JsonResult<JobTiming> {
let tags = opt_authed
@@ -8347,7 +8916,18 @@ async fn get_completed_job_timing(
let result = not_found_if_none(result, "Completed Job", id.to_string())?;
if opt_authed.is_none() && result.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&result.created_by,
view_token.as_deref(),
)
.await?;
} else if result.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -8367,7 +8947,7 @@ async fn delete_completed_job<'a>(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let mut tx = user_db.begin(&authed).await?;
let mut tx = user_db.clone().begin(&authed).await?;
require_admin(authed.is_admin, &authed.username)?;
let tags = get_scope_tags(&authed);
@@ -8411,17 +8991,21 @@ async fn delete_completed_job<'a>(
tx.commit().await?;
return get_completed_job(
OptViewToken(None),
OptAuthed(Some(authed)),
OptTokened { token: Some(token) },
Extension(db),
Extension(user_db),
Path((w_id, id)),
)
.await;
}
async fn get_otel_traces(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Json<Vec<serde_json::Value>>> {
// Check job exists and user has permission to view it
@@ -8435,7 +9019,18 @@ async fn get_otel_traces(
match job {
Some(created_by) => {
if opt_authed.is_none() && created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
} else if created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
+20
View File
@@ -174,6 +174,26 @@ pub async fn generate_approval_token(
Ok(hex::encode(mac.finalize().into_bytes()))
}
/// Stateless read-share signature for a job: `HMAC(workspace_key, job_id || "view_token")`.
/// Mirrors [`generate_approval_token`] but in a distinct domain so an approval token can
/// never be used as a view token (or vice-versa). Used to build a "share read link" that
/// grants an authenticated workspace member read access to a job (and its flow subtree)
/// they otherwise lack ACL on. No expiry/revocation (stateless), like the approval token.
pub async fn generate_view_token(
w_id: &str,
job_id: uuid::Uuid,
db: &DB,
) -> crate::error::Result<String> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let key = get_workspace_key(w_id, db).await?;
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
mac.update(job_id.as_bytes());
mac.update(b"view_token");
Ok(hex::encode(mac.finalize().into_bytes()))
}
pub async fn get_secret_value_as_admin(
db: &DB,
w_id: &str,
@@ -6,6 +6,7 @@
import { copyToClipboard, parseS3Object, roughSizeOfObject } from '$lib/utils'
import { base } from '$lib/base'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import { Button, Drawer, DrawerContent } from './common'
import {
ClipboardCopy,
@@ -176,9 +177,11 @@
let resultApiPath = $derived(
workspaceId && jobId
? nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
? appendViewToken(
nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
)
: undefined
)
let resultDownloadHref = $derived(
@@ -1016,9 +1019,7 @@
{#if largeObject}
<div class="text-xs text-emphasis"
>{#if resultApiPath && shouldDownloadViaClient()}
<button
onclick={() => downloadViaClient(resultApiPath!, resultDownloadName)}
>
<button onclick={() => downloadViaClient(resultApiPath!, resultDownloadName)}>
Download {filename ? '' : 'as JSON'}
</button>
{:else}
@@ -4,6 +4,7 @@
import Popover from './Popover.svelte'
import { copyToClipboard } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import type { DisplayResultUi } from './custom_ui'
import { createEventDispatcher } from 'svelte'
@@ -41,9 +42,11 @@
let resultApiPath = $derived(
workspaceId && jobId
? nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
? appendViewToken(
nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
)
: undefined
)
let downloadName = $derived(`${filename ?? 'result'}.json`)
@@ -28,6 +28,7 @@
import ModuleStatus from './ModuleStatus.svelte'
import { clone, isScriptPreview, msToSec, readFieldsRecursively, truncateRev } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import JobArgs from './JobArgs.svelte'
import { ChevronDown, Download, ExternalLink, Hourglass } from 'lucide-svelte'
import { deepEqual } from 'fast-equals'
@@ -1839,7 +1840,9 @@
style="min-height: {minTabHeight}px"
>
{#if !hideDownloadLogs && !isReplay && job?.id}
{@const logsApiPath = `/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}`}
{@const logsApiPath = appendViewToken(
`/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}`
)}
{@const logsName = `windmill_flow_logs_${job.id}.txt`}
<div class="flex justify-end p-1">
{#if shouldDownloadViaClient()}
+4 -1
View File
@@ -14,6 +14,7 @@
import { deepEqual } from 'fast-equals'
import { isWindmillTooBigObject } from './job_args'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
interface Props {
id?: string | undefined
@@ -29,7 +30,9 @@
let jsonStr = $state('')
const argsDownloadName = 'windmill-args.json'
let argsApiPath = $derived(id && workspace ? `/w/${workspace}/jobs_u/get_args/${id}` : undefined)
let argsApiPath = $derived(
id && workspace ? appendViewToken(`/w/${workspace}/jobs_u/get_args/${id}`) : undefined
)
let argsDataHref = $derived(`data:text/json;charset=utf-8,${encodeURIComponent(jsonStr)}`)
function pythonCode() {
+18 -1
View File
@@ -15,6 +15,7 @@
type OpenFlow
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getViewToken } from '$lib/viewToken'
import { WM_LOGS_SKIPPED } from '$lib/consts'
import { getContext, onDestroy, tick, untrack } from 'svelte'
import type { SupportedLanguage } from '$lib/common'
@@ -47,6 +48,9 @@
noLogs?: boolean
workspaceOverride?: string | undefined
notfound?: boolean
/** Status/body of the last load failure, so callers can distinguish e.g. a
* 403 (job exists but no access — offer a share link) from a 404. */
loadError?: { status?: number; message?: string } | undefined
allowConcurentRequests?: boolean
jobUpdateLastFetch?: Date | undefined
toastError?: boolean
@@ -65,6 +69,7 @@
allowConcurentRequests = false,
workspaceOverride = undefined,
notfound = $bindable(false),
loadError = $bindable(undefined),
jobUpdateLastFetch = $bindable(undefined),
toastError = false,
onlyResult = false,
@@ -600,9 +605,14 @@
}
}
notfound = false
loadError = undefined
} catch (err) {
const status = (err as any)?.status
loadError = { status, message: (err as any)?.body ?? (err as any)?.message }
errorIteration += 1
if (errorIteration == 5) {
// Auth failures won't resolve by retrying: surface them immediately so
// the caller can show the right message (e.g. 403 -> request a share link).
if (status === 403 || status === 404 || errorIteration == 5) {
notfound = true
job = undefined
clearCurrentId()
@@ -754,6 +764,13 @@
params.set('token', token.token)
}
// Share read link: SSE/EventSource can't set the X-View-Token header,
// so carry the token as a query param instead.
const viewToken = getViewToken()
if (viewToken) {
params.set('view_token', viewToken)
}
const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${id}?${params.toString()}`
currentEventSource = new EventSource(sseUrl)
+2 -1
View File
@@ -17,6 +17,7 @@
import { base } from '$lib/base'
import { withExternalDomain } from '$lib/externalDomain'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import { workspaceStore } from '$lib/stores'
import { AnsiUp } from 'ansi_up'
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
@@ -241,7 +242,7 @@
fetchedSkippedJobId = undefined
}
})
let logsApiPath = $derived(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`)
let logsApiPath = $derived(appendViewToken(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`))
let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`))
let downloadName = $derived(`windmill_logs_${jobId}.txt`)
let truncatedContent = $derived(
+44
View File
@@ -0,0 +1,44 @@
import { OpenAPI } from '$lib/gen'
/**
* Share-read-link support. When viewing a run via a share link
* (`/run/{id}?view_token=...`), the token grants the current authenticated member
* read access to that job and its flow subtree on the backend.
*
* The token is attached to every generated-client request via the `X-View-Token`
* header (registered once below) so we don't have to thread it through every
* `JobService` call. `EventSource`/SSE can't set headers, so those URLs read
* `getViewToken()` and append it as a `view_token` query param instead.
*/
let currentViewToken: string | undefined = undefined
export function setViewToken(token: string | undefined): void {
currentViewToken = token || undefined
}
export function getViewToken(): string | undefined {
return currentViewToken
}
/**
* Append the current view token as a `view_token` query param to a URL/path.
* Used for download links (plain `<a href>` and `downloadViaClient`), which don't
* go through the request interceptor that adds the `X-View-Token` header.
* Returns the url unchanged when no share link is active.
*/
export function appendViewToken(url: string): string {
if (!currentViewToken) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}view_token=${encodeURIComponent(currentViewToken)}`
}
// Register the request interceptor exactly once. It is a no-op unless a view token
// is currently set, so it is safe to keep installed for the whole session.
OpenAPI.interceptors.request.use((options) => {
if (currentViewToken) {
const headers = new Headers(options.headers)
headers.set('X-View-Token', currentViewToken)
options.headers = headers
}
return options
})
@@ -36,7 +36,8 @@
ClipboardCopy,
GitBranch,
GitFork,
EllipsisVertical
EllipsisVertical,
Share2
} from 'lucide-svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
@@ -85,6 +86,7 @@
} from '$lib/components/flows/FlowAssetsHandler.svelte'
import JobAssetsViewer from '$lib/components/assets/JobAssetsViewer.svelte'
import { page } from '$app/state'
import { setViewToken } from '$lib/viewToken'
import { twMerge } from 'tailwind-merge'
import FlowRestartButton from '$lib/components/FlowRestartButton.svelte'
import { useNestedRestartState } from '$lib/components/useNestedRestartState.svelte'
@@ -120,6 +122,7 @@
let testIsLoading = $state(false)
let jobLoader: JobLoader | undefined = $state(undefined)
let loadError: { status?: number; message?: string } | undefined = $state(undefined)
// Flow execution status state
let suspendStatus: import('$lib/utils').StateStore<Record<string, { job: Job; nb: number }>> =
@@ -146,6 +149,34 @@
concurrencyKey = await ConcurrencyGroupsService.getConcurrencyKey({ id: job.id })
}
// Share read link: if the URL carries a `view_token`, install it so every job
// read on this page (incl. flow steps, args, logs, SSE) is authorized by it.
// Set eagerly at init (before JobLoader mounts and fires its first fetch), and
// reactively keep it in sync across client-side navigation.
setViewToken(page.url.searchParams.get('view_token') ?? undefined)
$effect(() => {
setViewToken(page.url.searchParams.get('view_token') ?? undefined)
})
onDestroy(() => setViewToken(undefined))
async function shareReadLink(id: string): Promise<void> {
try {
const workspace = $workspaceStore!
const token = (await JobService.getJobViewToken({ workspace, id })).trim()
// Pin the workspace in the link: the token is signed with this workspace's
// key, and the logged layout only switches `$workspaceStore` when the URL
// carries `workspace=`. Without it a recipient whose active workspace
// differs would open the run (and validate the token) against the wrong one.
const url = `${window.location.origin}${base}/run/${id}?workspace=${encodeURIComponent(
workspace
)}&view_token=${encodeURIComponent(token)}`
copyToClipboard(url)
sendUserToast('Read-only share link copied to clipboard')
} catch (e) {
sendUserToast(`Failed to create share link: ${e}`, true)
}
}
async function deleteCompletedJob(id: string): Promise<void> {
await JobService.deleteCompletedJob({ workspace: $workspaceStore!, id })
getJob()
@@ -447,6 +478,7 @@
bind:jobUpdateLastFetch
workspaceOverride={$workspaceStore}
bind:notfound
bind:loadError
/>
{/if}
@@ -454,7 +486,28 @@
<PersistentScriptDrawer bind:this={persistentScriptDrawer} />
</Portal>
{#if notfound || (job?.workspace_id != undefined && $workspaceStore != undefined && job?.workspace_id != $workspaceStore)}
{#if loadError?.status === 403}
<div class="max-w-3xl px-4 mx-auto w-full">
<div class="mt-6">
<Alert type="warning" title="You don't have access to this run">
<div class="flex flex-col gap-2">
<p>
This run exists in <span class="font-semibold">{$workspaceStore}</span>, but you don't
have permission to view it.
</p>
<p>
Ask a colleague who can see it to open the run and use the
<span class="font-semibold">Share</span> button to send you a read-only link. Opening that
link will grant you access to this run (and its steps).
</p>
</div>
</Alert>
<div class="mt-4">
<Button href="{base}/runs" unifiedSize="md" variant="accent">Go to runs page</Button>
</div>
</div>
</div>
{:else if notfound || (job?.workspace_id != undefined && $workspaceStore != undefined && job?.workspace_id != $workspaceStore)}
<div class="max-w-7xl px-4 mx-auto w-full">
<div class="flex flex-col gap-6">
<h1 class="text-red-400 mt-6 text-2xl font-semibold"
@@ -535,6 +588,17 @@
</Button>
{/if}
{/if}
{#if job}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: Share2 }}
title="Copy a read-only share link to this run for another workspace member"
onclick={() => job && shareReadLink(job.id)}
>
Share
</Button>
{/if}
{@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`}
{@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`}
{#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined}