From 4e02eab2e0b6fa358169cbd3586429b0685df982 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 22 Jul 2023 19:53:53 +0200 Subject: [PATCH] feat: add SCIM support --- .../20230720092702_instance_groups.up.sql | 9 +- backend/windmill-api/src/groups.rs | 11 +- backend/windmill-api/src/scim.rs | 261 ++++++++++++++++-- .../components/buttons/AppSchemaForm.svelte | 2 +- .../apps/components/inputs/AppSelect.svelte | 2 +- .../components/common/table/FlowRow.svelte | 10 +- 6 files changed, 258 insertions(+), 37 deletions(-) diff --git a/backend/migrations/20230720092702_instance_groups.up.sql b/backend/migrations/20230720092702_instance_groups.up.sql index f9b93c6083..7d961b99ab 100644 --- a/backend/migrations/20230720092702_instance_groups.up.sql +++ b/backend/migrations/20230720092702_instance_groups.up.sql @@ -1,3 +1,8 @@ -- Add up migration script here -CREATE TABLE instance_group (name VARCHAR(255) PRIMARY KEY, summary VARCHAR(2000), external_id VARCHAR(1000)); -CREATE TABLE email_to_igroup (email VARCHAR(255), igroup VARCHAR(255), PRIMARY KEY (email, igroup)); \ No newline at end of file +CREATE TABLE instance_group (name VARCHAR(255) PRIMARY KEY, summary VARCHAR(2000)); +CREATE TABLE email_to_igroup (email VARCHAR(255), igroup VARCHAR(255) NOT NULL, PRIMARY KEY (email, igroup)); + +GRANT ALL PRIVILEGES ON TABLE instance_group TO windmill_admin; +GRANT ALL PRIVILEGES ON TABLE instance_group TO windmill_user; +GRANT ALL PRIVILEGES ON TABLE email_to_igroup TO windmill_admin; +GRANT ALL PRIVILEGES ON TABLE email_to_igroup TO windmill_user; \ No newline at end of file diff --git a/backend/windmill-api/src/groups.rs b/backend/windmill-api/src/groups.rs index 43c6ab33cb..910c45271b 100644 --- a/backend/windmill-api/src/groups.rs +++ b/backend/windmill-api/src/groups.rs @@ -112,20 +112,23 @@ struct QueryListGroup { pub only_member_of: Option, } async fn list_group_names( - Authed { username, .. }: Authed, + Authed { username, email, .. }: Authed, Extension(db): Extension, Query(QueryListGroup { only_member_of }): Query, Path(w_id): Path, ) -> JsonResult> { let rows = if !only_member_of.unwrap_or(false) { sqlx::query_scalar!( - "SELECT name FROM group_ WHERE workspace_id = $1 ORDER BY name desc", + "SELECT name FROM group_ WHERE workspace_id = $1 UNION ALL SELECT name FROM instance_group ORDER BY name desc", w_id ) .fetch_all(&db) .await? + .into_iter() + .filter_map(|x| x) + .collect() } else { - get_groups_for_user(&w_id, &username, &db).await? + get_groups_for_user(&w_id, &username, &email, &db).await? }; Ok(Json(rows)) @@ -523,7 +526,7 @@ async fn list_igroups(authed: Authed, Extension(db): Extension) -> JsonResul require_super_admin(&mut tx, &authed.email).await?; let groups = sqlx::query_as!( IGroup, - "SELECT igroup as name, array_agg(email_to_igroup.email) as emails FROM email_to_igroup GROUP BY igroup" + "SELECT name, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name" ) .fetch_all(&mut *tx) .await?; diff --git a/backend/windmill-api/src/scim.rs b/backend/windmill-api/src/scim.rs index c7065fbe6f..9d3b91ef82 100644 --- a/backend/windmill-api/src/scim.rs +++ b/backend/windmill-api/src/scim.rs @@ -11,7 +11,7 @@ use axum::{ middleware::Next, response::{IntoResponse, Response}, routing::{get, post}, - Extension, Router, + Extension, Json, Router, }; use bytes::{BufMut, BytesMut}; use hyper::{header, http::HeaderValue, Request, StatusCode}; @@ -19,7 +19,10 @@ use mime_guess::mime; use serde::{Deserialize, Serialize}; use serde_json::json; use sql_builder::SqlBuilder; -use windmill_common::error::{Error, Result}; +use windmill_common::{ + error::{Error, Result}, + utils::not_found_if_none, +}; use crate::db::DB; @@ -28,10 +31,23 @@ lazy_static::lazy_static! { .ok(); } +#[cfg(feature = "enterprise")] +pub fn global_service() -> Router { + Router::new() + .route("/Users", get(get_users).post(create_user)) + .route("/Groups", get(get_groups).post(create_group)) + .route( + "/Groups/:id", + get(get_group) + .put(update_group) + .patch(update_group) + .delete(delete_group), + ) +} + +#[cfg(not(feature = "enterprise"))] pub fn global_service() -> Router { Router::new().route("/Users", get(get_users)) - // .route("/Groups", get(get_groups).post(create_group)) - // .route("/Groups/:id", get(get_group)) } #[derive(Debug, Clone, Copy, Default)] @@ -74,10 +90,11 @@ where } } -#[derive(Serialize)] +#[derive(Serialize, Debug)] struct User { id: String, userName: String, + active: bool, } pub fn resource_response(schema: &str, resources: Vec) -> JsonScim where @@ -96,37 +113,72 @@ where pub struct ScimQuery { startIndex: Option, count: Option, + filter: Option, } pub async fn get_users( Extension(db): Extension, Query(query): Query, ) -> Result> { - let sqlb = SqlBuilder::select_from("usr") + let mut sqlb = SqlBuilder::select_from("usr") .fields(&["email"]) .limit(query.count.unwrap_or(100000)) .offset(query.startIndex.map(|x| x - 1).unwrap_or(0)) .clone(); + tracing::info!("SCIM filter: {:?}", query.filter); + + if let Some(filter) = query.filter { + let filter = filter + .replace("userName", "email") + .replace("eq", "=") + .replace("\"", "'"); + sqlb.and_where(&filter); + } + let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?; let users = sqlx::query_scalar(&sql) .fetch_all(&db) .await? .into_iter() - .map(|x: String| User { id: x.clone(), userName: x }) + .map(|x: String| User { id: x.clone(), userName: x, active: true }) .collect(); + tracing::info!("SCIM users: {:?}", users); Ok(resource_response( "urn:ietf:params:scim:api:messages:2.0:ListResponse", users, )) } +#[derive(Deserialize, Debug)] +pub struct CreateUser { + userName: String, +} +// #[cfg(feature = "enterprise")] +pub async fn create_user( + Extension(db): Extension, + Json(body): Json, +) -> Result> { + tracing::info!("SCIM creating user: {:?}", body); + sqlx::query!( + "INSERT INTO password (email, login_type, verified) VALUES ($1, 'saml', true) ON CONFLICT DO NOTHING", + body.userName, + ).execute(&db).await?; + Ok(JsonScim(json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": body.userName, + "userName": body.userName, + "active": true + }))) +} + +#[cfg(feature = "enterprise")] pub async fn get_groups( Extension(db): Extension, Query(query): Query, ) -> Result> { let sqlb = SqlBuilder::select_from("instance_group") - .fields(&["email"]) + .fields(&["name"]) .limit(query.count.unwrap_or(100000)) .offset(query.startIndex.map(|x| x - 1).unwrap_or(0)) .clone(); @@ -136,7 +188,7 @@ pub async fn get_groups( .fetch_all(&db) .await? .into_iter() - .map(|x: String| User { id: x.clone(), userName: x }) + .map(|x: String| User { id: x.clone(), userName: x, active: true }) .collect(); Ok(resource_response( "urn:ietf:params:scim:api:messages:2.0:ListResponse", @@ -144,24 +196,177 @@ pub async fn get_groups( )) } -// pub async fn get_group( -// Extension(db): Extension, -// Query(query): Query, -// Path(id): Path, -// ) -> Result> { -// let groups = sqlx::query_as!( -// IGroup, -// "SELECT igroup as name, array_agg(email_to_igroup.email) as emails FROM email_to_igroup GROUP BY igroup" -// ) -// .fetch_all(&mut *tx) -// .await?; -// Ok(resource_response( -// "urn:ietf:params:scim:api:messages:2.0:ListResponse", -// groups, -// )) +// { +// "schemas": [], +// "id": "abf4dd94-a4c0-4f67-89c9-76b03340cb9b", +// "displayName": "Test SCIMv2", +// "members": [], +// "meta": { +// "resourceType": "Group" +// } // } -// pub async fn create_group( -// Extension(db): Extension, -// Query(query): Query, -// ) -> Result> { + +#[cfg(feature = "enterprise")] +#[derive(Serialize)] +struct Group { + name: String, + emails: Option>, +} +#[cfg(feature = "enterprise")] +fn group_response(group: Group) -> JsonScim { + return JsonScim(json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "displayName": group.name, + "id": convert_name(&group.name), + "members": group.emails, + "meta": { + "resourceType": "Group" + } + })); +} + +#[cfg(feature = "enterprise")] +pub async fn get_group( + Extension(db): Extension, + Path(id): Path, +) -> Result> { + let group= sqlx::query_as!( + Group, + "SELECT name, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name", + id + ) + .fetch_optional(&db) + .await?; + let group = not_found_if_none(group, "Group", id)?; + Ok(group_response(group)) +} + +// { +// "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], +// "displayName": "Test SCIMv2", +// "members": [] // } + +#[cfg(feature = "enterprise")] +#[derive(Deserialize, Debug)] +pub struct CreateGroup { + pub displayName: String, + pub members: Vec, +} + +#[cfg(feature = "enterprise")] +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Member { + pub value: String, + pub display: String, +} + +#[cfg(feature = "enterprise")] +pub async fn create_group( + Extension(db): Extension, + Json(body): Json, +) -> Result> { + tracing::info!("SCIM creating group: {:?}", body); + let mut tx: sqlx::Transaction<'_, sqlx::Postgres> = db.begin().await?; + sqlx::query!( + "INSERT INTO instance_group (name) VALUES ($1) ON CONFLICT DO NOTHING", + convert_name(&body.displayName) + ) + .execute(&mut *tx) + .await?; + for member in &body.members { + sqlx::query!( + "INSERT INTO email_to_igroup (email, igroup) VALUES ($1, $2) ON CONFLICT DO NOTHING", + convert_name(&member.display), + body.displayName, + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(group_response(Group { + name: body.displayName.clone(), + emails: Some( + body.members + .clone() + .into_iter() + .map(|x| x.display.clone()) + .collect(), + ), + })) +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Operation { + pub op: String, + pub path: String, + pub value: Option, +} + +#[cfg(feature = "enterprise")] +#[derive(Deserialize, Debug)] +pub struct UpdateGroup { + pub schemas: Vec, + pub displayName: Option, + pub members: Option>, + pub Operations: Option>, +} + +#[cfg(feature = "enterprise")] +pub async fn update_group( + Extension(db): Extension, + Path(id): Path, + Json(body): Json, +) -> Result> { + tracing::info!("SCIM updating group: {:?}", body); + let mut tx: sqlx::Transaction<'_, sqlx::Postgres> = db.begin().await?; + if body.schemas.len() == 1 { + let schema = body.schemas.get(0).unwrap(); + if schema == "urn:ietf:params:scim:schemas:core:2.0:Group" { + if let Some(members) = body.members.clone() { + for m in members { + sqlx::query!( + "INSERT INTO email_to_igroup (email, igroup) VALUES ($1, $2) ON CONFLICT DO NOTHING", + m.display, + id + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(group_response(Group { + name: body.displayName.unwrap_or_default(), + emails: Some( + body.members + .unwrap_or_default() + .clone() + .into_iter() + .map(|x| x.display.clone()) + .collect(), + ), + })) + } else { + Err(Error::BadRequest("expected members".to_string())) + } + } else { + Err(Error::BadRequest("Invalid schemas".to_string())) + } + } else { + Err(Error::BadRequest("Invalid schemas".to_string())) + } +} + +pub async fn delete_group(Extension(db): Extension, Path(id): Path) -> Result<()> { + tracing::info!("SCIM delete group: {:?}", id); + sqlx::query!("DELETE FROM email_to_igroup WHERE igroup = $1", id) + .execute(&db) + .await?; + sqlx::query!("DELETE FROM instance_group WHERE name = $1", id) + .execute(&db) + .await?; + Ok(()) +} + +fn convert_name(name: &str) -> String { + name.replace(" ", "_").to_lowercase() +} diff --git a/frontend/src/lib/components/apps/components/buttons/AppSchemaForm.svelte b/frontend/src/lib/components/apps/components/buttons/AppSchemaForm.svelte index a71c981b69..1dc493a5dc 100644 --- a/frontend/src/lib/components/apps/components/buttons/AppSchemaForm.svelte +++ b/frontend/src/lib/components/apps/components/buttons/AppSchemaForm.svelte @@ -101,6 +101,6 @@ /> {:else} -

Empty form (no propertie)

+

Empty form (no property)

{/if} diff --git a/frontend/src/lib/components/apps/components/inputs/AppSelect.svelte b/frontend/src/lib/components/apps/components/inputs/AppSelect.svelte index 7b2dd6147d..070ecdf808 100644 --- a/frontend/src/lib/components/apps/components/inputs/AppSelect.svelte +++ b/frontend/src/lib/components/apps/components/inputs/AppSelect.svelte @@ -127,7 +127,7 @@ function onClear() { value = undefined - outputs?.result.set(undefined) + outputs?.result.set(undefined, true) if (iterContext && listInputs) { listInputs(id, undefined) } diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index 19177194c1..b88a9e0f76 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -11,6 +11,7 @@ faArchive, faCalendarAlt, faCodeFork, + faCopy, faEdit, faFileExport, faList, @@ -23,7 +24,7 @@ import Row from './Row.svelte' import DraftBadge from '$lib/components/DraftBadge.svelte' import { sendUserToast } from '$lib/toast' - import { DELETE, isOwner } from '$lib/utils' + import { DELETE, copyToClipboard, isOwner } from '$lib/utils' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' export let flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean } @@ -157,6 +158,13 @@ }, disabled: !owner || archived }, + { + displayName: 'Copy path', + icon: faCopy, + action: () => { + copyToClipboard(path) + } + }, { displayName: 'Deploy to staging/prod', icon: faFileExport,