From 5f5604a2aa1a64bf1cff5df66b63a8896538df69 Mon Sep 17 00:00:00 2001 From: Guillaume Bouvignies Date: Fri, 20 Oct 2023 00:20:20 +0200 Subject: [PATCH] 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 --- backend/windmill-api/openapi-deref.yaml | 7 +- backend/windmill-api/src/jobs.rs | 258 +++++++++++++----- backend/windmill-common/src/error.rs | 2 + backend/windmill-common/src/flow_status.rs | 2 +- backend/windmill-common/src/flows.rs | 5 + backend/windmill-common/src/jobs.rs | 8 +- backend/windmill-queue/src/jobs.rs | 4 +- backend/windmill-worker/src/worker.rs | 2 +- backend/windmill-worker/src/worker_flow.rs | 9 +- .../flows/content/FlowModuleSuspend.svelte | 150 ++++++++-- .../[job]/[resume]/[hmac]/+page.svelte | 177 ++++++------ openflow.openapi.yaml | 5 + 12 files changed, 437 insertions(+), 192 deletions(-) diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 9c311142f2..6f7edc5e39 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -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 diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index e37463255e..285eb0ad19 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -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, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result { - 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 { + 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, #[serde(skip_serializing_if = "Option::is_none")] pub script_path: Option, - pub args: Option<&'rows JsonRawValue>, - #[serde(skip_serializing_if = "Option::is_none", borrow)] - pub result: Option<&'rows JsonRawValue>, + pub args: Option>>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub logs: Option, pub deleted: bool, @@ -423,9 +429,9 @@ pub struct CompletedJob<'rows> { pub schedule_path: Option, pub permissioned_as: String, #[serde(skip_serializing_if = "Option::is_none")] - pub flow_status: Option, + pub flow_status: Option>>, #[serde(skip_serializing_if = "Option::is_none")] - pub raw_flow: Option, + pub raw_flow: Option>>, pub is_flow_step: bool, #[serde(skip_serializing_if = "Option::is_none")] pub language: Option, @@ -437,7 +443,7 @@ pub struct CompletedJob<'rows> { pub tag: String, } -impl<'row> CompletedJob<'row> { +impl CompletedJob { pub fn json_result(&self) -> Option { 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, Query(pagination): Query, Query(lq): Query, -) -> error::JsonResult>> { +) -> error::JsonResult> { 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, Extension(db): Extension, Path((w_id, job_id, resume_id, secret)): Path<(String, Uuid, u32, String)>, Query(approver): Query, @@ -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, Extension(db): Extension, Extension(rsmq): Extension>, 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, } @@ -1117,7 +1150,7 @@ pub struct QueryApprover { } pub async fn get_suspended_job_flow( - /* unauthed */ + authed: Option, Extension(db): Extension, Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>, Query(approver): Query, @@ -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, + 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::>(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, @@ -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 { - 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 { - 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, } -impl<'a> From for Job<'a> { +impl<'a> From for Job { fn from(uj: UnifiedJob) -> Self { match uj.typ.as_ref() { "CompletedJob" => Job::CompletedJob(CompletedJob { diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index 0dd9027fea..a567a13236 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -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}")] diff --git a/backend/windmill-common/src/flow_status.rs b/backend/windmill-common/src/flow_status.rs index 9c4b5c6b72..796f7b578f 100644 --- a/backend/windmill-common/src/flow_status.rs +++ b/backend/windmill-common/src/flow_status.rs @@ -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, diff --git a/backend/windmill-common/src/flows.rs b/backend/windmill-common/src/flows.rs index 574f9be0e6..5f6ba71445 100644 --- a/backend/windmill-common/src/flows.rs +++ b/backend/windmill-common/src/flows.rs @@ -67,6 +67,7 @@ pub struct NewFlow { pub schema: Option, pub draft_only: Option, pub tag: Option, + pub ws_error_handler_muted: Option, } #[derive(Deserialize, Serialize, Debug, Clone, Default)] @@ -169,6 +170,10 @@ pub struct Suspend { pub timeout: Option, #[serde(skip_serializing_if = "Option::is_none")] pub resume_form: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_auth_required: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_groups_required: Option, } #[derive(Deserialize, Serialize, Debug, Clone)] diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 2888bf10eb..22b48511cf 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -65,9 +65,9 @@ pub struct QueuedJob { pub schedule_path: Option, pub permissioned_as: String, #[serde(skip_serializing_if = "Option::is_none")] - pub flow_status: Option, + pub flow_status: Option>>, #[serde(skip_serializing_if = "Option::is_none")] - pub raw_flow: Option, + pub raw_flow: Option>>, pub is_flow_step: bool, #[serde(skip_serializing_if = "Option::is_none")] pub language: Option, @@ -127,13 +127,13 @@ impl QueuedJob { pub fn parse_raw_flow(&self) -> Option { self.raw_flow .as_ref() - .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .and_then(|v| serde_json::from_str::((**v).get()).ok()) } pub fn parse_flow_status(&self) -> Option { self.flow_status .as_ref() - .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .and_then(|v| serde_json::from_str::((**v).get()).ok()) } } diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 3db64b61d1..4eea26abd3 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -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>>, + &queued_job.raw_flow as &Option>>, queued_job.is_flow_step, skipped, queued_job.language.clone() as Option, diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 00d3c1403e..63d96c8f30 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -2365,7 +2365,7 @@ async fn handle_flow_dependency_job( "Flow Dependency requires raw flow".to_owned(), )) })?; - let mut flow = serde_json::from_value::(raw_flow).map_err(to_anyhow)?; + let mut flow = serde_json::from_str::((*raw_flow.0).get()).map_err(to_anyhow)?; flow.modules = lock_modules( flow.modules, diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 2eee08e3f3..9aade056c5 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -990,11 +990,12 @@ pub async fn handle_flow( .as_ref() .ok_or_else(|| Error::InternalErr(format!("requiring a raw flow value")))? .to_owned(); - let flow = serde_json::from_value::(value)?; + let flow = serde_json::from_str::((*value.0).get())?; - let status: FlowStatus = - serde_json::from_value::(flow_job.flow_status.clone().unwrap_or_default()) - .with_context(|| format!("parse flow status {}", flow_job.id))?; + let status: FlowStatus = serde_json::from_str::( + (*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( diff --git a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte index 85ecceed7b..51bc1169f5 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte @@ -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 { + 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 + } + }

@@ -21,6 +50,7 @@ used flexibly for other purpose.

+ { @@ -37,25 +67,93 @@ right: 'Suspend flow execution until events/approvals received' }} /> -
- Number of approvals/events required for resuming flow - {#if flowModule.suspend} - - {:else} - - {/if} +
+ + +
Core
+
+ +
Form
+
+ +
Permissions
+
+
+
- Timeout +{#if suspendTabSelected === 'core'} +
+ Number of approvals/events required for resuming flow - {#if flowModule.suspend} - - {:else} - - {/if} - {#if flowModule.suspend} -
-
+ {#if flowModule.suspend} + + {:else} + + {/if} + + Timeout + + {#if flowModule.suspend} + + {:else} + + {/if} +
+{:else if suspendTabSelected === 'permissions'} +
+ {#if emptyString($enterpriseLicense)} + + {/if} + {#if flowModule.suspend} +
+ { + if (flowModule.suspend) { + flowModule.suspend.user_auth_required = e.detail + } + }} + /> + +
+ + Require approvers to be members of one of the following user groups (leave empty for any) + + {#if allUserGroups.length !== 0} + { + 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} +
+ {/if} +
+{:else} +
+ {#if flowModule.suspend}
- -
- {/if} - {#if flowModule.suspend?.resume_form} - - {/if} -
+ > + {/if} + {#if flowModule.suspend?.resume_form} + + {/if} +
+{/if} diff --git a/frontend/src/routes/approve/[workspace]/[job]/[resume]/[hmac]/+page.svelte b/frontend/src/routes/approve/[workspace]/[job]/[resume]/[hmac]/+page.svelte index 85ebf2f772..69a46ebc40 100644 --- a/frontend/src/routes/approve/[workspace]/[job]/[resume]/[hmac]/+page.svelte +++ b/frontend/src/routes/approve/[workspace]/[job]/[resume]/[hmac]/+page.svelte @@ -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 @@ {#if error} -

{error}

- {/if} -
-
-

Current approvers

-

Each approver can only approve once and cannot change his approver name set by the approval - sender

-
- {#if currentApprovers.length > 0} -
    - {#each currentApprovers as approver} -
  • {approver.approver}Unique id of approval: {approver.resume_id}
  • - {/each} -
- {:else} -

No current approvers for this step (approval steps can require more than one approval)

+
+ {#if error.startsWith('Not authorized:')} +
+ +

Not Authorized

+
+

{error.replace(/^(Not authorized: )/, '')}

+ + {:else} +
+ +

Permission denied

+
+

{error.replace(/^(Permission denied: )/, '')}

+ {/if} +
+ {:else} +
+
+

Current approvers

+

Each approver can only approve once and cannot change his approver name set by the + approval sender

+
+ {#if currentApprovers.length > 0} +
    + {#each currentApprovers as approver} +
  • {approver.approver}Unique id of approval: {approver.resume_id}
  • + {/each} +
+ {:else} +

No current approvers for this step (approval steps can require more than one + approval)

+ {/if} +
+
+
+ {#if job && job.raw_flow} + {/if}
-
- {#if job && job.raw_flow} - +

Flow arguments

+ + +
+ {#if approver} +

Dis/approving as: {approver}

{/if}
-
-

Flow arguments

- - -
- {#if approver} -

Dis/approving as: {approver}

+ {#if completed} +

The flow is not running anymore. You cannot cancel or resume it.

+ {:else if alreadyResumed} +

You have already approved this flow to be resumed

{/if} -
- {#if completed} -

The flow is not running anymore. You cannot cancel or resume it.

- {:else if alreadyResumed} -

You have already approved this flow to be resumed

- {/if} - {#if schema} - - {/if} + {#if schema} + + {/if} -
- - -
- - - {#if job && job.raw_flow} -

Flow details

-
- +
+ +
+ + + {#if job && job.raw_flow} +

Flow details

+
+ +
+ {/if} {/if} diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index e30f14ba3b..10e0c3a7f7 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -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: