mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
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:
+23
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+23
@@ -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
@@ -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"}');
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user