feat: add SCIM support

This commit is contained in:
Ruben Fiszel
2023-07-22 19:53:53 +02:00
parent 957db65da8
commit 4e02eab2e0
6 changed files with 258 additions and 37 deletions
@@ -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;
+7 -4
View File
@@ -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?;
+233 -28
View File
@@ -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,