Files
windmill/backend/windmill-api/src/folders.rs
2024-02-23 23:12:50 +01:00

649 lines
17 KiB
Rust

/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use std::sync::Arc;
use crate::db::ApiAuthed;
use crate::{
db::DB,
users::{AuthCache, Tokened},
webhook_util::{WebhookMessage, WebhookShared},
};
use axum::{
extract::{Extension, Path, Query},
routing::{delete, get, post},
Json, Router,
};
use lazy_static::lazy_static;
use regex::Regex;
use windmill_audit::audit_ee::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
db::UserDB,
error::{self, to_anyhow, JsonResult, Result},
users::username_to_permissioned_as,
utils::{not_found_if_none, paginate, Pagination},
};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Postgres, Transaction};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_folders))
.route("/listnames", get(list_foldernames))
.route("/create", post(create_folder))
.route("/get/:name", get(get_folder))
.route("/update/:name", post(update_folder))
.route("/getusage/:name", get(get_folder_usage))
.route("/delete/:name", delete(delete_folder))
.route("/addowner/:name", post(add_owner))
.route("/removeowner/:name", post(remove_owner))
.route("/is_owner/*path", get(is_owner_api))
}
#[derive(FromRow, Serialize, Deserialize, Clone)]
pub struct Folder {
pub workspace_id: String,
pub name: String,
pub display_name: String,
pub owners: Vec<String>,
pub extra_perms: serde_json::Value,
}
#[derive(Deserialize)]
pub struct NewFolder {
pub name: String,
pub display_name: Option<String>,
pub owners: Option<Vec<String>>,
pub extra_perms: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct UpdateFolder {
pub display_name: Option<String>,
pub owners: Option<Vec<String>>,
pub extra_perms: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct Owner {
pub owner: String,
pub write: Option<bool>,
}
async fn list_folders(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<Folder>> {
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as!(
Folder,
"SELECT workspace_id, name, display_name, owners, extra_perms FROM folder WHERE workspace_id = $1 ORDER BY name desc LIMIT $2 OFFSET $3",
w_id,
per_page as i64,
offset as i64
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn list_foldernames(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<String>> {
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_scalar!(
"SELECT name FROM folder WHERE workspace_id = $1 ORDER BY name desc LIMIT $2 OFFSET $3",
w_id,
per_page as i64,
offset as i64
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn check_name_conflict<'c>(
tx: &mut Transaction<'c, Postgres>,
w_id: &str,
name: &str,
) -> Result<()> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2)",
name,
w_id
)
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
if exists {
return Err(windmill_common::error::Error::BadRequest(format!(
"Folder {} already exists",
name
)));
}
return Ok(());
}
lazy_static! {
static ref VALID_FOLDER_NAME: Regex = Regex::new(r#"^[a-zA-Z_0-9]+$"#).unwrap();
}
async fn create_folder(
authed: ApiAuthed,
Tokened { token }: Tokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(rsmq): Extension<Option<rsmq_async::MultiplexedRsmq>>,
Extension(cache): Extension<Arc<AuthCache>>,
Path(w_id): Path<String>,
Json(ng): Json<NewFolder>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
if !VALID_FOLDER_NAME.is_match(&ng.name) {
return Err(windmill_common::error::Error::BadRequest(format!(
"Folder name can only contain alphanumeric characters, underscores"
)));
}
check_name_conflict(&mut tx, &w_id, &ng.name).await?;
cache.invalidate(&w_id, token).await;
let owner = username_to_permissioned_as(&authed.username);
let owners = ng.owners.unwrap_or_else(|| vec![owner.clone()]);
let owners = if owners.contains(&owner) {
owners.clone()
} else {
owners
.iter()
.cloned()
.chain(std::iter::once(owner))
.collect()
};
let mut extra_perms = ng
.extra_perms
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
if extra_perms.is_object() {
let extra_mut = extra_perms.as_object_mut().unwrap();
for o in &owners {
extra_mut.insert(o.clone(), serde_json::json!(true));
}
} else {
return Err(error::Error::BadRequest(
"extra_perms must be an object".to_string(),
));
}
sqlx::query_as!(
Folder,
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) VALUES ($1, $2, $3, $4, $5)",
w_id,
ng.name,
ng.display_name.unwrap_or(ng.name.clone()),
&owners,
extra_perms,
)
.execute(&mut *tx)
.await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", ng.name) },
Some(format!("Folder '{}' created", ng.name)),
rsmq,
true,
)
.await?;
audit_log(
&mut *tx,
&authed.username,
"folder.create",
ActionKind::Create,
&w_id,
Some(&ng.name.to_string()),
None,
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateFolder { workspace: w_id, name: ng.name.clone() },
);
Ok(format!("Created folder {}", ng.name))
}
pub async fn is_owner_api(
authed: ApiAuthed,
Path((_w_id, name)): Path<(String, String)>,
) -> JsonResult<bool> {
Ok(Json(is_owner(&authed, &name)))
}
pub fn is_owner(ApiAuthed { is_admin, folders, .. }: &ApiAuthed, name: &str) -> bool {
if *is_admin {
true
} else {
folders.into_iter().any(|x| x.0 == name && x.2)
}
}
pub fn require_is_owner(authed: &ApiAuthed, name: &str) -> Result<()> {
if is_owner(authed, name) {
Ok(())
} else {
Err(windmill_common::error::Error::NotAuthorized(format!(
"You are not owner of the folder {}",
name
)))
}
}
async fn update_folder(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(ng): Json<UpdateFolder>,
) -> Result<String> {
use sql_builder::prelude::*;
let mut sqlb = SqlBuilder::update_table("folder");
sqlb.and_where_eq("name", "?".bind(&name));
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
if let Some(display_name) = ng.display_name {
sqlb.set("display_name", "?".bind(&display_name));
}
if let Some(owners) = ng.owners {
sqlb.set(
"owners",
"?".bind(&format!(
"{{{}}}",
owners
.iter()
.map(|x| format!("\"{x}\""))
.collect::<Vec<_>>()
.join(","),
)),
);
}
if let Some(extra_perms) = ng.extra_perms {
sqlb.set(
"extra_perms",
"?".bind(&serde_json::to_string(&extra_perms).map_err(to_anyhow)?),
);
}
sqlb.returning("*");
let mut tx = user_db.begin(&authed).await?;
let sql = sqlb
.sql()
.map_err(|e| error::Error::InternalErr(e.to_string()))?;
let nfolder = sqlx::query_as::<_, Folder>(&sql)
.fetch_one(&mut *tx)
.await?;
if let Some(extra_perms) = nfolder.extra_perms.as_object() {
for o in nfolder.owners {
if !extra_perms
.get(&o)
.and_then(|x| x.as_bool())
.unwrap_or(false)
{
return Err(windmill_common::error::Error::BadRequest(format!(
"Owner {} would not have permission to write to folder and that is an invalid state",
o
)));
}
}
}
audit_log(
&mut *tx,
&authed.username,
"folder.update",
ActionKind::Update,
&w_id,
Some(&name.to_string()),
None,
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone().clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.to_owned() },
);
Ok(format!("Updated folder {}", name))
}
pub async fn get_folderopt<'c>(
db: &mut Transaction<'c, Postgres>,
w_id: &str,
name: &str,
) -> Result<Option<Folder>> {
let folderopt = sqlx::query_as!(
Folder,
"SELECT workspace_id, name, display_name, owners, extra_perms FROM folder WHERE name = $1 AND workspace_id = $2",
name,
w_id
)
.fetch_optional(&mut **db)
.await?;
Ok(folderopt)
}
async fn get_folder(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<Folder> {
let mut tx = user_db.begin(&authed).await?;
let folder = not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
tx.commit().await?;
Ok(Json(folder))
}
#[derive(Serialize)]
struct FolderUsage {
pub scripts: i64,
pub schedules: i64,
pub flows: i64,
pub apps: i64,
pub resources: i64,
pub variables: i64,
}
async fn get_folder_usage(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<FolderUsage> {
let mut tx = user_db.begin(&authed).await?;
let scripts = sqlx::query_scalar!(
"SELECT count(path) FROM script WHERE path LIKE 'f/' || $1 || '%' AND archived IS false AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let schedules = sqlx::query_scalar!(
"SELECT count(path) FROM schedule WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let flows = sqlx::query_scalar!(
"SELECT count(path) FROM flow WHERE path LIKE 'f/' || $1 || '%' AND archived IS false AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let apps = sqlx::query_scalar!(
"SELECT count(path) FROM app WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let raw_apps = sqlx::query_scalar!(
"SELECT count(path) FROM raw_app WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let resources = sqlx::query_scalar!(
"SELECT count(path) FROM resource WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
let variables = sqlx::query_scalar!(
"SELECT count(path) FROM variable WHERE path LIKE 'f/' || $1 || '%' AND workspace_id = $2",
name,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(0);
tx.commit().await?;
Ok(Json(FolderUsage {
scripts,
flows,
schedules,
apps: apps + raw_apps,
resources,
variables,
}))
}
async fn delete_folder(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(rsmq): Extension<Option<rsmq_async::MultiplexedRsmq>>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
sqlx::query!(
"DELETE FROM folder WHERE name = $1 AND workspace_id = $2",
name,
w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed.username,
"folder.delete",
ActionKind::Delete,
&w_id,
Some(&name.to_string()),
None,
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", name) },
Some(format!("Folder '{}' deleted", name)),
rsmq,
true,
)
.await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("delete folder at name {}", name))
}
async fn add_owner(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(Owner { owner, .. }): Json<Owner>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
require_is_owner(&authed, &name)?;
sqlx::query!(
"UPDATE folder SET owners = array_append(owners::text[], $1) WHERE name = $2 AND workspace_id = $3 AND NOT $1 = ANY(owners) RETURNING name",
owner,
&name,
&w_id,
)
.fetch_optional(&mut *tx)
.await?;
sqlx::query(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms"
))
.bind(true)
.bind(&name)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed.username,
"folder.add_owner",
ActionKind::Update,
&w_id,
Some(&name.to_string()),
Some([("owner", owner.as_str())].into()),
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("Added {} to folder {}", owner, name))
}
pub async fn get_folders_for_user(
w_id: &str,
username: &str,
groups: &[String],
db: &DB,
) -> Result<Vec<(String, bool, bool)>> {
let mut perms = groups
.into_iter()
.map(|x| format!("g/{}", x))
.collect::<Vec<_>>();
perms.insert(0, format!("u/{}", username));
let folders = sqlx::query!(
"SELECT name, (EXISTS (SELECT 1 FROM (SELECT key, value FROM jsonb_each_text(extra_perms) WHERE key = ANY($1)) t WHERE value::boolean IS true)) as write, $1 && owners::text[] as owner FROM folder
WHERE extra_perms ?| $1 AND workspace_id = $2",
&perms[..],
w_id,
)
.fetch_all(db)
.await?
.into_iter()
.map(|x| (x.name, x.write.unwrap_or(false), x.owner.unwrap_or(false)))
.collect();
Ok(folders)
}
async fn remove_owner(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(Owner { owner, write }): Json<Owner>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
require_is_owner(&authed, &name)?;
sqlx::query!(
"UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 RETURNING name",
owner,
&name,
&w_id,
)
.fetch_optional(&mut *tx)
.await?;
if let Some(write) = write {
sqlx::query(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms"
))
.bind(write)
.bind(&name)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&authed.username,
"folder.remove_owner",
ActionKind::Update,
&w_id,
Some(&name.to_string()),
Some([("owner", owner.as_str())].into()),
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("Removed {} to folder {}", owner, name))
}