diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 439db6822f..be3f3722a0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -3829,6 +3829,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" + [[package]] name = "uuid" version = "1.2.1" @@ -4120,6 +4126,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "urlencoding", "windmill-audit", "windmill-common", "windmill-parser", diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 339ee0e77e..6c2f3c1752 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -64,4 +64,5 @@ tokio-util.workspace = true tokio-tar.workspace = true hmac.workspace = true cookie.workspace = true -sha2.workspace = true \ No newline at end of file +sha2.workspace = true +urlencoding.workspace = true diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e1d18fe18f..7018730cba 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2887,6 +2887,43 @@ paths: schema: type: string + /w/{workspace}/jobs/resume_urls/{id}/{resume_id}: + get: + summary: get resume urls given a job_id, resume_id and a nonce to resume a flow + operationId: getResumeUrls + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/JobId" + - name: resume_id + in: path + required: true + schema: + type: integer + - name: approver + in: query + schema: + type: string + responses: + "200": + description: url endpoints + content: + application/json: + schema: + type: object + properties: + approvalPage: + type: string + resume: + type: string + cancel: + type: string + required: + - approvalPage + - resume + - cancel + /w/{workspace}/jobs/resume/{id}/{resume_id}/{signature}: get: summary: resume a job for a suspended flow diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 1ddbe225d7..1b9c8b6352 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -6,6 +6,8 @@ * LICENSE-AGPL for a copy of the license. */ +use std::sync::Arc; + use anyhow::Context; use axum::{ extract::{FromRequest, Path, Query}, @@ -18,6 +20,7 @@ use hyper::StatusCode; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sql_builder::{prelude::*, quote, SqlBuilder}; use sqlx::{query_scalar, types::Uuid, Postgres, Transaction}; +use urlencoding::encode; use windmill_audit::{audit_log, ActionKind}; use windmill_common::{ error::{self, to_anyhow, Error}, @@ -34,6 +37,7 @@ use crate::{ db::{UserDB, DB}, users::Authed, variables::get_workspace_key, + BaseUrl, }; pub fn workspaced_service() -> Router { @@ -64,6 +68,7 @@ pub fn workspaced_service() -> Router { "/job_signature/:job_id/:resume_id", get(create_job_signature), ) + .route("/resume_urls/:job_id/:resume_id", get(get_resume_urls)) .route("/result_by_id/:job_id/:node_id", get(get_result_by_id)) } @@ -662,16 +667,74 @@ pub async fn create_job_signature( Query(approver): Query, ) -> error::Result { let key = get_workspace_key(&w_id, &mut user_db.begin(&authed).await?).await?; + create_signature(key, job_id, resume_id, approver.approver) +} + +fn create_signature( + key: String, + job_id: Uuid, + resume_id: u32, + approver: Option, +) -> Result { let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?; mac.update(job_id.as_bytes()); mac.update(resume_id.to_be_bytes().as_ref()); - tracing::info!("approver: {:?}", approver.approver); - if let Some(approver) = approver.approver { + if let Some(approver) = approver { mac.update(approver.as_bytes()); } Ok(hex::encode(mac.finalize().into_bytes())) } +#[allow(non_snake_case)] +#[derive(Serialize)] +pub struct ResumeUrls { + approvalPage: String, + cancel: String, + resume: String, +} + +fn build_resume_url( + op: &str, + w_id: &str, + job_id: &Uuid, + resume_id: &u32, + signature: &str, + approver: &str, + base_url: &str, +) -> String { + format!("{base_url}/api/w/{w_id}/jobs/{op}/{job_id}/{resume_id}/{signature}{approver}") +} + +pub async fn get_resume_urls( + authed: Authed, + Extension(user_db): Extension, + Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>, + Query(approver): Query, + Extension(base_url): Extension>, +) -> error::JsonResult { + let key = get_workspace_key(&w_id, &mut user_db.begin(&authed).await?).await?; + let signature = create_signature(key, job_id, resume_id, approver.approver.clone())?; + let base_url = base_url.0.clone(); + let approver = approver + .approver + .as_ref() + .map(|x| format!("?approver={}", encode(x))) + .unwrap_or_else(String::new); + let res = ResumeUrls { + approvalPage: format!( + "{base_url}/approve/{w_id}/{job_id}/{resume_id}/{signature}{approver}" + ), + cancel: build_resume_url( + "cancel", &w_id, &job_id, &resume_id, &signature, &approver, &base_url, + ), + resume: build_resume_url( + "resume", &w_id, &job_id, &resume_id, &signature, &approver, &base_url, + ), + }; + + Ok(Json(res)) +} + #[derive(Serialize, Debug)] #[serde(tag = "type")] pub enum Job { diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index bce87fecb6..bb85b53fca 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -46,7 +46,7 @@ mod workspaces; const GIT_VERSION: &str = git_version!(args = ["--tag", "--always"], fallback = "unknown-version"); -struct BaseUrl(String); +pub struct BaseUrl(String); pub struct IsSecure(bool); pub struct CookieDomain(Option); pub struct CloudHosted(bool); diff --git a/deno-client/mod.ts b/deno-client/mod.ts index 68c1aef66a..175eae591f 100644 --- a/deno-client/mod.ts +++ b/deno-client/mod.ts @@ -1,4 +1,4 @@ -import { ResourceService, VariableService } from './windmill-api/index.ts' +import { JobService, ResourceService, VariableService } from './windmill-api/index.ts' import { OpenAPI } from './windmill-api/index.ts' export { @@ -168,87 +168,30 @@ export async function databaseUrlFromResource(path: string): Promise { return `postgresql://${resource.user}:${resource.password}@${resource.host}:${resource.port}/${resource.dbname}?sslmode=${resource.sslmode}` } - -export interface NonceAndHmac { - nonce: number; - signature: string; -} - /** - * Get HMAC and nonce needed for approval script - * @param workspace workspace name - * @param jobId + * Get URLs needed for resuming a flow after this step * @param approver approver name - * @returns HMAC and nonce needed to authorize approval script actions + * @returns approval page UI URL, resume and cancel API URLs for resumeing the flow */ -export async function genNounceAndHmac(workspace: string, jobId: string, approver?: string): Promise { - const nonce = Math.floor(Math.random() * 4294967295); - const u = new URL( - `/api/w/${workspace}/jobs/job_signature/${jobId}/${nonce}`, - Deno.env.get("WM_BASE_URL"), - ); - - u.searchParams.append('token', Deno.env.get("WM_TOKEN") ?? ''); - if (approver) { - u.searchParams.append('approver', approver); - } - - const sig = await fetch(u.toString()); - return { - nonce, - signature: await sig.text() - }; -} - -export interface ResumeEndpoints { +export async function getResumeUrls(approver?: string): Promise<{ approvalPage: string; resume: string; cancel: string; +}> { + const nonce = Math.floor(Math.random() * 4294967295); + const workspace = getWorkspace() + return await JobService.getResumeUrls({ workspace, resumeId: nonce, approver, id: Deno.env.get("WM_JOB_ID") ?? 'NO_JOB_ID' }) } /** - * Get URLs needed for approval script - * @param approver approver name - * @returns approval page UI URL, resume and cancel API URLs for approval script + * @deprecated use getResumeUrls instead */ -export async function getResumeEndpoints(approver?: string): Promise { - const workspace = getWorkspace() - - const { nonce, signature } = await genNounceAndHmac( - workspace, - Deno.env.get("WM_JOB_ID") ?? "no_job_id", - approver - ); - - function getResumeUrl(op: string): string { - const u = new URL( - `${op}/${Deno.env.get("WM_JOB_ID")}/${nonce}/${signature}`, - Deno.env.get("WM_BASE_URL") + `/api/w/${workspace}/jobs/`, - ); - if (approver) { - u.searchParams.append('approver', approver); - } - - return u.toString(); - } - - function getApprovalPage() { - const u = new URL( - `/approve/${workspace}/${Deno.env.get("WM_JOB_ID")}/${nonce}/${signature}`, - Deno.env.get("WM_BASE_URL"), - ); - if (approver) { - u.searchParams.append('approver', approver); - } - - return u.toString(); - } - - return { - approvalPage: getApprovalPage(), - resume: getResumeUrl("resume"), - cancel: getResumeUrl("cancel"), - }; +export function getResumeEndpoints(approver?: string): Promise<{ + approvalPage: string; + resume: string; + cancel: string; +}> { + return getResumeUrls(approver) } export function base64ToUint8Array(data: string): Uint8Array { diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index 554004aeb9..c31c32c731 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -327,6 +327,24 @@ def get_state_path() -> str: return state_path +def get_resume_urls(approver: str | None = None) -> Dict: + from windmill_api.api.job import get_resume_urls as get_resume_urls_api + + workspace = get_workspace() + client = create_client() + job_id = os.environ.get("WM_JOB_ID") or "NO_ID" + import random + + nonce = random.randint(0, 1000000000) + res = get_resume_urls_api.sync_detailed( + workspace, job_id, nonce, client=client, approver=approver + ) + if res.parsed is not None: + return res.parsed.to_dict() + else: + raise Exception("Failed to get resume urls") + + def _transform_leaves(d: Dict[str, Any]) -> Dict[str, Any]: return {k: _transform_leaf(v) for k, v in d.items()}