Files
windmill/backend/src/workspaces.rs
Ruben Fiszel 2e132878e4 first commit
2022-05-05 04:25:58 +02:00

667 lines
17 KiB
Rust

/*
* Author & Copyright: Ruben Fiszel 2021
* This file and its contents are licensed under the AGPL License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::{
db::{UserDB, DB},
error::{Error, JsonResult, Result},
users::{Authed, WorkspaceInvite}, utils::{require_admin, require_super_admin, Pagination}, audit::{audit_log, ActionKind}, scripts::{Script, Schema}, resources::{Resource, ResourceType}, flow::Flow, variables::ListableVariable,
};
use axum::{extract::{Extension, Path, Query}, routing::{get, post, delete}, Json, Router, response::{IntoResponse}, body::StreamBody};
use hyper::{StatusCode, header};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow};
use tempfile::TempDir;
use tokio::fs::File;
use tokio_util::io::ReaderStream;
pub fn workspaced_service() -> Router {
Router::new()
.route("/list_pending_invites", get(list_pending_invites))
.route("/update", post(edit_workspace))
.route("/delete", delete(delete_workspace))
.route("/invite_user", post(invite_user))
.route("/delete_invite", post(delete_invite))
.route("/get_settings", get(get_settings))
.route("/edit_slack_command", post(edit_slack_command))
.route("/tarball", get(tarball_workspace))
}
pub fn global_service() -> Router {
Router::new()
.route("/list_as_superadmin", get(list_workspaces_as_super_admin))
.route("/list", get(list_workspaces))
.route("/users", get(user_workspaces))
.route("/create", post(create_workspace))
.route("/exists", post(exists_workspace))
.route("/validate_username", post(validate_username))
.route("/validate_id", post(validate_id))
}
#[derive(FromRow, Serialize)]
struct Workspace {
id: String,
name: String,
owner: String,
domain: Option<String>,
deleted: bool
}
#[derive(FromRow, Serialize, Debug)]
pub struct WorkspaceSettings {
pub workspace_id: String,
pub slack_team_id: Option<String>,
pub slack_name: Option<String>,
pub slack_command_script: Option<String>
}
#[derive(sqlx::Type, Serialize, Deserialize, Debug)]
#[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")]
pub enum WorkspaceKeyKind {
Cloud
}
#[derive(Deserialize)]
struct EditCommandScript {
slack_command_script: Option<String>
}
#[derive(Deserialize)]
struct CreateWorkspace {
id: String,
name: String,
username: String,
domain: Option<String>,
}
#[derive(Deserialize)]
struct EditWorkspace {
name: String,
owner: String,
domain: Option<String>
}
#[derive(Serialize)]
struct WorkspaceList {
pub email: String,
pub workspaces: Vec<UserWorkspace>,
}
#[derive(Serialize)]
struct UserWorkspace {
pub id: String,
pub name: String,
pub username: String,
}
#[derive(Deserialize)]
struct WorkspaceId {
pub id: String,
}
#[derive(Deserialize)]
struct ValidateUsername {
pub id: String,
pub username: String,
}
#[derive(Deserialize)]
pub struct NewWorkspaceInvite {
pub email: String,
pub is_admin: bool,
}
async fn list_pending_invites(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<WorkspaceInvite>> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as!(WorkspaceInvite, "SELECT * from workspace_invite WHERE workspace_id = $1", w_id)
.fetch_all(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn exists_workspace(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Json(WorkspaceId { id }): Json<WorkspaceId>
) -> JsonResult<bool> {
let mut tx = user_db.begin(&authed).await?;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM workspace WHERE workspace.id = $1)",
id)
.fetch_one(&mut tx)
.await?
.unwrap_or(false);
tx.commit().await?;
Ok(Json(exists))
}
async fn list_workspaces(
authed: Authed,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<Vec<Workspace>> {
let mut tx = user_db.begin(&authed).await?;
let workspaces = sqlx::query_as!(
Workspace,
"SELECT workspace.* FROM workspace, usr WHERE usr.workspace_id = workspace.id AND usr.email = $1 AND deleted = false",
authed.email.as_ref())
.fetch_all(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(workspaces))
}
async fn get_settings(
authed: Authed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<WorkspaceSettings> {
let mut tx = user_db.begin(&authed).await?;
let settings = sqlx::query_as!(
WorkspaceSettings,
"SELECT * FROM workspace_settings WHERE workspace_id = $1",
&w_id)
.fetch_one(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(settings))
}
async fn edit_slack_command(
authed: Authed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Authed { is_admin, username, .. }: Authed,
Json(es): Json<EditCommandScript>
) -> Result<String> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_settings SET slack_command_script = $1 WHERE workspace_id = $2",
es.slack_command_script,
&w_id
)
.execute(&mut tx)
.await?;
audit_log(
&mut tx,
&authed.username,
"workspaces.edit_command_script",
ActionKind::Update,
&w_id,
Some(&authed.email.unwrap()),
Some([
("script", es.slack_command_script.unwrap_or("NO_SCRIPT".to_string()).as_str())
].into()),
)
.await?;
tx.commit().await?;
Ok(format!("Edit command script {}", &w_id))
}
async fn list_workspaces_as_super_admin(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Query(pagination): Query<Pagination>,
Authed { email, .. }: Authed,
) -> JsonResult<Vec<Workspace>> {
let mut tx = user_db.begin(&authed).await?;
require_super_admin(&mut tx, email).await?;
let (per_page, offset) = crate::utils::paginate(pagination);
let workspaces = sqlx::query_as!(
Workspace,
"SELECT * FROM workspace LIMIT $1 OFFSET $2", per_page as i32, offset as i32)
.fetch_all(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(workspaces))
}
async fn user_workspaces(
Extension(db): Extension<DB>,
Authed { email, .. }: Authed,
) -> JsonResult<WorkspaceList> {
let email = email
.ok_or("not a personal token")
.map_err(|x| Error::NotAuthorized(x.to_string()))?;
let mut tx = db.begin().await?;
let workspaces = sqlx::query_as!(
UserWorkspace,
"SELECT workspace.id, workspace.name, usr.username
FROM workspace, usr WHERE usr.workspace_id = workspace.id AND usr.email = $1 AND deleted = false",
email)
.fetch_all(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(WorkspaceList { email, workspaces }))
}
async fn create_workspace(
authed: Authed,
Extension(db): Extension<DB>,
Json(nw): Json<CreateWorkspace>
) -> Result<String> {
if &nw.username == "bot" {
return Err(Error::BadRequest("bot is a reserved username".to_string()))
}
let mut tx = db.begin().await?;
sqlx::query!(
"INSERT INTO workspace
(id, name, owner, domain)
VALUES ($1, $2, $3, $4)",
nw.id,
nw.name,
authed.email,
nw.domain
)
.execute(&mut tx)
.await?;
sqlx::query!(
"INSERT INTO workspace_settings
(workspace_id)
VALUES ($1)",
nw.id
)
.execute(&mut tx)
.await?;
let key = crate::utils::rd_string(64);
sqlx::query!(
"INSERT INTO workspace_key
(workspace_id, kind, key)
VALUES ($1, 'cloud', $2)",
nw.id, &key
)
.execute(&mut tx)
.await?;
let mc = magic_crypt::new_magic_crypt!(key, 256);
sqlx::query!(
"INSERT INTO variable
(workspace_id, path, value, is_secret, description)
VALUES ($1, 'g/all/pretty_secret', $2, true, 'This item is secret'),
($3, 'g/all/not_secret', $4, false, 'This item is not secret')",
nw.id,
crate::variables::encrypt(&mc, "pretty secret value".to_string()),
nw.id,
"finland does not actually exist",
)
.execute(&mut tx)
.await?;
sqlx::query!(
"INSERT INTO usr
(workspace_id, email, username, is_admin)
VALUES ($1, $2, $3, true)",
nw.id,
authed.email,
nw.username,
)
.execute(&mut tx)
.await?;
sqlx::query!(
"INSERT INTO group_
VALUES ($1, 'all', 'The group that always contains all users of this workspace')",
nw.id
)
.execute(&mut tx)
.await?;
sqlx::query!(
"INSERT INTO usr_to_group
VALUES ($1, 'all', $2)",
nw.id,
nw.username
)
.execute(&mut tx)
.await?;
audit_log(
&mut tx,
&authed.username,
"workspaces.create",
ActionKind::Create,
&nw.id,
Some(&authed.email.unwrap()),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Created workspace {}", &nw.id))
}
async fn edit_workspace(
authed: Authed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Authed { is_admin, username, .. }: Authed,
Json(ew): Json<EditWorkspace>
) -> Result<String> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace SET name = $1, owner = $2, domain = $3 WHERE id = $4",
ew.name,
ew.owner,
ew.domain,
&w_id
)
.execute(&mut tx)
.await?;
audit_log(
&mut tx,
&authed.username,
"workspaces.update",
ActionKind::Update,
&w_id,
Some(&authed.email.unwrap()),
Some([
("domain", ew.domain.unwrap_or("NO_DOMAIN".to_string()).as_str())
].into()),
)
.await?;
tx.commit().await?;
Ok(format!("Updated workspace {}", &w_id))
}
async fn delete_workspace(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Authed { is_admin, username, email, .. }: Authed,
) -> Result<String> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace SET deleted = true WHERE id = $1",
&w_id
)
.execute(&mut tx)
.await?;
audit_log(
&mut tx,
&username,
"workspaces.delete",
ActionKind::Update,
&w_id,
Some(&email.unwrap_or("noemail".to_string())),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Deleted workspace {}", &w_id))
}
async fn invite_user(
Authed { username, is_admin, .. }: Authed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(nu): Json<NewWorkspaceInvite>,
) -> Result<(StatusCode, String)> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"INSERT INTO workspace_invite
(workspace_id, email, is_admin)
VALUES ($1, $2, $3)",
&w_id,
nu.email,
nu.is_admin
)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok((
StatusCode::CREATED,
format!("user with email {} invited", nu.email),
))
}
async fn delete_invite(
Authed { username, is_admin, .. }: Authed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(nu): Json<NewWorkspaceInvite>,
) -> Result<(StatusCode, String)> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"DELETE FROM workspace_invite WHERE
workspace_id = $1 AND email = $2 AND is_admin = $3",
&w_id,
nu.email,
nu.is_admin
)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok((
StatusCode::CREATED,
format!("invite to email {} deleted", nu.email),
))
}
async fn validate_username(
Extension(db): Extension<DB>,
Json(vu): Json<ValidateUsername>,
) -> Result<String> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)",
vu.username,
vu.id
)
.fetch_one(&db)
.await?
.unwrap_or(true);
if exists {
return Err(Error::BadRequest("username already taken".to_string()))
}
Ok("valid username".to_string())
}
async fn validate_id(
Extension(db): Extension<DB>,
Json(wi): Json<WorkspaceId>,
) -> Result<String> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1)",
wi.id
)
.fetch_one(&db)
.await?
.unwrap_or(true);
if exists {
return Err(Error::BadRequest("id already taken".to_string()))
}
Ok("valid workspace".to_string())
}
#[derive(Serialize)]
struct ScriptMetadata {
summary: String,
description: String,
schema: Option<Schema>,
is_template: bool,
lock: Vec<String>
}
async fn tarball_workspace(
authed: Authed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> Result<([(headers::HeaderName, String); 2], impl IntoResponse)> {
require_admin(authed.is_admin, &authed.username)?;
let tmp_dir = TempDir::new_in(".")?;
let name = format!("windmill-{w_id}.tar");
let file_path = tmp_dir.path().join(&name);
let file = File::create(&file_path).await?;
let mut a = tokio_tar::Builder::new(file);
{
let scripts = sqlx::query_as::<_, Script>(
"SELECT * FROM script as o WHERE workspace_id = $1 AND archived = false
AND created_at = (select max(created_at) from script where path = o.path AND workspace_id = $1)"
)
.bind(&w_id)
.fetch_all(&db)
.await?;
for script in scripts {
write_to_archive(script.content, format!("scripts/{}.py", script.path), &mut a).await?;
let lock = script.lock.unwrap_or_else(|| "".to_string())
.lines()
.map(|x| x.to_string())
.collect();
let metadata = ScriptMetadata {
summary: script.summary, description: script.description, schema: script.schema, is_template: script.is_template, lock };
let metadata_str = serde_json::to_string_pretty(&metadata).unwrap();
write_to_archive(metadata_str, format!("scripts/{}.json", script.path), &mut a).await?;
};
}
{
let resources = sqlx::query_as!(Resource,
"SELECT * FROM resource WHERE workspace_id = $1",
&w_id
)
.fetch_all(&db)
.await?;
for resource in resources {
let resource_str = serde_json::to_string_pretty(&resource).unwrap();
write_to_archive(resource_str, format!("resources/{}.json", resource.path), &mut a).await?;
}
}
{
let resource_types = sqlx::query_as!(ResourceType,
"SELECT * FROM resource_type WHERE workspace_id = $1",
&w_id
)
.fetch_all(&db)
.await?;
for resource_type in resource_types {
let resource_str = serde_json::to_string_pretty(&resource_type).unwrap();
write_to_archive(resource_str, format!("resource_types/{}.json", resource_type.name), &mut a).await?;
}
}
{
let flows = sqlx::query_as::<_, Flow>(
"SELECT * FROM flow WHERE workspace_id = $1 AND archived = false"
)
.bind(&w_id)
.fetch_all(&db)
.await?;
for flow in flows {
let flow_str = serde_json::to_string_pretty(&flow).unwrap();
write_to_archive(flow_str, format!("flows/{}.json", flow.path), &mut a).await?;
}
}
{
let variables = sqlx::query_as::<_, ListableVariable>(
"SELECT * FROM variable WHERE workspace_id = $1 AND is_secret = false"
)
.bind(&w_id)
.fetch_all(&db)
.await?;
for var in variables {
let flow_str = serde_json::to_string_pretty(&var).unwrap();
write_to_archive(flow_str, format!("variables/{}.json", var.path), &mut a).await?;
}
}
a.into_inner().await?;
let file = tokio::fs::File::open(file_path).await?;
let stream = ReaderStream::new(file);
let body = StreamBody::new(stream);
let headers = [
(header::CONTENT_TYPE, "application/x-tar".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{name}\""),
),
];
Ok((headers, body))
}
async fn write_to_archive(content: String, path: String, a: &mut tokio_tar::Builder<File>) -> Result<()> {
let bytes = content.as_bytes();
let mut header = tokio_tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mtime(0);
header.set_uid(0);
header.set_gid(0);
header.set_mode(0o777);
header.set_cksum();
a.append_data(&mut header, path, bytes).await?;
Ok(())
}