mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 00:06:06 +00:00
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:
co-authored by
Claude Opus 5
parent
fdd76a6f13
commit
5ce29b3436
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user