mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
feat: Approval step optionally require logged-in user (#2462)
* backend auth user validation * Approval page suggesting login * BE impl to support groups * Add FE for setting suspend on flow step * handle admin as a specific case * restrict feature to entperprise only * group names as static input transforms * Suspend settings with tabs * Update enterprise edition check * cleanup --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
committed by
GitHub
co-authored by
Ruben Fiszel
parent
3ed29ae2dd
commit
5f5604a2aa
@@ -1,6 +1,6 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
version: 1.183.0
|
||||
version: 1.184.0
|
||||
title: Windmill API
|
||||
contact:
|
||||
name: Windmill Team
|
||||
@@ -3495,6 +3495,11 @@ paths:
|
||||
properties:
|
||||
schema:
|
||||
type: object
|
||||
user_auth_required:
|
||||
type: boolean
|
||||
user_groups_required:
|
||||
oneOf: *ref_23
|
||||
discriminator: *ref_24
|
||||
retry:
|
||||
type: object
|
||||
properties: &ref_173
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use serde_json::value::RawValue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
@@ -40,7 +41,7 @@ use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{self, to_anyhow, Error},
|
||||
flow_status::{Approval, FlowStatus, FlowStatusModule},
|
||||
flows::FlowValue,
|
||||
flows::{FlowValue, InputTransform},
|
||||
jobs::{script_path_to_payload, JobKind, JobPayload, QueuedJob, RawCode},
|
||||
oauth2::HmacSha256,
|
||||
scripts::{Script, ScriptHash, ScriptLang},
|
||||
@@ -342,19 +343,24 @@ async fn get_job(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
) -> error::Result<Response> {
|
||||
let cjob_option = sqlx::query("SELECT
|
||||
let job = get_job_internal(&db, w_id.as_str(), id).await?;
|
||||
Ok(Json(job).into_response())
|
||||
}
|
||||
|
||||
async fn get_job_internal(db: &DB, workspace_id: &str, job_id: Uuid) -> error::Result<Job> {
|
||||
let cjob_maybe = sqlx::query_as::<_, CompletedJob>("SELECT
|
||||
id, workspace_id, parent_job, created_by, created_at, duration_ms, success, script_hash, script_path,
|
||||
CASE WHEN pg_column_size(args) < 2000000 THEN args ELSE '{\"reason\": \"WINDMILL_TOO_BIG\"}'::jsonb END as args, CASE WHEN pg_column_size(result) < 2000000 THEN result ELSE '\"WINDMILL_TOO_BIG\"'::jsonb END as result, logs, deleted, raw_code, canceled, canceled_by, canceled_reason, job_kind, env_id,
|
||||
schedule_path, permissioned_as, flow_status, raw_flow, is_flow_step, language, started_at, is_skipped,
|
||||
raw_lock, email, visible_to_owner, mem_peak, tag
|
||||
FROM completed_job WHERE id = $1 AND workspace_id = $2")
|
||||
.bind(id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if let Some(job) = cjob_option {
|
||||
let job = Job::CompletedJob(CompletedJob::from_row(&job)?);
|
||||
Ok(Json(job).into_response())
|
||||
.bind(job_id)
|
||||
.bind(workspace_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.map(Job::CompletedJob);
|
||||
if let Some(cjob) = cjob_maybe {
|
||||
Ok(cjob)
|
||||
} else {
|
||||
let job_o = sqlx::query_as::<_, QueuedJob>(
|
||||
"SELECT id, workspace_id, parent_job, created_by, created_at, started_at, scheduled_for, running,
|
||||
@@ -364,13 +370,13 @@ async fn get_job(
|
||||
root_job, leaf_jobs, tag, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id, cache_ttl
|
||||
FROM queue WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.bind(job_id)
|
||||
.bind(workspace_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.map(Job::QueuedJob);
|
||||
let job: Job<'_> = not_found_if_none(job_o, "Job", id.to_string())?;
|
||||
Ok(Json(job).into_response())
|
||||
let job: Job = not_found_if_none(job_o, "Job", job_id.to_string())?;
|
||||
Ok(job)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +397,7 @@ async fn get_job_logs(
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Serialize)]
|
||||
pub struct CompletedJob<'rows> {
|
||||
pub struct CompletedJob {
|
||||
pub workspace_id: String,
|
||||
pub id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -405,9 +411,9 @@ pub struct CompletedJob<'rows> {
|
||||
pub script_hash: Option<ScriptHash>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub script_path: Option<String>,
|
||||
pub args: Option<&'rows JsonRawValue>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", borrow)]
|
||||
pub result: Option<&'rows JsonRawValue>,
|
||||
pub args: Option<sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logs: Option<String>,
|
||||
pub deleted: bool,
|
||||
@@ -423,9 +429,9 @@ pub struct CompletedJob<'rows> {
|
||||
pub schedule_path: Option<String>,
|
||||
pub permissioned_as: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flow_status: Option<serde_json::Value>,
|
||||
pub flow_status: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_flow: Option<serde_json::Value>,
|
||||
pub raw_flow: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
pub is_flow_step: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<ScriptLang>,
|
||||
@@ -437,7 +443,7 @@ pub struct CompletedJob<'rows> {
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
impl<'row> CompletedJob<'row> {
|
||||
impl CompletedJob {
|
||||
pub fn json_result(&self) -> Option<serde_json::Value> {
|
||||
self.result
|
||||
.as_ref()
|
||||
@@ -489,7 +495,7 @@ pub struct ListableCompletedJob {
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
impl<'a> IntoResponse for CompletedJob<'a> {
|
||||
impl<'a> IntoResponse for CompletedJob {
|
||||
fn into_response(self) -> Response {
|
||||
Json(self).into_response()
|
||||
}
|
||||
@@ -735,7 +741,7 @@ async fn list_jobs(
|
||||
Path(w_id): Path<String>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListCompletedQuery>,
|
||||
) -> error::JsonResult<Vec<Job<'static>>> {
|
||||
) -> error::JsonResult<Vec<Job>> {
|
||||
check_scopes(&authed, || format!("listjobs"))?;
|
||||
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
@@ -892,7 +898,7 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
}
|
||||
|
||||
pub async fn resume_suspended_job(
|
||||
/* unauthed */
|
||||
authed: Option<ApiAuthed>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job_id, resume_id, secret)): Path<(String, Uuid, u32, String)>,
|
||||
Query(approver): Query<QueryApprover>,
|
||||
@@ -909,7 +915,16 @@ pub async fn resume_suspended_job(
|
||||
}
|
||||
mac.verify_slice(hex::decode(secret)?.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("Invalid signature"))?;
|
||||
let flow = get_suspended_parent_flow_info(job_id, &mut tx).await?;
|
||||
let parent_flow_info = get_suspended_parent_flow_info(job_id, &mut tx).await?;
|
||||
let parent_flow = get_job_internal(&db, w_id.as_str(), parent_flow_info.id).await?;
|
||||
let flow_status = parent_flow
|
||||
.flow_status()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
|
||||
let flow_value = parent_flow
|
||||
.raw_flow()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow value in the flow job"))?;
|
||||
|
||||
conditionally_require_authed_user(authed, job_id, flow_status, flow_value)?;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -925,9 +940,17 @@ pub async fn resume_suspended_job(
|
||||
return Err(anyhow::anyhow!("resume request already sent").into());
|
||||
}
|
||||
|
||||
insert_resume_job(resume_id, job_id, &flow, value, approver.approver, &mut tx).await?;
|
||||
insert_resume_job(
|
||||
resume_id,
|
||||
job_id,
|
||||
&parent_flow_info,
|
||||
value,
|
||||
approver.approver,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
resume_immediately_if_relevant(parent_flow_info, job_id, &mut tx).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
@@ -1053,7 +1076,7 @@ async fn get_suspended_flow_info<'c>(
|
||||
}
|
||||
|
||||
pub async fn cancel_suspended_job(
|
||||
/* unauthed */
|
||||
authed: Option<ApiAuthed>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(rsmq): Extension<Option<rsmq_async::MultiplexedRsmq>>,
|
||||
Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>,
|
||||
@@ -1071,12 +1094,22 @@ pub async fn cancel_suspended_job(
|
||||
.map_err(|_| anyhow::anyhow!("Invalid signature"))?;
|
||||
|
||||
let whom = approver.approver.unwrap_or_else(|| "unknown".to_string());
|
||||
let parent_flow = get_suspended_parent_flow_info(job, &mut tx).await?.id;
|
||||
let parent_flow_id = get_suspended_parent_flow_info(job, &mut tx).await?.id;
|
||||
|
||||
let parent_flow = get_job_internal(&db, w_id.as_str(), parent_flow_id).await?;
|
||||
let flow_status = parent_flow
|
||||
.flow_status()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
|
||||
let flow_value = parent_flow
|
||||
.raw_flow()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow value in the flow job"))?;
|
||||
|
||||
conditionally_require_authed_user(authed, job, flow_status, flow_value)?;
|
||||
|
||||
let (mut tx, cjob) = windmill_queue::cancel_job(
|
||||
&whom,
|
||||
Some("approval request disapproved".to_string()),
|
||||
parent_flow,
|
||||
parent_flow_id,
|
||||
&w_id,
|
||||
tx,
|
||||
&db,
|
||||
@@ -1091,23 +1124,23 @@ pub async fn cancel_suspended_job(
|
||||
"jobs.disapproval",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&parent_flow.to_string()),
|
||||
Some(&parent_flow_id.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Flow {parent_flow} of job {job} cancelled"))
|
||||
Ok(format!("Flow {parent_flow_id} of job {job} cancelled"))
|
||||
} else {
|
||||
Ok(format!(
|
||||
"Flow {parent_flow} of job {job} was not cancellable"
|
||||
"Flow {parent_flow_id} of job {job} was not cancellable"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SuspendedJobFlow<'a> {
|
||||
pub job: Job<'a>,
|
||||
pub struct SuspendedJobFlow {
|
||||
pub job: Job,
|
||||
pub approvers: Vec<Approval>,
|
||||
}
|
||||
|
||||
@@ -1117,7 +1150,7 @@ pub struct QueryApprover {
|
||||
}
|
||||
|
||||
pub async fn get_suspended_job_flow(
|
||||
/* unauthed */
|
||||
authed: Option<ApiAuthed>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>,
|
||||
Query(approver): Query<QueryApprover>,
|
||||
@@ -1132,6 +1165,7 @@ pub async fn get_suspended_job_flow(
|
||||
}
|
||||
mac.verify_slice(hex::decode(secret)?.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("Invalid signature"))?;
|
||||
|
||||
let flow_id = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT parent_job
|
||||
@@ -1149,40 +1183,24 @@ pub async fn get_suspended_job_flow(
|
||||
.await?
|
||||
.flatten()
|
||||
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
|
||||
let cjob_option =
|
||||
sqlx::query("SELECT * FROM completed_job WHERE id = $1 AND workspace_id = $2")
|
||||
.bind(flow_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
let mut _rows = None;
|
||||
let flow_o = if let Some(job) = cjob_option {
|
||||
_rows = Some(job);
|
||||
Some(Job::CompletedJob(CompletedJob::from_row(
|
||||
_rows.as_ref().unwrap(),
|
||||
)?))
|
||||
} else {
|
||||
sqlx::query_as::<_, QueuedJob>(
|
||||
"SELECT *
|
||||
FROM queue WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(flow_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(Job::QueuedJob)
|
||||
};
|
||||
let flow = not_found_if_none(flow_o, "Parent Flow", job.to_string())?;
|
||||
|
||||
let flow = get_job_internal(&db, w_id.as_str(), flow_id).await?;
|
||||
|
||||
let flow_status = flow
|
||||
.flow_status()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to deserialize the flow"))?;
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
|
||||
let flow_value = flow
|
||||
.raw_flow()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow value in the flow job"))?;
|
||||
|
||||
let flow_module_status = flow_status
|
||||
.modules
|
||||
.iter()
|
||||
.find(|p| p.job() == Some(job))
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the module"))?;
|
||||
|
||||
conditionally_require_authed_user(authed, job, flow_status.clone(), flow_value)?;
|
||||
|
||||
let approvers_from_status = match flow_module_status {
|
||||
FlowStatusModule::Success { approvers, .. } => approvers.to_owned(),
|
||||
_ => vec![],
|
||||
@@ -1211,6 +1229,82 @@ pub async fn get_suspended_job_flow(
|
||||
Ok(Json(SuspendedJobFlow { job: flow, approvers }).into_response())
|
||||
}
|
||||
|
||||
fn conditionally_require_authed_user(
|
||||
authed: Option<ApiAuthed>,
|
||||
job: Uuid,
|
||||
flow_status: FlowStatus,
|
||||
flow_value: FlowValue,
|
||||
) -> error::Result<()> {
|
||||
let flow_module_status = flow_status
|
||||
.modules
|
||||
.iter()
|
||||
.find(|p| p.job() == Some(job))
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the module"))?;
|
||||
|
||||
// Check if user is authed and fail if it's required by the flow
|
||||
let raw_flow_module = flow_value
|
||||
.modules
|
||||
.iter()
|
||||
.find(|m| m.id == flow_module_status.id())
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the module in the raw flow"))?;
|
||||
let user_auth_required_for_approval = raw_flow_module
|
||||
.suspend
|
||||
.as_ref()
|
||||
.map(|s| s.user_auth_required)
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
if user_auth_required_for_approval {
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
return Err(Error::BadRequest(
|
||||
"Approvals for logged in users is an enterprise only feature".to_string(),
|
||||
));
|
||||
#[cfg(feature = "enterprise")]
|
||||
if authed.is_none() {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Only logged in users can approve this flow step".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if authed.is_some() && !authed.as_ref().unwrap().username.eq("admin") {
|
||||
let required_groups = raw_flow_module
|
||||
.suspend
|
||||
.as_ref()
|
||||
.map(|s| s.user_groups_required.clone())
|
||||
.flatten();
|
||||
if required_groups.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
match required_groups.unwrap() {
|
||||
InputTransform::Static { value } => {
|
||||
let required_groups_or_empty = serde_json::from_value::<Vec<String>>(value)
|
||||
.expect("Unable to deserialize group names");
|
||||
if !required_groups_or_empty.is_empty() {
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
return Err(Error::BadRequest(
|
||||
"Approvals for users in certain user groups is an enterprise only feature"
|
||||
.to_string(),
|
||||
));
|
||||
#[cfg(feature = "enterprise")]
|
||||
if true {
|
||||
for required_group in required_groups_or_empty.iter() {
|
||||
if authed.as_ref().unwrap().groups.contains(&required_group) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let error_msg = format!("Only users from one of the following groups are allowed to approve this workflow: {}",
|
||||
required_groups_or_empty.join(", "));
|
||||
return Err(Error::PermissionDenied(error_msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
InputTransform::Javascript { expr: _ } => {
|
||||
return Err(Error::InternalErr("Not yet implemented".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_job_signature(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -1289,25 +1383,39 @@ pub async fn get_resume_urls(
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum Job<'a> {
|
||||
pub enum Job {
|
||||
QueuedJob(QueuedJob),
|
||||
CompletedJob(CompletedJob<'a>),
|
||||
CompletedJob(CompletedJob),
|
||||
}
|
||||
|
||||
impl<'a> Job<'a> {
|
||||
impl Job {
|
||||
pub fn raw_flow(&self) -> Option<FlowValue> {
|
||||
let value = match self {
|
||||
Job::QueuedJob(job) => job.raw_flow.clone(),
|
||||
Job::CompletedJob(job) => job.raw_flow.clone(),
|
||||
};
|
||||
value.map(|v| serde_json::from_value(v).ok()).flatten()
|
||||
match self {
|
||||
Job::QueuedJob(job) => job
|
||||
.raw_flow
|
||||
.as_ref()
|
||||
.map(|rf| serde_json::from_str(rf.0.get()).ok())
|
||||
.flatten(),
|
||||
Job::CompletedJob(job) => job
|
||||
.raw_flow
|
||||
.as_ref()
|
||||
.map(|rf| serde_json::from_str(rf.0.get()).ok())
|
||||
.flatten(),
|
||||
}
|
||||
}
|
||||
pub fn flow_status(&self) -> Option<FlowStatus> {
|
||||
let value = match self {
|
||||
Job::QueuedJob(job) => job.flow_status.clone(),
|
||||
Job::CompletedJob(job) => job.flow_status.clone(),
|
||||
};
|
||||
value.map(|v| serde_json::from_value(v).ok()).flatten()
|
||||
match self {
|
||||
Job::QueuedJob(job) => job
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.map(|rf| serde_json::from_str(rf.0.get()).ok())
|
||||
.flatten(),
|
||||
Job::CompletedJob(job) => job
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.map(|rf| serde_json::from_str(rf.0.get()).ok())
|
||||
.flatten(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1344,7 +1452,7 @@ struct UnifiedJob {
|
||||
concurrency_time_window_s: Option<i32>,
|
||||
}
|
||||
|
||||
impl<'a> From<UnifiedJob> for Job<'a> {
|
||||
impl<'a> From<UnifiedJob> for Job {
|
||||
fn from(uj: UnifiedJob) -> Self {
|
||||
match uj.typ.as_ref() {
|
||||
"CompletedJob" => Job::CompletedJob(CompletedJob {
|
||||
|
||||
@@ -37,6 +37,8 @@ pub enum Error {
|
||||
NotFound(String),
|
||||
#[error("Not authorized: {0}")]
|
||||
NotAuthorized(String),
|
||||
#[error("Permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
#[error("Require Admin privileges for {0}")]
|
||||
RequireAdmin(String),
|
||||
#[error("{0}")]
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn is_retry_default(v: &RetryStatus) -> bool {
|
||||
v.fail_count == 0 && v.failed_jobs.is_empty()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct FlowStatus {
|
||||
pub step: i32,
|
||||
pub modules: Vec<FlowStatusModule>,
|
||||
|
||||
@@ -67,6 +67,7 @@ pub struct NewFlow {
|
||||
pub schema: Option<Schema>,
|
||||
pub draft_only: Option<bool>,
|
||||
pub tag: Option<String>,
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
@@ -169,6 +170,10 @@ pub struct Suspend {
|
||||
pub timeout: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resume_form: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_auth_required: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_groups_required: Option<InputTransform>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
|
||||
@@ -65,9 +65,9 @@ pub struct QueuedJob {
|
||||
pub schedule_path: Option<String>,
|
||||
pub permissioned_as: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flow_status: Option<serde_json::Value>,
|
||||
pub flow_status: Option<Json<Box<RawValue>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_flow: Option<serde_json::Value>,
|
||||
pub raw_flow: Option<Json<Box<RawValue>>>,
|
||||
pub is_flow_step: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<ScriptLang>,
|
||||
@@ -127,13 +127,13 @@ impl QueuedJob {
|
||||
pub fn parse_raw_flow(&self) -> Option<FlowValue> {
|
||||
self.raw_flow
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowValue>(v.clone()).ok())
|
||||
.and_then(|v| serde_json::from_str::<FlowValue>((**v).get()).ok())
|
||||
}
|
||||
|
||||
pub fn parse_flow_status(&self) -> Option<FlowStatus> {
|
||||
self.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
|
||||
.and_then(|v| serde_json::from_str::<FlowStatus>((**v).get()).ok())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -343,8 +343,8 @@ pub async fn add_completed_job<
|
||||
queued_job.job_kind.clone() as JobKind,
|
||||
queued_job.schedule_path,
|
||||
queued_job.permissioned_as,
|
||||
queued_job.flow_status,
|
||||
queued_job.raw_flow,
|
||||
&queued_job.flow_status as &Option<Json<Box<RawValue>>>,
|
||||
&queued_job.raw_flow as &Option<Json<Box<RawValue>>>,
|
||||
queued_job.is_flow_step,
|
||||
skipped,
|
||||
queued_job.language.clone() as Option<ScriptLang>,
|
||||
|
||||
@@ -2365,7 +2365,7 @@ async fn handle_flow_dependency_job(
|
||||
"Flow Dependency requires raw flow".to_owned(),
|
||||
))
|
||||
})?;
|
||||
let mut flow = serde_json::from_value::<FlowValue>(raw_flow).map_err(to_anyhow)?;
|
||||
let mut flow = serde_json::from_str::<FlowValue>((*raw_flow.0).get()).map_err(to_anyhow)?;
|
||||
|
||||
flow.modules = lock_modules(
|
||||
flow.modules,
|
||||
|
||||
@@ -990,11 +990,12 @@ pub async fn handle_flow<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InternalErr(format!("requiring a raw flow value")))?
|
||||
.to_owned();
|
||||
let flow = serde_json::from_value::<FlowValue>(value)?;
|
||||
let flow = serde_json::from_str::<FlowValue>((*value.0).get())?;
|
||||
|
||||
let status: FlowStatus =
|
||||
serde_json::from_value::<FlowStatus>(flow_job.flow_status.clone().unwrap_or_default())
|
||||
.with_context(|| format!("parse flow status {}", flow_job.id))?;
|
||||
let status: FlowStatus = serde_json::from_str::<FlowStatus>(
|
||||
(*flow_job.flow_status.clone().unwrap_or_default().0).get(),
|
||||
)
|
||||
.with_context(|| format!("parse flow status {}", flow_job.id))?;
|
||||
|
||||
tracing::debug!("handle_flow: {:#?}", flow_job.flow_status);
|
||||
push_next_flow_job(
|
||||
|
||||
@@ -4,13 +4,42 @@
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { Alert, Tab, Tabs } from '$lib/components/common'
|
||||
import { GroupService, type FlowModule } from '$lib/gen'
|
||||
import { emptySchema, emptyString } from '$lib/utils'
|
||||
import { enterpriseLicense, workspaceStore } from '$lib/stores.js'
|
||||
import { SecondsInput } from '../../common'
|
||||
import Multiselect from 'svelte-multiselect'
|
||||
|
||||
export let flowModule: FlowModule
|
||||
|
||||
export let allUserGroups: string[] = []
|
||||
let selectedUserGroups: string[] | undefined
|
||||
let suspendTabSelected: 'core' | 'form' | 'permissions' = 'core'
|
||||
|
||||
$: isSuspendEnabled = Boolean(flowModule.suspend)
|
||||
|
||||
async function loadGroups(): Promise<void> {
|
||||
allUserGroups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
|
||||
}
|
||||
|
||||
$: {
|
||||
if ($workspaceStore) {
|
||||
loadGroups()
|
||||
}
|
||||
switch (flowModule.suspend?.user_groups_required?.type) {
|
||||
case 'static':
|
||||
if (flowModule.suspend?.user_groups_required.value === undefined) {
|
||||
selectedUserGroups = []
|
||||
} else {
|
||||
selectedUserGroups = flowModule.suspend?.user_groups_required.value
|
||||
}
|
||||
break
|
||||
case 'javascript':
|
||||
console.warn('javascript input transform not supported yet')
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2 class="pb-4">
|
||||
@@ -21,6 +50,7 @@
|
||||
used flexibly for other purpose.
|
||||
</Tooltip>
|
||||
</h2>
|
||||
|
||||
<Toggle
|
||||
checked={isSuspendEnabled}
|
||||
on:change={() => {
|
||||
@@ -37,25 +67,93 @@
|
||||
right: 'Suspend flow execution until events/approvals received'
|
||||
}}
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<span class="text-xs font-bold">Number of approvals/events required for resuming flow</span>
|
||||
|
||||
{#if flowModule.suspend}
|
||||
<input bind:value={flowModule.suspend.required_events} type="number" min="1" placeholder="1" />
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
<div class="overflow-x-auto scrollbar-hidden">
|
||||
<Tabs bind:selected={suspendTabSelected}>
|
||||
<Tab size="xs" value="core" disabled={!isSuspendEnabled}>
|
||||
<div class="flex gap-2 items-center my-1">Core</div>
|
||||
</Tab>
|
||||
<Tab size="xs" value="form" disabled={!isSuspendEnabled}>
|
||||
<div class="flex gap-2 items-center my-1">Form</div>
|
||||
</Tab>
|
||||
<Tab size="xs" value="permissions" disabled={!isSuspendEnabled}>
|
||||
<div class="flex gap-2 items-center my-1">Permissions</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<span class="text-xs font-bold">Timeout</span>
|
||||
{#if suspendTabSelected === 'core'}
|
||||
<div class="flex flex-col mt-4 gap-4">
|
||||
<span class="text-xs font-bold">Number of approvals/events required for resuming flow</span>
|
||||
|
||||
{#if flowModule.suspend}
|
||||
<SecondsInput bind:seconds={flowModule.suspend.timeout} />
|
||||
{:else}
|
||||
<SecondsInput disabled />
|
||||
{/if}
|
||||
{#if flowModule.suspend}
|
||||
<div class="mt-4" />
|
||||
<div class="flex gap-4">
|
||||
{#if flowModule.suspend}
|
||||
<input
|
||||
bind:value={flowModule.suspend.required_events}
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
/>
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
|
||||
<span class="text-xs font-bold">Timeout</span>
|
||||
|
||||
{#if flowModule.suspend}
|
||||
<SecondsInput bind:seconds={flowModule.suspend.timeout} />
|
||||
{:else}
|
||||
<SecondsInput disabled />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if suspendTabSelected === 'permissions'}
|
||||
<div class="flex flex-col mt-4 gap-4">
|
||||
{#if emptyString($enterpriseLicense)}
|
||||
<Alert type="warning" title="Editing permissions is only available in enterprise version" />
|
||||
{/if}
|
||||
{#if flowModule.suspend}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
disabled={emptyString($enterpriseLicense)}
|
||||
checked={Boolean(flowModule.suspend.user_auth_required)}
|
||||
options={{
|
||||
right: 'Require approvers to be logged in'
|
||||
}}
|
||||
on:change={(e) => {
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.user_auth_required = e.detail
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mb-4" />
|
||||
|
||||
<span class="text-xs font-bold"
|
||||
>Require approvers to be members of one of the following user groups (leave empty for any)
|
||||
</span>
|
||||
{#if allUserGroups.length !== 0}
|
||||
<Multiselect
|
||||
disabled={emptyString($enterpriseLicense) || !flowModule.suspend.user_auth_required}
|
||||
on:change={(e) => {
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.user_groups_required = {
|
||||
value: selectedUserGroups,
|
||||
type: 'static'
|
||||
}
|
||||
}
|
||||
}}
|
||||
bind:selected={selectedUserGroups}
|
||||
options={allUserGroups}
|
||||
selectedOptionsDraggable={false}
|
||||
placeholder="Authorized user groups"
|
||||
ulOptionsClass={'!bg-surface-secondary'}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col mt-4 gap-4">
|
||||
{#if flowModule.suspend}
|
||||
<Toggle
|
||||
checked={Boolean(flowModule.suspend.resume_form)}
|
||||
options={{
|
||||
@@ -89,12 +187,10 @@
|
||||
></pre
|
||||
>
|
||||
</Slider></div
|
||||
></div
|
||||
>
|
||||
|
||||
<div class="mt-2" />
|
||||
{/if}
|
||||
{#if flowModule.suspend?.resume_form}
|
||||
<SchemaEditor bind:schema={flowModule.suspend.resume_form.schema} />
|
||||
{/if}
|
||||
</div>
|
||||
>
|
||||
{/if}
|
||||
{#if flowModule.suspend?.resume_form}
|
||||
<SchemaEditor bind:schema={flowModule.suspend.resume_form.schema} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
import FlowGraph from '$lib/components/graph/FlowGraph.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { LogIn, AlertTriangle } from 'lucide-svelte'
|
||||
|
||||
$workspaceStore = $page.params.workspace
|
||||
let rd = $page.url.href.replace($page.url.origin, '')
|
||||
|
||||
let job: Job | undefined = undefined
|
||||
let currentApprovers: { resume_id: number; approver: string }[] = []
|
||||
@@ -114,91 +116,112 @@
|
||||
|
||||
<CenteredModal title="Approval for resuming of flow" disableLogo>
|
||||
{#if error}
|
||||
<p class="text-red-400 text-lg">{error}</p>
|
||||
{/if}
|
||||
<div class="flex flex-row justify-between flex-wrap sm:flex-nowrap gap-x-4">
|
||||
<div class="w-full">
|
||||
<h2 class="mt-4">Current approvers</h2>
|
||||
<p class="text-xs italic"
|
||||
>Each approver can only approve once and cannot change his approver name set by the approval
|
||||
sender</p
|
||||
>
|
||||
<div class="my-4">
|
||||
{#if currentApprovers.length > 0}
|
||||
<ul>
|
||||
{#each currentApprovers as approver}
|
||||
<li
|
||||
><b
|
||||
>{approver.approver}<Tooltip>Unique id of approval: {approver.resume_id}</Tooltip
|
||||
></b
|
||||
></li
|
||||
>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm"
|
||||
>No current approvers for this step (approval steps can require more than one approval)</p
|
||||
>
|
||||
<div class="space-y-6">
|
||||
{#if error.startsWith('Not authorized:')}
|
||||
<div class="flex flex-row gap-4 justify-center">
|
||||
<AlertTriangle />
|
||||
<p class="text-lg">Not Authorized</p>
|
||||
</div>
|
||||
<p class="text-sm">{error.replace(/^(Not authorized: )/, '')}</p>
|
||||
<Button href={`/user/login?${rd ? 'rd=' + encodeURIComponent(rd) : ''}`}>
|
||||
Sign in
|
||||
<LogIn class="w-8" size={18} />
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="flex flex-row gap-4 justify-center">
|
||||
<AlertTriangle class="" />
|
||||
<p class="text-lg">Permission denied</p>
|
||||
</div>
|
||||
<p class="text-sm">{error.replace(/^(Permission denied: )/, '')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row justify-between flex-wrap sm:flex-nowrap gap-x-4">
|
||||
<div class="w-full">
|
||||
<h2 class="mt-4">Current approvers</h2>
|
||||
<p class="text-xs italic"
|
||||
>Each approver can only approve once and cannot change his approver name set by the
|
||||
approval sender</p
|
||||
>
|
||||
<div class="my-4">
|
||||
{#if currentApprovers.length > 0}
|
||||
<ul>
|
||||
{#each currentApprovers as approver}
|
||||
<li
|
||||
><b
|
||||
>{approver.approver}<Tooltip
|
||||
>Unique id of approval: {approver.resume_id}</Tooltip
|
||||
></b
|
||||
></li
|
||||
>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm"
|
||||
>No current approvers for this step (approval steps can require more than one
|
||||
approval)</p
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
{#if job && job.raw_flow}
|
||||
<FlowMetadata {job} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
{#if job && job.raw_flow}
|
||||
<FlowMetadata {job} />
|
||||
<h2 class="mt-4 mb-2">Flow arguments</h2>
|
||||
|
||||
<JobArgs args={job?.args} />
|
||||
<div class="mt-8">
|
||||
{#if approver}
|
||||
<p>Dis/approving as: <b>{approver}</b></p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="mt-4 mb-2">Flow arguments</h2>
|
||||
|
||||
<JobArgs args={job?.args} />
|
||||
<div class="mt-8">
|
||||
{#if approver}
|
||||
<p>Dis/approving as: <b>{approver}</b></p>
|
||||
{#if completed}
|
||||
<div class="my-2"
|
||||
><p><b>The flow is not running anymore. You cannot cancel or resume it.</b></p></div
|
||||
>
|
||||
{:else if alreadyResumed}
|
||||
<div class="my-2"><p><b>You have already approved this flow to be resumed</b></p></div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if completed}
|
||||
<div class="my-2"
|
||||
><p><b>The flow is not running anymore. You cannot cancel or resume it.</b></p></div
|
||||
>
|
||||
{:else if alreadyResumed}
|
||||
<div class="my-2"><p><b>You have already approved this flow to be resumed</b></p></div>
|
||||
{/if}
|
||||
|
||||
{#if schema}
|
||||
<SchemaForm bind:isValid={valid} {schema} bind:args={payload} />
|
||||
{/if}
|
||||
{#if schema}
|
||||
<SchemaForm bind:isValid={valid} {schema} bind:args={payload} />
|
||||
{/if}
|
||||
|
||||
<div class="w-max-md flex flex-row gap-x-4 gap-y-4 justify-between w-full flex-wrap mt-2">
|
||||
<Button
|
||||
btnClasses="grow"
|
||||
color="red"
|
||||
on:click|once={cancel}
|
||||
size="md"
|
||||
disabled={completed || alreadyResumed}>Disapprove/Cancel</Button
|
||||
>
|
||||
<Button
|
||||
btnClasses="grow"
|
||||
color="green"
|
||||
on:click|once={resume}
|
||||
size="md"
|
||||
disabled={completed || alreadyResumed || !valid}>Approve/Resume</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-row flex-wrap justify-between"
|
||||
><a href="https://windmill.dev">Learn more about Windmill</a>
|
||||
<a target="_blank" rel="noreferrer" href="/run/{job?.id}?workspace={job?.workspace_id}"
|
||||
>Flow run details (require auth)</a
|
||||
>
|
||||
</div>
|
||||
{#if job && job.raw_flow}
|
||||
<h2 class="mt-10">Flow details</h2>
|
||||
<div class="border border-gray-700">
|
||||
<FlowGraph
|
||||
modules={job.raw_flow?.modules}
|
||||
failureModule={job.raw_flow?.failure_module}
|
||||
notSelectable
|
||||
/>
|
||||
<div class="w-max-md flex flex-row gap-x-4 gap-y-4 justify-between w-full flex-wrap mt-2">
|
||||
<Button
|
||||
btnClasses="grow"
|
||||
color="red"
|
||||
on:click|once={cancel}
|
||||
size="md"
|
||||
disabled={completed || alreadyResumed}>Disapprove/Cancel</Button
|
||||
>
|
||||
<Button
|
||||
btnClasses="grow"
|
||||
color="green"
|
||||
on:click|once={resume}
|
||||
size="md"
|
||||
disabled={completed || alreadyResumed || !valid}>Approve/Resume</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-row flex-wrap justify-between"
|
||||
><a href="https://windmill.dev">Learn more about Windmill</a>
|
||||
<a target="_blank" rel="noreferrer" href="/run/{job?.id}?workspace={job?.workspace_id}"
|
||||
>Flow run details (require auth)</a
|
||||
>
|
||||
</div>
|
||||
{#if job && job.raw_flow}
|
||||
<h2 class="mt-10">Flow details</h2>
|
||||
<div class="border border-gray-700">
|
||||
<FlowGraph
|
||||
modules={job.raw_flow?.modules}
|
||||
failureModule={job.raw_flow?.failure_module}
|
||||
notSelectable
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</CenteredModal>
|
||||
|
||||
@@ -119,6 +119,11 @@ components:
|
||||
properties:
|
||||
schema:
|
||||
type: object
|
||||
user_auth_required:
|
||||
type: boolean
|
||||
user_groups_required:
|
||||
$ref: "#/components/schemas/InputTransform"
|
||||
|
||||
retry:
|
||||
$ref: "#/components/schemas/Retry"
|
||||
required:
|
||||
|
||||
Reference in New Issue
Block a user