diff --git a/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json b/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json new file mode 100644 index 0000000000..8944a6001f --- /dev/null +++ b/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json @@ -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" +} diff --git a/backend/.sqlx/query-8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460.json b/backend/.sqlx/query-8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460.json new file mode 100644 index 0000000000..2ad0791d3c --- /dev/null +++ b/backend/.sqlx/query-8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460.json @@ -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" +} diff --git a/backend/.sqlx/query-ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50.json b/backend/.sqlx/query-ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50.json new file mode 100644 index 0000000000..a84b022930 --- /dev/null +++ b/backend/.sqlx/query-ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50.json @@ -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" +} diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql new file mode 100644 index 0000000000..e6b28fba0c --- /dev/null +++ b/backend/tests/fixtures/jobs_read_auth.sql @@ -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"}'); diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs new file mode 100644 index 0000000000..f19daf0f92 --- /dev/null +++ b/backend/tests/jobs_read_auth.rs @@ -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) -> 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(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0c649f357f..13dcefea81 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index d9820d1070..a4382e04ff 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -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, } async fn get_result_by_id( + OptViewToken(view_token): OptViewToken, authed: ApiAuthed, tokened: Tokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, flow_id, node_id)): Path<(String, Uuid, String)>, Query(JsonPath { json_path, .. }): Query, ) -> windmill_common::error::JsonResult> { + // 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, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> error::Result { + // 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, 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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result { 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, } +/// 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` extractor (axum allows only one typed `Query`). +pub struct OptViewToken(pub Option); + +impl axum::extract::FromRequestParts for OptViewToken { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> std::result::Result { + let from_query = parts.uri.query().and_then(|q| { + serde_urlencoded::from_str::>(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::>(), + ) + .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> { + 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 { + 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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Query(GetJobQuery { no_logs, no_code, approval_token }): Query, ) -> error::Result { @@ -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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::JsonResult { 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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Query(query_job_logs): Query, ) -> error::Result { @@ -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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result { 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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> JsonResult> { 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, + Extension(user_db): Extension, Path((w_id, file_p)): Path<(String, String)>, ) -> error::Result { 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, + Extension(user_db): Extension, Path((w_id, job_id)): Path<(String, Uuid)>, Query(JobUpdateQuery { log_offset, @@ -7232,6 +7683,17 @@ async fn get_job_update( .. }): Query, ) -> JsonResult { + 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, + Extension(user_db): Extension, 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, ) -> error::Result { + // 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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result { 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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Query(JsonPath { json_path, suspended_job, approver, resume_id, secret }): Query, ) -> error::Result { @@ -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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Query(GetCompletedJobQuery { get_started }): Query, ) -> error::Result { @@ -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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::JsonResult { 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, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result { - 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, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result>> { // 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(), diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 07ac44b755..9c944cb778 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -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 { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let key = get_workspace_key(w_id, db).await?; + let mut mac = Hmac::::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, diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index 8ac06662d8..3d94c33c21 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -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}
{#if resultApiPath && shouldDownloadViaClient()} - {:else} diff --git a/frontend/src/lib/components/DisplayResultControlBar.svelte b/frontend/src/lib/components/DisplayResultControlBar.svelte index d45ca3f92d..45011d2db1 100644 --- a/frontend/src/lib/components/DisplayResultControlBar.svelte +++ b/frontend/src/lib/components/DisplayResultControlBar.svelte @@ -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`) diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 809eff58d9..d0bf66d61b 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -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`}
{#if shouldDownloadViaClient()} diff --git a/frontend/src/lib/components/JobArgs.svelte b/frontend/src/lib/components/JobArgs.svelte index 77c94de11e..ffa90bb941 100644 --- a/frontend/src/lib/components/JobArgs.svelte +++ b/frontend/src/lib/components/JobArgs.svelte @@ -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() { diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index 7913fd4be7..1aa24e68a5 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -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) diff --git a/frontend/src/lib/components/LogViewer.svelte b/frontend/src/lib/components/LogViewer.svelte index ca90e3968f..b5a31265be 100644 --- a/frontend/src/lib/components/LogViewer.svelte +++ b/frontend/src/lib/components/LogViewer.svelte @@ -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( diff --git a/frontend/src/lib/viewToken.ts b/frontend/src/lib/viewToken.ts new file mode 100644 index 0000000000..18c21d27d8 --- /dev/null +++ b/frontend/src/lib/viewToken.ts @@ -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 `` 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 +}) diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 05b53ece63..1357038c39 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -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> = @@ -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 { + 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 { await JobService.deleteCompletedJob({ workspace: $workspaceStore!, id }) getJob() @@ -447,6 +478,7 @@ bind:jobUpdateLastFetch workspaceOverride={$workspaceStore} bind:notfound + bind:loadError /> {/if} @@ -454,7 +486,28 @@ -{#if notfound || (job?.workspace_id != undefined && $workspaceStore != undefined && job?.workspace_id != $workspaceStore)} +{#if loadError?.status === 403} +
+
+ +
+

+ This run exists in {$workspaceStore}, but you don't + have permission to view it. +

+

+ Ask a colleague who can see it to open the run and use the + Share button to send you a read-only link. Opening that + link will grant you access to this run (and its steps). +

+
+
+
+ +
+
+
+{:else if notfound || (job?.workspace_id != undefined && $workspaceStore != undefined && job?.workspace_id != $workspaceStore)}

{/if} {/if} + {#if job} + + {/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}