mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 00:01:55 +00:00
89a7a37776
* 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>
193 lines
9.8 KiB
SQL
193 lines
9.8 KiB
SQL
-- 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"}');
|