feat: add public sharing option for job pages (#10573)

* feat: add public sharing option for job pages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate public run sharing and address review findings

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address review nits on public run sharing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: key public run view on workspace, job and token

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-08-06 17:18:29 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent fdd76a6f13
commit 5ce29b3436
11 changed files with 929 additions and 281 deletions
+213
View File
@@ -83,6 +83,32 @@ async fn post(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCo
(status, body)
}
async fn post_json(
url: &str,
token: Option<&str>,
body: serde_json::Value,
) -> (reqwest::StatusCode, String) {
let mut req = client().post(url).json(&body);
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)
}
async fn delete(url: &str, token: Option<&str>) -> (reqwest::StatusCode, String) {
let mut req = client().delete(url);
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;
@@ -92,6 +118,8 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
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");
let rules_url =
format!("http://localhost:{port}/api/w/test-workspace/workspaces/protection_rules");
// The endpoints that return the victim job's sensitive data by UUID.
let endpoints = [
@@ -507,6 +535,161 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
"a non-reader must not be able to mint a share token (got {status})"
);
// ---- PUBLIC SHARE READ LINK (public view_token) ----
// A member-audience token must never turn into a public one: links already handed
// out stay confined to logged-in members.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}?view_token={token}"),
None,
)
.await;
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"a member view_token must not grant unauthenticated read (got {status}): {body}"
);
assert!(
!body.contains(RESULT_SECRET),
"unauth body must not leak with a member token: {body}"
);
// The owner mints the public flavor for the top flow.
let (status, mint_body) = get(
&authed_base,
&format!("job_public_view_token/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner must be able to mint a public share token (got {status}): {mint_body}"
);
let public_token = mint_body.trim().trim_matches('"').to_string();
// It grants a logged-out visitor the shared job and its whole flow subtree.
for path in [
format!("get/{TOP_SECRET_FLOW}?view_token={public_token}"),
format!("get_args/{TOP_SECRET_FLOW}?view_token={public_token}"),
format!("getupdate/{TOP_SECRET_FLOW}?view_token={public_token}"),
format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={public_token}"),
format!("get_logs/{DEEP_LEAF_JOB}?view_token={public_token}"),
] {
let (status, body) = get(&base, &path, None).await;
assert!(
status.is_success(),
"a public view_token must grant unauthenticated read of {path} (got {status}): {body}"
);
}
// Same scoping as the member flavor: another job, and a tampered signature, are refused.
for path in [
format!("get/{VICTIM}?view_token={public_token}"),
format!("get/{TOP_SECRET_FLOW}?view_token={TOP_SECRET_FLOW}.deadbeef"),
] {
let (status, body) = get(&base, &path, None).await;
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"an out-of-scope or forged public token must not grant read of {path} (got {status}): {body}"
);
}
// The public audience is a superset of the member one: it must also satisfy the
// authenticated ACL check, so a viewer handed a public link is not worse off.
let (status, body) = get(
&base,
&format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={public_token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"a public view_token must also grant an authenticated member read (got {status}): {body}"
);
// Tag-scoped caller, job inside its scope: the member flavor is allowed (asserted
// further down), the public one must not be — i.e. strictly stricter.
let (status, body) = get(
&authed_base,
&format!("job_public_view_token/{VICTIM}"),
Some("SCOPED_DENO_TOKEN"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"a tag-scoped token must NOT mint a public link, even for an in-scope job (got {status}): {body}"
);
// Minting the public flavor takes the same read access as the member one.
let (status, _) = get(
&authed_base,
&format!("job_public_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 public share token (got {status})"
);
// Publishing a run is gated by `RestrictPublicRunSharing`, so a wrong bitflag or
// rule-kind arm would silently let a restricted member expose one. Admins bypass, and
// the member flavor is untouched by the rule.
let (status, body) = post_json(
&rules_url,
Some("SECRET_TOKEN"),
serde_json::json!({
"name": "no-public-runs",
"rules": ["RestrictPublicRunSharing"],
"bypass_users": [],
"bypass_groups": []
}),
)
.await;
assert!(
status.is_success(),
"admin should create the protection rule (got {status}): {body}"
);
let (status, body) = get(
&authed_base,
&format!("job_public_view_token/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"the rule must block a non-admin owner from minting a public link (got {status}): {body}"
);
let (status, body) = get(
&authed_base,
&format!("job_view_token/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"the rule must NOT affect the member flavor (got {status}): {body}"
);
let (status, body) = get(
&authed_base,
&format!("job_public_view_token/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN"),
)
.await;
assert!(
status.is_success(),
"an admin must bypass the rule (got {status}): {body}"
);
// Later assertions in this test mint public tokens; drop the rule so they see the
// same workspace state as before.
let (status, _) = delete(&format!("{rules_url}/no-public-runs"), Some("SECRET_TOKEN")).await;
assert!(status.is_success(), "admin should delete the rule");
// ---- 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
@@ -618,6 +801,36 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
"viewer must not cancel another user's job ({path}, got {status}): {body}"
);
}
// A public share link grants read, never the right to kill the run: cancelling stays
// confined to anonymously-created jobs for a logged-out caller.
let (status, mint_body) = get(
&authed_base,
&format!("job_public_view_token/{RUNNING_JOB}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(status.is_success(), "owner mints for the running job");
let running_public = mint_body.trim().trim_matches('"').to_string();
let (status, body) = get(
&base,
&format!("get/{RUNNING_JOB}?view_token={running_public}"),
None,
)
.await;
assert!(
status.is_success(),
"the public token must grant the anonymous read it is for (got {status}): {body}"
);
let (status, body) = post(
&base,
&format!("queue/cancel/{RUNNING_JOB}?view_token={running_public}"),
None,
)
.await;
assert!(
!status.is_success(),
"a public token must not let an anonymous caller cancel the run (got {status}): {body}"
);
// The owner still cancels their own job (no over-blocking). Keep this last: it
// takes RUNNING_JOB out of the queue.
let (status, body) = post(
+28
View File
@@ -10698,6 +10698,33 @@ paths:
schema:
type: string
/w/{workspace}/jobs/job_public_view_token/{id}:
get:
summary: mint a public read-only share token for a job
description: >
Returns a stateless `{job_id}.{hmac}` token that grants anyone holding it —
including logged-out visitors, who land on the minimal public run page — 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: getJobPublicViewToken
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: the public share read token
content:
text/plain:
schema:
type: string
/w/{workspace}/flows/list_paths:
get:
summary: list all flow paths
@@ -33408,6 +33435,7 @@ components:
- DisableWorkspaceForking
- RestrictDeployToDeployers
- RestrictAnonymousAppDeployment
- RestrictPublicRunSharing
RuleBypasserGroups:
type: array
description: Groups that can bypass this ruleset
+366 -263
View File
@@ -53,6 +53,7 @@ use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR};
use windmill_common::workspace_dependencies::{
RawWorkspaceDependencies, MIN_VERSION_WORKSPACE_DEPENDENCIES,
};
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
use windmill_common::DYNAMIC_INPUT_CACHE;
#[cfg(all(feature = "enterprise", feature = "instance_smtp"))]
use windmill_common::{email_oss::send_email_html, server::load_smtp_config};
@@ -369,6 +370,10 @@ pub fn workspaced_service() -> Router {
"/job_view_token/{id}",
get(get_job_view_token).layer(cors.clone()),
)
.route(
"/job_public_view_token/{id}",
get(get_job_public_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))
@@ -502,7 +507,63 @@ async fn get_job_view_token(
// 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?;
let hmac = generate_view_token(&w_id, id, VIEW_TOKEN_DOMAIN, &db).await?;
Ok(format!("{id}.{hmac}"))
}
/// Public flavor of [`get_job_view_token`]: the resulting link additionally grants read of
/// the job (and its flow subtree) to logged-out visitors, who land on the minimal public
/// run page. Read access is necessary but not sufficient — exposing workspace data to the
/// anonymous internet is a privileged action, gated like an app's anonymous execution mode.
async fn get_job_public_view_token(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<String> {
require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?;
// Anonymous readers of a public link carry no scope to confine, so the link would
// reach out-of-scope descendants of an in-scope job (mixed-tag flow trees). A tag
// scope is a hard restriction: refuse rather than silently narrow the link.
if get_scope_tags(&authed).is_some() {
return Err(Error::PermissionDenied(
"A tag-scoped token cannot share a run publicly: the resulting link is read \
anonymously and could not carry the tag restriction to the run's steps."
.to_string(),
));
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::RestrictPublicRunSharing,
&authed.username,
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let hmac = generate_view_token(&w_id, id, PUBLIC_VIEW_TOKEN_DOMAIN, &db).await?;
// The link is stateless and permanent, so the mint is the only moment this is
// observable: audit it unconditionally rather than through the opt-in job-view log.
let mut tx = db.begin().await?;
audit_log(
&mut *tx,
&AuditAuthor::from(&authed),
"jobs.share_publicly",
ActionKind::Create,
&w_id,
Some(&id.to_string()),
None,
)
.await?;
tx.commit().await?;
Ok(format!("{id}.{hmac}"))
}
@@ -1320,7 +1381,7 @@ struct GetJobQuery {
/// 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;
/// Unauthenticated callers go through [`require_unauthed_job_read_access`] instead;
/// 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
@@ -1447,9 +1508,13 @@ async fn require_job_read_access(
}
// 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.
// this authenticated member read of the shared job and its flow subtree. Either
// audience does — the public one is a superset of the member one.
if let Some(token) = view_token {
if validate_view_token(db, w_id, job_id, token).await? {
if validate_view_token(db, w_id, job_id, token)
.await?
.is_some()
{
return Ok(());
}
}
@@ -1542,43 +1607,109 @@ async fn job_ancestor_chain_ids(db: &DB, w_id: &str, job_id: &Uuid) -> error::Re
})
}
/// Who a share read link was minted for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ViewTokenAudience {
/// Read of the job subtree for an authenticated workspace member.
Member,
/// The same read, additionally granted to logged-out visitors.
Public,
}
/// 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.
/// link that also renders the flow's steps. Returns the token's audience 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> {
) -> error::Result<Option<ViewTokenAudience>> {
let Some((shared_id_str, provided_hmac)) = token.split_once('.') else {
return Ok(false);
return Ok(None);
};
let Ok(shared_id) = Uuid::parse_str(shared_id_str) else {
return Ok(false);
return Ok(None);
};
let Ok(provided_bytes) = hex::decode(provided_hmac) else {
return Ok(false);
return Ok(None);
};
// Constant-time verification (same domain as `generate_view_token`, mirroring
// Constant-time verification (same domains 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);
}
let verify = |domain: &[u8]| -> error::Result<bool> {
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
mac.update(shared_id.as_bytes());
mac.update(domain);
Ok(mac.verify_slice(&provided_bytes).is_ok())
};
let audience = if verify(VIEW_TOKEN_DOMAIN)? {
ViewTokenAudience::Member
} else if verify(PUBLIC_VIEW_TOKEN_DOMAIN)? {
ViewTokenAudience::Public
} else {
return Ok(None);
};
if accessed_job_id == &shared_id {
return Ok(true);
return Ok(Some(audience));
}
// 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))
Ok(chain.contains(&shared_id).then_some(audience))
}
/// Whether `token` is a *public* share link covering `job_id`. This is the only thing
/// that lets a logged-out caller read a job that was not itself run anonymously.
async fn public_view_token_grants(
db: &DB,
w_id: &str,
job_id: &Uuid,
token: Option<&str>,
) -> error::Result<bool> {
let Some(token) = token else {
return Ok(false);
};
Ok(validate_view_token(db, w_id, job_id, token).await? == Some(ViewTokenAudience::Public))
}
/// Read gate for logged-out callers, the counterpart of [`require_job_read_access`]: a job
/// run anonymously is public by construction, and a public share link publishes any other.
async fn require_unauthed_job_read_access(
db: &DB,
w_id: &str,
job_id: &Uuid,
created_by: &str,
view_token: Option<&str>,
) -> error::Result<()> {
if created_by == "anonymous" || public_view_token_grants(db, w_id, job_id, view_token).await? {
return Ok(());
}
Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
))
}
/// The read gate of every handler that serves both audiences (the `jobs_u` router):
/// [`require_job_read_access`] when logged in, [`require_unauthed_job_read_access`] when not.
async fn require_opt_authed_job_read_access(
db: &DB,
user_db: &UserDB,
opt_authed: &Option<ApiAuthed>,
w_id: &str,
job_id: &Uuid,
created_by: &str,
view_token: Option<&str>,
) -> error::Result<()> {
match opt_authed {
Some(authed) => {
require_job_read_access(db, user_db, authed, w_id, job_id, created_by, view_token).await
}
None => require_unauthed_job_read_access(db, w_id, job_id, created_by, view_token).await,
}
}
lazy_static::lazy_static! {
@@ -1747,7 +1878,12 @@ async fn get_job(
false
};
let mut get = GetQuery::new().with_in_tags(tags.as_ref());
let public_view_grant = opt_authed.is_none()
&& public_view_token_grants(&db, &w_id, &id, view_token.as_deref()).await?;
let mut get = GetQuery::new()
.with_in_tags(tags.as_ref())
.with_public_view_grant(public_view_grant);
if !has_valid_approval_token {
get = get.with_auth(&opt_authed);
}
@@ -1762,21 +1898,21 @@ 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?;
}
// A valid approval token is itself the capability; otherwise the caller must pass the
// same visibility as `jobs/list` (see `require_job_read_access`), or hold a public
// share link when logged out — which `public_view_grant` already established above,
// so skip re-deriving it here: this handler is what the public run page polls.
if !has_valid_approval_token && !public_view_grant {
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
job.created_by(),
view_token.as_deref(),
)
.await?;
}
log_job_view(
@@ -1873,6 +2009,10 @@ struct GetQuery<'a> {
with_flow: bool,
with_auth: Option<&'a Option<ApiAuthed>>,
with_in_tags: Option<&'a Vec<&'a str>>,
/// A public share link covering this job was presented: lifts the logged-out
/// restriction in [`Self::check_auth`], the way a view token lifts the ACL check
/// in `require_job_read_access` for a member.
with_public_view_grant: bool,
}
impl<'a> GetQuery<'a> {
@@ -1883,6 +2023,7 @@ impl<'a> GetQuery<'a> {
with_flow: true,
with_auth: None,
with_in_tags: None,
with_public_view_grant: false,
}
}
@@ -1907,9 +2048,16 @@ impl<'a> GetQuery<'a> {
Self { with_in_tags: in_tags, ..self }
}
fn with_public_view_grant(self, granted: bool) -> Self {
Self { with_public_view_grant: granted, ..self }
}
fn check_auth(&self, email: Option<&str>) -> error::Result<()> {
if let Some(email) = email {
if self.with_auth.is_some_and(|x| x.is_none()) && email != "anonymous" {
if self.with_auth.is_some_and(|x| x.is_none())
&& email != "anonymous"
&& !self.with_public_view_grant
{
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
@@ -2391,22 +2539,16 @@ async fn get_completed_job_logs_tail(
.await?;
if let Some(record) = record {
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(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
let logs = record.logs.unwrap_or_default();
Ok(Json(logs))
@@ -2455,22 +2597,16 @@ async fn get_job_logs(
.await?;
if let Some(record) = record {
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(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
let logs = record.logs.unwrap_or_default();
log_job_view(
@@ -2524,10 +2660,15 @@ async fn get_job_logs(
.await?;
let text = not_found_if_none(text, "Job Logs", id.to_string())?;
if opt_authed.is_none() && text.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
if opt_authed.is_none() {
require_unauthed_job_read_access(
&db,
&w_id,
&id,
&text.created_by,
view_token.as_deref(),
)
.await?;
}
let logs = text.logs.unwrap_or_default();
@@ -2631,22 +2772,16 @@ async fn authorize_flow_tree_read(
let root_job = not_found_if_none(root_job, "Job", id.to_string())?;
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(),
));
}
require_opt_authed_job_read_access(
db,
user_db,
&opt_authed,
w_id,
&id,
&root_job.created_by,
view_token.as_deref(),
)
.await?;
log_job_view(
db,
@@ -3627,22 +3762,16 @@ async fn get_args(
.await?;
if let Some(record) = record {
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(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
log_job_view(
&db,
@@ -3666,22 +3795,16 @@ async fn get_args(
.fetch_optional(&db)
.await?;
let record = not_found_if_none(record, "Job Args", id.to_string())?;
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(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
log_job_view(
&db,
@@ -4235,7 +4358,9 @@ pub async fn resume_suspended_flow_as_owner(
// --- New approval system endpoints ---
use windmill_common::variables::{generate_approval_token, generate_view_token};
use windmill_common::variables::{
generate_approval_token, generate_view_token, PUBLIC_VIEW_TOKEN_DOMAIN, VIEW_TOKEN_DOMAIN,
};
/// Verify an approval token against the workspace key + job_id.
async fn validate_approval_token(
@@ -4700,7 +4825,7 @@ async fn get_approval_info(
// Possession of view rights over this approval is sufficient to mint a
// share-read-link token for the flow: it only grants read (no resume), and only to
// an authenticated workspace member, so it never widens what the approver can do.
let hmac = generate_view_token(&w_id, row.id, &db).await?;
let hmac = generate_view_token(&w_id, row.id, VIEW_TOKEN_DOMAIN, &db).await?;
let view_token = Some(format!("{}.{hmac}", row.id));
Ok(Json(ApprovalInfo {
@@ -5213,7 +5338,7 @@ pub async fn get_suspended_job_flow(
// Possession of a valid approval secret is sufficient to mint a share-read-link
// token for the parent flow: it only grants read (no resume), and only to an
// authenticated workspace member, so it never widens what the approver can do.
let hmac = generate_view_token(&w_id, flow_id, &db).await?;
let hmac = generate_view_token(&w_id, flow_id, VIEW_TOKEN_DOMAIN, &db).await?;
let view_token = Some(format!("{flow_id}.{hmac}"));
Ok(Json(SuspendedJobFlow { job: flow, approvers, view_token }).into_response())
@@ -7632,6 +7757,7 @@ pub async fn stream_job(
poll_delay_ms,
early_return,
has_failure_module,
false,
);
let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok));
@@ -9331,8 +9457,7 @@ async fn get_log_file(
}
// Authorization: the log file directory is the job id, so gate access the same
// way as get_job_logs — the caller must be able to read the job. Non-logged-in
// callers may only read logs of jobs created by the anonymous user.
// way as get_job_logs — the caller must be able to read the job.
let tags = opt_authed
.as_ref()
.map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec()))
@@ -9346,22 +9471,16 @@ async fn get_log_file(
.fetch_optional(&db)
.await?
.ok_or_else(|| error::Error::NotFound(format!("Job {job_id} not found")))?;
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(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&job_id,
&created_by,
view_token.as_deref(),
)
.await?;
let local_file = format!("{}/logs/{file_p}", *WINDMILL_DIR);
// SECURITY (defense in depth): refuse to read through a symlink so a planted
@@ -9455,6 +9574,10 @@ async fn get_job_update(
)
.await?;
}
// A public share link authorizes a logged-out read of this job, seeding the latch
// that would otherwise confine it to anonymously-run jobs.
let mut unauthed_read_authorized = opt_authed.is_none()
&& public_view_token_grants(&db, &w_id, &job_id, view_token.as_deref()).await?;
Ok(Json(
get_job_update_data(
&opt_authed,
@@ -9475,7 +9598,7 @@ async fn get_job_update(
None,
false,
&mut false,
&mut false,
&mut unauthed_read_authorized,
)
.await?,
))
@@ -9501,7 +9624,7 @@ async fn get_job_update_sse(
}): 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.
// mirroring the per-stream `unauthed_read_authorized` latch in the streaming loop.
if let Some(authed) = opt_authed.as_ref() {
require_job_update_read_access(
&db,
@@ -9513,6 +9636,8 @@ async fn get_job_update_sse(
)
.await?;
}
let unauthed_read_authorized = opt_authed.is_none()
&& public_view_token_grants(&db, &w_id, &job_id, view_token.as_deref()).await?;
let (tx, rx) = tokio::sync::mpsc::channel(32);
@@ -9534,6 +9659,7 @@ async fn get_job_update_sse(
poll_delay_ms,
None,
false,
unauthed_read_authorized,
);
let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| {
@@ -9573,6 +9699,9 @@ pub fn start_job_update_sse_stream(
poll_delay_ms: Option<u64>,
early_return: Option<String>,
has_failure_module: bool,
// Seeds the per-stream latch below: set when the caller is logged out but presented
// a public share link for this job, which authorizes the whole stream up front.
unauthed_read_authorized: bool,
) -> () {
tokio::spawn(async move {
let mut log_offset = initial_log_offset;
@@ -9582,10 +9711,10 @@ pub fn start_job_update_sse_stream(
// Latched once the early_return node's failure is observed alongside a
// failure_module — subsequent polls then skip the redundant per-node lookup.
let mut early_return_suppressed = false;
// Latched once we've verified the job was created by "anonymous" — for
// Latched once a logged-out caller's read of this job has been authorized — for
// unauthenticated SSE streams, this gates access and is checked once per
// stream rather than once per poll (created_by cannot change).
let mut anonymous_verified = false;
let mut unauthed_read_authorized = unauthed_read_authorized;
// Send initial update immediately
let mut running = running;
@@ -9611,7 +9740,7 @@ pub fn start_job_update_sse_stream(
early_return.as_deref(),
has_failure_module,
&mut early_return_suppressed,
&mut anonymous_verified,
&mut unauthed_read_authorized,
)
.await
{
@@ -9734,7 +9863,7 @@ pub fn start_job_update_sse_stream(
early_return.as_deref(),
has_failure_module,
&mut early_return_suppressed,
&mut anonymous_verified,
&mut unauthed_read_authorized,
)
.await
{
@@ -9888,7 +10017,9 @@ async fn get_job_update_data(
early_return: Option<&str>,
has_failure_module: bool,
early_return_suppressed: &mut bool,
anonymous_verified: &mut bool,
// Latched gate for logged-out callers: once true, this job's updates are readable
// without a session (job run anonymously, or a public share link presented).
unauthed_read_authorized: &mut bool,
) -> error::Result<JobUpdate> {
let tags = if log_view {
log_job_view(
@@ -9910,14 +10041,13 @@ async fn get_job_update_data(
let ignore_flow_stream_job_id = is_flow.is_some_and(|x| !x) || flow_stream_job_id.is_some();
if only_result.unwrap_or(false) {
// Unauthenticated callers may only read jobs whose creator is "anonymous".
// Unauthenticated callers are confined to jobs they may read logged out.
// The non-only_result branch enforces this via `record.created_by` from its
// main query, but the only_result branch below fetches solely the result by
// (workspace_id, job_id), so we guard here to close the gap. The
// `anonymous_verified` flag is preserved across SSE poll iterations so the
// lookup only happens once per stream — `created_by` cannot change for a
// given job once it has been created.
if opt_authed.is_none() && !*anonymous_verified {
// (workspace_id, job_id), so we guard here to close the gap. The latch is
// preserved across SSE poll iterations so the lookup only happens once per
// stream — `created_by` cannot change for a given job once it has been created.
if opt_authed.is_none() && !*unauthed_read_authorized {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
@@ -9933,7 +10063,7 @@ async fn get_job_update_data(
.to_string(),
));
}
*anonymous_verified = true;
*unauthed_read_authorized = true;
}
let (result, running, mut result_stream, mut new_stream_offset, new_flow_stream_job_id) =
@@ -10174,15 +10304,22 @@ async fn get_job_update_data(
.await?
.ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?;
if opt_authed.is_none() && 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(),
));
if opt_authed.is_none() && !*unauthed_read_authorized {
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(),
));
}
*unauthed_read_authorized = true;
}
let job = if record.completed.unwrap_or(false) && get_full_job_on_completion {
let get = GetQuery::new()
.with_auth(&opt_authed)
// Already authorized above, latch included — don't re-derive it from
// `created_by` here or a public share link would lose the full job.
.with_public_view_grant(*unauthed_read_authorized)
.without_logs()
.without_code();
Some(get.fetch(&db, job_id, &w_id).await?)
@@ -10308,19 +10445,24 @@ async fn get_completed_job<'a>(
.map(|authed| get_scope_tags(authed))
.flatten();
let public_view_grant = opt_authed.is_none()
&& public_view_token_grants(&db, &w_id, &id, view_token.as_deref()).await?;
let job_o = GetQuery::new()
.with_auth(&opt_authed)
.with_in_tags(tags.as_ref())
.with_public_view_grant(public_view_grant)
.fetch_completed(&db, &id, &w_id)
.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(
// `public_view_grant` already settled the logged-out case above — don't re-derive it.
if !public_view_grant {
require_opt_authed_job_read_access(
&db,
&user_db,
authed,
&opt_authed,
&w_id,
&id,
&cj.created_by,
@@ -10456,22 +10598,16 @@ async fn get_completed_job_result(
};
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(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
}
format_result(
@@ -10573,22 +10709,16 @@ 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 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(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&res.created_by,
view_token.as_deref(),
)
.await?;
log_job_view(
&db,
@@ -10619,23 +10749,16 @@ async fn get_completed_job_result_maybe(
.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(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
}
let started = sqlx::query_scalar!(
"SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2",
@@ -10709,9 +10832,8 @@ async fn get_dispatch_events(
// Gate on the producer job's visibility, exactly like
// get_completed_job_timing on the same unauthed router: scope tags
// first, then per-job read access for authed users, anonymous-only
// jobs otherwise. The dispatch_event FK to v2_job(id) guarantees the
// producer row exists for any extant event.
// first, then the shared per-audience read gate. The dispatch_event FK
// to v2_job(id) guarantees the producer row exists for any extant event.
let producer = sqlx::query!(
r#"SELECT created_by AS "created_by!"
FROM v2_job
@@ -10724,22 +10846,16 @@ async fn get_dispatch_events(
.await?;
let producer = not_found_if_none(producer, "Job", id.to_string())?;
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&producer.created_by,
view_token.as_deref(),
)
.await?;
} else if producer.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&producer.created_by,
view_token.as_deref(),
)
.await?;
let rows = sqlx::query!(
r#"SELECT
@@ -10892,22 +11008,16 @@ async fn get_completed_job_timing(
let result = not_found_if_none(result, "Completed Job", id.to_string())?;
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(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&result.created_by,
view_token.as_deref(),
)
.await?;
Ok(Json(JobTiming {
created_at: result.created_at,
@@ -11202,23 +11312,16 @@ async fn get_otel_traces(
match job {
Some(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(),
));
}
require_opt_authed_job_read_access(
&db,
&user_db,
&opt_authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
}
None => {
return Err(Error::NotFound(format!("Job {} not found", id)));
@@ -579,6 +579,7 @@ async fn route_job(
None,
early_return,
has_failure_module,
false,
);
let body = axum::body::Body::from_stream(
+15 -4
View File
@@ -192,14 +192,25 @@ 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")`.
/// Domain separator of the member-audience view token (see [`generate_view_token`]).
pub const VIEW_TOKEN_DOMAIN: &[u8] = b"view_token";
/// Domain separator of the public-audience view token (see [`generate_view_token`]).
/// Distinct from [`VIEW_TOKEN_DOMAIN`] so that already-shared member links keep granting
/// only what they were minted for: going public is always an explicit new mint.
pub const PUBLIC_VIEW_TOKEN_DOMAIN: &[u8] = b"public_view_token";
/// Stateless read-share signature for a job: `HMAC(workspace_key, job_id || domain)`.
/// 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.
/// grants read access to a job (and its flow subtree) to someone who otherwise lacks ACL
/// on it: an authenticated workspace member under [`VIEW_TOKEN_DOMAIN`], anyone holding
/// the link (logged in or not) under [`PUBLIC_VIEW_TOKEN_DOMAIN`]. No expiry/revocation
/// (stateless), like the approval token.
pub async fn generate_view_token(
w_id: &str,
job_id: uuid::Uuid,
domain: &[u8],
db: &DB,
) -> crate::error::Result<String> {
use hmac::{Hmac, Mac};
@@ -208,7 +219,7 @@ pub async fn generate_view_token(
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");
mac.update(domain);
Ok(hex::encode(mac.finalize().into_bytes()))
}
@@ -70,6 +70,7 @@ bitflags::bitflags! {
const DISABLE_WORKSPACE_FORKING = 1 << 1;
const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2;
const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3;
const RESTRICT_PUBLIC_RUN_SHARING = 1 << 4;
}
}
@@ -81,6 +82,7 @@ pub enum ProtectionRuleKind {
DisableWorkspaceForking,
RestrictDeployToDeployers,
RestrictAnonymousAppDeployment,
RestrictPublicRunSharing,
}
impl ProtectionRuleKind {
@@ -98,6 +100,9 @@ impl ProtectionRuleKind {
ProtectionRuleKind::RestrictAnonymousAppDeployment => {
ProtectionRules::RESTRICT_ANONYMOUS_APP_DEPLOYMENT
}
ProtectionRuleKind::RestrictPublicRunSharing => {
ProtectionRules::RESTRICT_PUBLIC_RUN_SHARING
}
}
}
@@ -113,6 +118,9 @@ impl ProtectionRuleKind {
ProtectionRuleKind::RestrictAnonymousAppDeployment => {
"Making an app publicly accessible without login (anonymous execution mode) is restricted in this workspace"
}
ProtectionRuleKind::RestrictPublicRunSharing => {
"Sharing a run publicly (readable without login) is restricted in this workspace"
}
}
}
}
@@ -198,6 +198,7 @@
JOBS_FORCE_CANCEL: 'jobs.force_cancel',
JOBS_DISAPPROVAL: 'jobs.disapproval',
JOBS_DELETE: 'jobs.delete',
JOBS_SHARE_PUBLICLY: 'jobs.share_publicly',
ACCOUNT_DELETE: 'account.delete',
AI_REQUEST: 'ai.request',
RESOURCES_CREATE: 'resources.create',
@@ -549,7 +549,25 @@
triggerContext?.simplifiedPoll.set(detail)
},
expandSubflow: async (id: string, path: string) => {
const flow = await FlowService.getFlowByPath({ workspace: workspace, path })
// Reads the subflow's *current* definition, which a share link deliberately does
// not cover: it authorizes the run's job subtree, not the workspace's flow
// library. So a share-link viewer (and any anonymous one) is refused here — name
// that case, but only when the error actually says so.
let flow: OpenFlow
try {
flow = await FlowService.getFlowByPath({ workspace: workspace, path })
} catch (err) {
const denied = err?.status === 401 || err?.status === 403
sendUserToast(
`Could not expand subflow ${path}: ${
denied
? "viewing a subflow's definition requires being logged in with access to it"
: (err?.body ?? err)
}`,
true
)
return
}
expandedSubflows[id] = { modules: flow.value.modules, groups: flow.value.groups }
expandedSubflows = expandedSubflows
},
@@ -42,6 +42,7 @@
let disableFork = $state(hasRule('DisableWorkspaceForking'))
let restrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers'))
let restrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment'))
let restrictPublicRunSharing = $state(hasRule('RestrictPublicRunSharing'))
let selectedGroups = $state<string[]>(
untrack(() => rule)?.bypass_groups?.map((g) => g.replace('g/', '')) ?? []
)
@@ -55,6 +56,7 @@
let initialDisableFork = $state(hasRule('DisableWorkspaceForking'))
let initialRestrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers'))
let initialRestrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment'))
let initialRestrictPublicRunSharing = $state(hasRule('RestrictPublicRunSharing'))
let initialSelectedGroups = $state<string[]>(
untrack(() => rule)?.bypass_groups
? untrack(() => rule)!.bypass_groups.map((g) => g.replace('g/', ''))
@@ -122,6 +124,7 @@
disableFork ||
restrictDeployToDeployers ||
restrictAnonymousAppDeployment ||
restrictPublicRunSharing ||
selectedGroups.length > 0 ||
selectedUsers.length > 0
: name !== initialName ||
@@ -129,6 +132,7 @@
disableFork !== initialDisableFork ||
restrictDeployToDeployers !== initialRestrictDeployToDeployers ||
restrictAnonymousAppDeployment !== initialRestrictAnonymousAppDeployment ||
restrictPublicRunSharing !== initialRestrictPublicRunSharing ||
JSON.stringify([...selectedGroups].sort()) !==
JSON.stringify([...initialSelectedGroups].sort()) ||
JSON.stringify([...selectedUsers].sort()) !==
@@ -171,7 +175,8 @@
: []),
...(restrictAnonymousAppDeployment
? ['RestrictAnonymousAppDeployment' as ProtectionRuleKind]
: [])
: []),
...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : [])
],
bypass_groups: selectedGroups,
bypass_users: selectedUsers
@@ -203,7 +208,8 @@
: []),
...(restrictAnonymousAppDeployment
? ['RestrictAnonymousAppDeployment' as ProtectionRuleKind]
: [])
: []),
...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : [])
],
bypass_groups: selectedGroups,
bypass_users: selectedUsers
@@ -218,6 +224,7 @@
initialDisableFork = disableFork
initialRestrictDeployToDeployers = restrictDeployToDeployers
initialRestrictAnonymousAppDeployment = restrictAnonymousAppDeployment
initialRestrictPublicRunSharing = restrictPublicRunSharing
initialSelectedGroups = clone(selectedGroups)
initialSelectedUsers = clone(selectedUsers)
@@ -371,6 +378,20 @@
(anonymous execution mode). Apps that are already public can still be redeployed.
</div>
</div>
<!-- Restrict public run sharing -->
<div class="flex flex-col gap-2">
<Toggle
bind:checked={restrictPublicRunSharing}
options={{
right: 'Restrict public run sharing'
}}
/>
<div class="text-xs text-secondary ml-6">
Only workspace admins and bypass users can mint a public link to a run (readable without
login). The read-only link for workspace members stays available to everyone.
</div>
</div>
</div>
</Section>
@@ -38,7 +38,9 @@
ClipboardCopy,
GitBranch,
EllipsisVertical,
Share2
Share2,
Globe,
Users
} from 'lucide-svelte'
import { isJobResolvable } from '$lib/utils'
@@ -102,7 +104,11 @@
import FlowRestartButton from '$lib/components/FlowRestartButton.svelte'
import { useNestedRestartState } from '$lib/components/useNestedRestartState.svelte'
import JobOtelTraces from '$lib/components/JobOtelTraces.svelte'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import {
canUserBypassRuleKind,
isRuleActive,
protectionRulesState
} from '$lib/workspaceProtectionRules.svelte'
import {
buildForkEditUrl,
editInForkAllowed,
@@ -209,6 +215,22 @@
}
}
async function sharePublicLink(id: string): Promise<void> {
try {
const workspace = $workspaceStore!
const token = (await JobService.getJobPublicViewToken({ workspace, id })).trim()
// The workspace is a path segment here: the public run page is outside the
// logged layout and has no workspace store to fall back on.
const url = `${window.location.origin}${base}/public_run/${encodeURIComponent(
workspace
)}/${id}?view_token=${encodeURIComponent(token)}`
copyToClipboard(url)
sendUserToast('Public link copied to clipboard — anyone with it can view this run')
} catch (e) {
sendUserToast(`Failed to create public link: ${e}`, true)
}
}
async function deleteCompletedJob(id: string): Promise<void> {
await JobService.deleteCompletedJob({ workspace: $workspaceStore!, id })
getJob()
@@ -540,6 +562,15 @@
let showEditButton = $derived(!isRuleActive('DisableDirectDeployment'))
// Admins always pass the backend gate. Everyone else fails closed while the rulesets
// are still loading, so the item is never briefly offered to a restricted user.
let canSharePublicly = $derived(
!!$userStore?.is_admin ||
!!$userStore?.is_super_admin ||
(protectionRulesState.rulesets !== undefined &&
canUserBypassRuleKind('RestrictPublicRunSharing', $userStore ?? undefined))
)
$effect(() => {
job?.id && lastJobId !== job.id && untrack(() => getConcurrencyKey(job))
})
@@ -718,15 +749,33 @@
{/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)}
<Dropdown
customWidth={280}
items={[
{
displayName: 'Copy link for members',
icon: Users,
tooltip:
'Read-only link to this run for another member of this workspace. They must be logged in.',
action: () => job && shareReadLink(job.id)
},
{
displayName: 'Copy public link',
icon: Globe,
disabled: !canSharePublicly,
tooltip: canSharePublicly
? "Read-only link that anyone on the internet can open, without logging in. It shows a minimal version of this page: this run's inputs, result and logs, and for a flow its graph plus every step's inputs, result, logs and code. The link cannot be revoked."
: 'Sharing a run publicly is restricted in this workspace. Ask an admin to share it, or to grant you a bypass on the ruleset.',
action: () => job && sharePublicLink(job.id)
}
]}
>
Share
</Button>
{#snippet buttonReplacement()}
<Button nonCaptureEvent variant="default" unifiedSize="md" startIcon={{ icon: Share2 }}>
Share
</Button>
{/snippet}
</Dropdown>
{/if}
{@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`}
{@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`}
@@ -897,7 +946,9 @@
<Button
href={buildForkEditUrl(isScript ? 'script' : 'flow', job?.script_path ?? '')}
onClick={(e) =>
onEditInForkClick(e, isScript ? 'script' : 'flow', job?.script_path ?? '', { hasHref: true })}
onEditInForkClick(e, isScript ? 'script' : 'flow', job?.script_path ?? '', {
hasHref: true
})}
unifiedSize="md"
variant="default"
size="sm"
@@ -0,0 +1,193 @@
<script lang="ts">
import { page } from '$app/state'
import { onDestroy, untrack } from 'svelte'
import { Globe } from 'lucide-svelte'
import type { Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { setViewToken } from '$lib/viewToken'
import { setLicense } from '$lib/enterpriseUtils'
import { applyDarkModeVariant } from '$lib/darkModeVariant'
import { displayDate, isNotFlow, truncateRev } from '$lib/utils'
import { Alert, Badge, Skeleton } from '$lib/components/common'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import FlowStatusViewer from '$lib/components/FlowStatusViewer.svelte'
import JobArgs from '$lib/components/JobArgs.svelte'
import JobLoader from '$lib/components/JobLoader.svelte'
import JobStatus from '$lib/components/JobStatus.svelte'
import LogViewer from '$lib/components/LogViewer.svelte'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
// SvelteKit reuses this component across /public_run/* navigations, so every input
// derived from the URL has to stay reactive — a captured-once value would leave the
// previous run (and its token) live while the address bar shows another link.
let workspace = $derived(page.params.workspace ?? '')
let jobId = $derived(page.params.id ?? '')
let viewToken = $derived(page.url.searchParams.get('view_token') ?? undefined)
// Identity of what is on screen. The token is part of it: the same run reached with a
// different credential is a different view, and a completed job makes no further
// request that would reject a now-invalid one.
let viewKey = $derived(`${workspace}|${jobId}|${viewToken ?? ''}`)
// The public share token is the page's only credential: install it before JobLoader
// mounts so every read it fires (job, args, logs, SSE, flow steps) carries it. Set
// eagerly at init, then kept in sync for client-side navigation.
setViewToken(viewToken)
$effect(() => {
setViewToken(viewToken)
})
onDestroy(() => setViewToken(undefined))
$effect(() => {
$workspaceStore = workspace
})
const darkMode =
window.localStorage.getItem('dark-mode') ??
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
document.documentElement.classList.toggle('dark', darkMode === 'dark')
applyDarkModeVariant()
setLicense()
let job: (Job & { result?: any; result_stream?: string }) | undefined = $state()
let notfound = $state(false)
let loadError: { status?: number; message?: string } | undefined = $state(undefined)
let jobLoader: JobLoader | undefined = $state(undefined)
let isFlow = $derived(job != undefined && !isNotFlow(job?.job_kind))
// Clear the previous run first: `isFlow` then falls back to false, which remounts the
// JobLoader a flow run had unmounted, so the effect below has a loader to watch with.
let resetForViewKey: string | undefined = undefined
$effect(() => {
const key = viewKey
untrack(() => {
if (resetForViewKey === key) return
resetForViewKey = key
job = undefined
notfound = false
loadError = undefined
})
})
// `watchJob` writes `job`, so it must not run tracked — reading its own output back
// would re-enter this effect on every poll.
let watchedViewKey: string | undefined = undefined
$effect(() => {
const key = viewKey
const id = jobId
const loader = jobLoader
untrack(() => {
if (!loader || !id || watchedViewKey === key) return
watchedViewKey = key
loader.watchJob(id)
})
})
</script>
<svelte:head>
<title>Run {truncateRev(jobId, 8)} | Windmill</title>
<!-- The URL *is* the credential: keep the link out of search indexes, and out of the
Referer header of anything the rendered result or logs may link to. -->
<meta name="robots" content="noindex, nofollow" />
<meta name="referrer" content="no-referrer" />
</svelte:head>
<!-- Flow jobs are watched by FlowStatusViewer's own loader, exactly like the run page. -->
{#if !isFlow}
<JobLoader
bind:this={jobLoader}
bind:job
bind:notfound
bind:loadError
workspaceOverride={workspace}
/>
{/if}
<div class="min-h-screen bg-surface">
<div class="border-b bg-surface-secondary">
<div class="max-w-7xl mx-auto w-full px-4 py-3 flex items-center gap-3">
<WindmillIcon height="20px" width="20px" />
<span class="text-sm font-semibold text-primary">Run {truncateRev(jobId, 8)}</span>
<Badge color="blue" small>
<span class="flex items-center gap-1"><Globe size={12} /> Public read-only view</span>
</Badge>
</div>
</div>
<div class="max-w-7xl mx-auto w-full px-4 py-6 flex flex-col gap-6">
{#if loadError || notfound}
<Alert type="error" title="This run is not available">
{#if loadError?.status === 403 || loadError?.status === 400}
This link is not valid for this run, or public sharing of it was never enabled.
{:else}
Run {jobId} was not found in {workspace}.
{/if}
</Alert>
{:else if !job}
<Skeleton layout={[[3], 1, [8]]} />
{:else}
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<JobStatus {job} />
{#if job.script_path}
<Badge color="gray">{job.script_path}</Badge>
{/if}
{#if job.language}
<Badge color="gray">{job.language}</Badge>
{/if}
</div>
<div class="text-xs text-secondary">
Started {job.started_at ? displayDate(job.started_at) : 'not yet'}
</div>
</div>
<div>
<div class="text-xs text-emphasis font-semibold mb-1">Inputs</div>
<JobArgs workspace={job.workspace_id ?? workspace} id={job.id} args={job.args} />
</div>
{#if isFlow}
<FlowStatusViewer
jobId={job.id}
initialJob={job}
workspaceId={workspace}
onJobsLoaded={({ job: newJob }) => (job = newJob)}
onDone={({ job: newJob }) => (job = newJob)}
/>
{:else}
<div>
<div class="text-xs text-emphasis font-semibold mb-1">Result</div>
<div class="border rounded-md bg-surface-tertiary p-4 overflow-auto max-h-screen">
{#if job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined)}
<DisplayResult
workspaceId={job.workspace_id}
result_stream={job.result_stream}
jobId={job.id}
result={job.result}
language={job.language}
isTest={false}
/>
{:else}
<div class="text-secondary text-sm">No output is available yet</div>
{/if}
</div>
</div>
<div>
<div class="text-xs text-emphasis font-semibold mb-1">Logs</div>
<div class="border rounded-md bg-surface-secondary p-2 overflow-auto min-h-[400px]">
<LogViewer
jobId={job.id}
duration={job?.['duration_ms']}
mem={job?.['mem_peak']}
isLoading={job?.['running'] == false}
content={job?.logs}
tag={job?.tag}
/>
</div>
</div>
{/if}
{/if}
</div>
</div>