mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: add SCIM support
This commit is contained in:
@@ -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));
|
||||
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;
|
||||
@@ -112,20 +112,23 @@ struct QueryListGroup {
|
||||
pub only_member_of: Option<bool>,
|
||||
}
|
||||
async fn list_group_names(
|
||||
Authed { username, .. }: Authed,
|
||||
Authed { username, email, .. }: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Query(QueryListGroup { only_member_of }): Query<QueryListGroup>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
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<DB>) -> 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?;
|
||||
|
||||
@@ -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<S>(schema: &str, resources: Vec<S>) -> JsonScim<serde_json::Value>
|
||||
where
|
||||
@@ -96,37 +113,72 @@ where
|
||||
pub struct ScimQuery {
|
||||
startIndex: Option<u32>,
|
||||
count: Option<u32>,
|
||||
filter: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_users(
|
||||
Extension(db): Extension<DB>,
|
||||
Query(query): Query<ScimQuery>,
|
||||
) -> Result<JsonScim<serde_json::Value>> {
|
||||
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<DB>,
|
||||
Json(body): Json<CreateUser>,
|
||||
) -> Result<JsonScim<serde_json::Value>> {
|
||||
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<DB>,
|
||||
Query(query): Query<ScimQuery>,
|
||||
) -> Result<JsonScim<serde_json::Value>> {
|
||||
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<DB>,
|
||||
// Query(query): Query<ScimQuery>,
|
||||
// Path(id): Path<String>,
|
||||
// ) -> Result<JsonScim<serde_json::Value>> {
|
||||
// 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<DB>,
|
||||
// Query(query): Query<ScimQuery>,
|
||||
// ) -> Result<JsonScim<serde_json::Value>> {
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[derive(Serialize)]
|
||||
struct Group {
|
||||
name: String,
|
||||
emails: Option<Vec<String>>,
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
fn group_response(group: Group) -> JsonScim<serde_json::Value> {
|
||||
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<DB>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<JsonScim<serde_json::Value>> {
|
||||
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<Member>,
|
||||
}
|
||||
|
||||
#[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<DB>,
|
||||
Json(body): Json<CreateGroup>,
|
||||
) -> Result<JsonScim<serde_json::Value>> {
|
||||
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<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UpdateGroup {
|
||||
pub schemas: Vec<String>,
|
||||
pub displayName: Option<String>,
|
||||
pub members: Option<Vec<Member>>,
|
||||
pub Operations: Option<Vec<Operation>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub async fn update_group(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateGroup>,
|
||||
) -> Result<JsonScim<serde_json::Value>> {
|
||||
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<DB>, Path(id): Path<String>) -> 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()
|
||||
}
|
||||
|
||||
@@ -101,6 +101,6 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="m-2 italic">Empty form (no propertie)</p>
|
||||
<p class="m-2 italic">Empty form (no property)</p>
|
||||
{/if}
|
||||
</RunnableWrapper>
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
function onClear() {
|
||||
value = undefined
|
||||
outputs?.result.set(undefined)
|
||||
outputs?.result.set(undefined, true)
|
||||
if (iterContext && listInputs) {
|
||||
listInputs(id, undefined)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user