Files
windmill/backend/windmill-api/src/users.rs
T

2418 lines
72 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.
*/
#![allow(non_snake_case)]
use std::{sync::Arc, time::Duration};
use crate::db::ApiAuthed;
use crate::{
db::DB,
folders::get_folders_for_user,
utils::require_super_admin,
webhook_util::{InstanceEvent, WebhookShared},
workspaces::invite_user_to_all_auto_invite_worspaces,
BASE_URL, COOKIE_DOMAIN, IS_SECURE, SMTP_CLIENT, SMTP_FROM,
};
use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::{
async_trait,
extract::{Extension, FromRequestParts, OriginalUri, Path, Query},
http::{self, request::Parts},
response::{IntoResponse, Response},
routing::{delete, get, post},
Json, Router,
};
use hyper::{header::LOCATION, StatusCode};
use lazy_static::lazy_static;
use mail_send::mail_builder::MessageBuilder;
use rand::rngs::OsRng;
use regex::Regex;
use retainer::Cache;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use time::OffsetDateTime;
use tower_cookies::{Cookie, Cookies};
use tracing::{Instrument, Span};
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::{self, to_anyhow, Error, JsonResult, Result},
users::SUPERADMIN_SECRET_EMAIL,
utils::{not_found_if_none, rd_string, require_admin, Pagination, StripPath},
};
use windmill_queue::CLOUD_HOSTED;
const TTL_TOKEN_CACHE_S: u64 = 60; // 60s
pub const TTL_TOKEN_DB_H: u32 = 72;
const COOKIE_NAME: &str = "token";
const COOKIE_PATH: &str = "/";
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_users))
.route("/list_usernames", get(list_usernames))
.route("/exists", post(exists_username))
.route("/update/:user", post(update_workspace_user))
.route("/delete/:user", delete(delete_workspace_user))
.route("/is_owner/*path", get(is_owner_of_path))
.route("/whois/:username", get(whois))
.route("/whoami", get(whoami))
.route("/leave", post(leave_workspace))
}
pub fn global_service() -> Router {
Router::new()
.route("/exists/:email", get(exists_email))
.route("/email", get(get_email))
.route("/whoami", get(global_whoami))
.route("/list_invites", get(list_invites))
.route("/decline_invite", post(decline_invite))
.route("/accept_invite", post(accept_invite))
.route("/list_as_super_admin", get(list_users_as_super_admin))
.route("/setpassword", post(set_password))
.route("/create", post(create_user))
.route("/update/:user", post(update_user))
.route("/delete/:user", delete(delete_user))
.route("/tokens/create", post(create_token))
.route("/tokens/delete/:token_prefix", delete(delete_token))
.route("/tokens/list", get(list_tokens))
.route("/tokens/impersonate", post(impersonate))
.route("/usage", get(get_usage))
.route("/all_runnables", get(get_all_runnables))
.route("/refresh_token", get(refresh_token))
// .route("/list_invite_codes", get(list_invite_codes))
// .route("/create_invite_code", post(create_invite_code))
// .route("/signup", post(signup))
// .route("/lost_password", post(lost_password))
// .route("/use_magic_link", get(use_magic_link))
}
pub fn make_unauthed_service() -> Router {
Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.route("/logout", get(logout))
}
pub struct AuthCache {
cache: Cache<(String, String), ApiAuthed>,
db: DB,
superadmin_secret: Option<String>,
}
impl AuthCache {
pub fn new(db: DB, superadmin_secret: Option<String>) -> Self {
AuthCache { cache: Cache::new(), db, superadmin_secret }
}
pub async fn invalidate(&self, w_id: &str, token: String) {
self.cache.remove(&(w_id.to_string(), token)).await;
}
pub async fn get_authed(&self, w_id: Option<String>, token: &str) -> Option<ApiAuthed> {
let key = (
w_id.as_ref().unwrap_or(&"".to_string()).to_string(),
token.to_string(),
);
let s = self.cache.get(&key).await.map(|c| c.to_owned());
match s {
a @ Some(_) => a,
None => {
let user_o = sqlx::query_as::<_, (Option<String>, Option<String>, bool, Option<Vec<String>>)>(
"UPDATE token SET last_used_at = now() WHERE token = $1 AND (expiration > NOW() \
OR expiration IS NULL) RETURNING owner, email, super_admin, scopes",
)
.bind(token)
.fetch_optional(&self.db)
.await
.ok()
.flatten();
if let Some(user) = user_o {
let authed_o = {
match user {
(Some(owner), Some(email), super_admin, _) if w_id.is_some() => {
if let Some((prefix, name)) = owner.split_once('/') {
if prefix == "u" {
let (is_admin, is_operator) = if super_admin {
(true, false)
} else {
let r = sqlx::query!(
"SELECT is_admin, operator FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id.as_ref().unwrap()
)
.fetch_one(&self.db)
.await
.ok();
if let Some(r) = r {
(r.is_admin, r.operator)
} else {
(false, true)
}
};
let w_id = &w_id.unwrap();
let groups =
get_groups_for_user(w_id, &name, &email, &self.db)
.await
.ok()
.unwrap_or_default();
let folders =
get_folders_for_user(w_id, &name, &groups, &self.db)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: name.to_string(),
is_admin,
is_operator,
groups,
folders,
scopes: None,
})
} else {
let groups = vec![name.to_string()];
let folders = get_folders_for_user(
&w_id.unwrap(),
"",
&groups,
&self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: format!("group-{name}"),
is_admin: false,
groups,
is_operator: false,
folders,
scopes: None,
})
}
} else {
let groups = vec![];
let folders = vec![];
Some(ApiAuthed {
email: email,
username: owner,
is_admin: super_admin,
is_operator: true,
groups,
folders,
scopes: None,
})
}
}
(_, Some(email), super_admin, scopes) => {
if w_id.is_some() {
let row_o = sqlx::query_as::<_, (String, bool, bool)>(
"SELECT username, is_admin, operator FROM usr where email = $1 AND \
workspace_id = $2 AND disabled = false",
)
.bind(&email)
.bind(&w_id.as_ref().unwrap())
.fetch_optional(&self.db)
.await
.unwrap_or(Some(("error".to_string(), false, false)));
match row_o {
Some((username, is_admin, is_operator)) => {
let groups = get_groups_for_user(
&w_id.as_ref().unwrap(),
&username,
&email,
&self.db,
)
.await
.ok()
.unwrap_or_default();
let folders = get_folders_for_user(
&w_id.unwrap(),
&username,
&groups,
&self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email,
username,
is_admin: is_admin || super_admin,
is_operator,
groups,
folders,
scopes,
})
}
None if super_admin => Some(ApiAuthed {
email: email.clone(),
username: email,
is_admin: super_admin,
is_operator: false,
groups: vec![],
folders: vec![],
scopes,
}),
None => None,
}
} else {
Some(ApiAuthed {
email: email.to_string(),
username: email,
is_admin: super_admin,
is_operator: true,
groups: Vec::new(),
folders: Vec::new(),
scopes,
})
}
}
_ => None,
}
};
if let Some(authed) = authed_o.as_ref() {
self.cache
.insert(key, authed.clone(), Duration::from_secs(TTL_TOKEN_CACHE_S))
.await;
}
authed_o
} else if self
.superadmin_secret
.as_ref()
.map(|x| x == token)
.unwrap_or(false)
{
Some(ApiAuthed {
email: SUPERADMIN_SECRET_EMAIL.to_string(),
username: "superadmin_secret".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
})
} else {
None
}
}
}
}
pub async fn monitor(&self) {
self.cache.monitor(20, 0.25, Duration::from_secs(10)).await;
}
}
async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
let auth_header = parts
.headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "));
let from_cookie = match auth_header {
Some(x) => Some(x.to_owned()),
None => Extension::<Cookies>::from_request_parts(parts, state)
.await
.ok()
.and_then(|cookies| cookies.get(COOKIE_NAME).map(|c| c.value().to_owned())),
};
#[derive(Deserialize)]
struct Token {
token: Option<String>,
}
match from_cookie {
Some(token) => Some(token),
None => Query::<Token>::from_request_parts(parts, state)
.await
.ok()
.and_then(|token| token.token.clone()),
}
}
#[derive(Clone, Debug)]
pub struct Tokened {
pub token: String,
}
#[async_trait]
impl<S> FromRequestParts<S> for Tokened
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(Tokened { token: "".to_string() });
};
let already_tokened = parts.extensions.get::<Tokened>();
if let Some(tokened) = already_tokened {
Ok(tokened.clone())
} else {
let token_o = extract_token(parts, state).await;
if let Some(token) = token_o {
let tokened = Self { token };
parts.extensions.insert(tokened.clone());
Ok(tokened)
} else {
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
}
}
}
}
pub async fn maybe_refresh_folders(
path: &str,
w_id: &str,
authed: ApiAuthed,
db: &DB,
) -> ApiAuthed {
if authed.is_admin {
return authed;
}
let splitted = path.split('/').collect::<Vec<_>>();
if splitted.len() >= 2
&& splitted[0] == "f"
&& !authed.folders.iter().any(|(f, _, _)| f == splitted[1])
{
let name = &authed.username;
let groups = get_groups_for_user(w_id, name, &authed.email, db)
.await
.ok()
.unwrap_or_default();
let folders = get_folders_for_user(w_id, name, &groups, db)
.await
.ok()
.unwrap_or_default();
ApiAuthed { folders, ..authed }
} else {
authed
}
}
#[async_trait]
impl<S> FromRequestParts<S> for ApiAuthed
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(ApiAuthed {
email: "".to_owned(),
username: "".to_owned(),
is_admin: false,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
});
};
let already_authed = parts.extensions.get::<ApiAuthed>();
if let Some(authed) = already_authed {
Ok(authed.clone())
} else {
let already_tokened = parts.extensions.get::<Tokened>();
let token_o = if let Some(token) = already_tokened {
Some(token.token.clone())
} else {
extract_token(parts, state).await
};
let original_uri = OriginalUri::from_request_parts(parts, state)
.await
.ok()
.map(|x| x.0)
.unwrap_or_default();
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
Some(path_vec[3].to_owned())
} else {
None
};
if let Some(token) = token_o {
if let Ok(Extension(cache)) =
Extension::<Arc<AuthCache>>::from_request_parts(parts, state).await
{
if let Some(authed) = cache.get_authed(workspace_id.clone(), &token).await {
parts.extensions.insert(authed.clone());
if authed.scopes.is_some() && (path_vec.len() < 3 || path_vec[4] != "jobs")
{
return Err((
StatusCode::UNAUTHORIZED,
format!("Unauthorized scoped token: {:?}", authed.scopes),
));
}
Span::current().record("username", &authed.username.as_str());
Span::current().record("email", &authed.email);
if let Some(workspace_id) = workspace_id {
Span::current().record("workspace_id", &workspace_id);
}
return Ok(authed);
}
}
}
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
}
}
}
pub fn check_scopes<F>(authed: &ApiAuthed, required: F) -> error::Result<()>
where
F: FnOnce() -> String,
{
if let Some(scopes) = &authed.scopes {
let req = &required();
if !scopes.contains(req) {
return Err(Error::BadRequest(format!("missing required scope: {req}")));
}
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct OptAuthed(pub Option<ApiAuthed>);
#[async_trait]
impl<S> FromRequestParts<S> for OptAuthed
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
ApiAuthed::from_request_parts(parts, state)
.await
.map(|authed| Self(Some(authed)))
.or_else(|_| Ok(Self(None)))
}
}
#[derive(FromRow, Serialize)]
pub struct User {
pub workspace_id: String,
pub email: String,
pub username: String,
pub is_admin: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub operator: bool,
pub disabled: bool,
pub role: Option<String>,
}
#[derive(FromRow, Serialize)]
pub struct Usage {
pub executions: i64,
}
#[derive(Serialize)]
pub struct UserWithUsage {
#[serde(flatten)]
pub user: User,
pub usage: Usage,
}
#[derive(FromRow, Serialize, Debug)]
pub struct GlobalUserInfo {
email: String,
login_type: Option<String>,
super_admin: bool,
verified: bool,
name: Option<String>,
company: Option<String>,
}
#[derive(Serialize)]
pub struct UserInfo {
pub workspace_id: String,
pub email: String,
pub username: String,
pub is_admin: bool,
pub is_super_admin: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub groups: Vec<String>,
pub operator: bool,
pub disabled: bool,
pub role: Option<String>,
pub folders: Vec<String>,
pub folders_owners: Vec<String>,
}
#[derive(FromRow, Serialize)]
pub struct WorkspaceInvite {
pub workspace_id: String,
pub email: String,
pub is_admin: bool,
pub operator: bool,
}
#[derive(FromRow, Serialize)]
pub struct InviteCode {
pub code: String,
pub seats_left: i32,
pub seats_given: i32,
}
#[derive(Deserialize)]
pub struct NewInviteCode {
pub code: String,
pub seats: i32,
}
#[derive(FromRow)]
pub struct MagicLink {
pub email: String,
pub token: String,
pub expiration: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize)]
pub struct UseMagicLink {
pub email: String,
pub token: String,
}
#[derive(Deserialize)]
pub struct NewUser {
pub email: String,
pub password: String,
pub super_admin: bool,
pub name: Option<String>,
pub company: Option<String>,
}
#[derive(Deserialize)]
pub struct AcceptInvite {
pub workspace_id: String,
pub username: String,
}
#[derive(Deserialize)]
pub struct DeclineInvite {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct EditUser {
pub is_super_admin: Option<bool>,
}
#[derive(Deserialize)]
pub struct EditWorkspaceUser {
pub is_admin: Option<bool>,
pub operator: Option<bool>,
pub disabled: Option<bool>,
}
#[derive(Deserialize)]
pub struct EditPassword {
pub password: String,
}
#[derive(FromRow, Serialize)]
pub struct TruncatedToken {
pub label: Option<String>,
pub token_prefix: Option<String>,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used_at: chrono::DateTime<chrono::Utc>,
pub scopes: Option<Vec<String>>,
}
#[derive(Deserialize)]
pub struct NewToken {
pub label: Option<String>,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
pub impersonate_email: Option<String>,
pub scopes: Option<Vec<String>>,
}
#[derive(Deserialize)]
pub struct Login {
pub email: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct Signup {
pub email: String,
pub password: String,
pub name: Option<String>,
pub company: Option<String>,
}
#[derive(Deserialize)]
pub struct LostPassword {
pub email: String,
}
#[derive(Deserialize)]
struct WorkspaceUsername {
pub username: String,
}
async fn exists_username(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(WorkspaceUsername { username }): Json<WorkspaceUsername>,
) -> JsonResult<bool> {
let mut tx = user_db.begin(&authed).await?;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2)",
&w_id,
&username
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
tx.commit().await?;
Ok(Json(exists))
}
async fn list_users(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<UserWithUsage>> {
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query(
"
SELECT usr.*, usage.*
FROM usr
, LATERAL (
SELECT COALESCE(SUM(duration_ms + 1000)/1000 , 0) executions
FROM completed_job
WHERE workspace_id = $1
AND job_kind NOT IN ('flow', 'flowpreview')
AND email = usr.email
AND now() - '1 week'::interval < created_at
) usage
WHERE workspace_id = $1
",
)
.bind(&w_id)
.try_map(|row| {
// flatten not released yet https://github.com/launchbadge/sqlx/pull/1959
Ok(UserWithUsage { user: FromRow::from_row(&row)?, usage: FromRow::from_row(&row)? })
})
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn list_users_as_super_admin(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<GlobalUserInfo>> {
require_super_admin(&db, &authed.email).await?;
let per_page = pagination.per_page.unwrap_or(10000).max(1);
let offset = (pagination.page.unwrap_or(1).max(1) - 1) * per_page;
let rows = sqlx::query_as!(
GlobalUserInfo,
"SELECT email, login_type::text, verified, super_admin, name, company from password LIMIT \
$1 OFFSET $2",
per_page as i32,
offset as i32
)
.fetch_all(&db)
.await?;
Ok(Json(rows))
}
// async fn list_invite_codes(
// authed: ApiAuthed,
// Extension(db): Extension<DB>,
// Query(pagination): Query<Pagination>
// ) -> JsonResult<Vec<InviteCode>> {
// let mut tx = db.begin().await?;
// require_super_admin(&mut tx, authed.email).await?;
// let (per_page, offset) = crate::utils::paginate(pagination);
// let rows = sqlx::query_as!(InviteCode, "SELECT * from invite_code LIMIT $1 OFFSET $2", per_page as i32, offset as i32)
// .fetch_all(&mut tx)
// .await?;
// tx.commit().await?;
// Ok(Json(rows))
// }
async fn list_usernames(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<String>> {
if *CLOUD_HOSTED && w_id == "demo" {
return Ok(Json(vec![
authed.username,
"other_usernames_redacted_in_demo_workspace".to_string(),
]));
}
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_scalar!("SELECT username from usr WHERE workspace_id = $1", &w_id)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn list_invites(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<Vec<WorkspaceInvite>> {
let mut tx = db.begin().await?;
let rows = sqlx::query_as!(
WorkspaceInvite,
"SELECT * from workspace_invite WHERE email = $1",
authed.email
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
#[derive(Deserialize)]
struct LogoutQuery {
rd: Option<String>,
}
async fn logout(
Tokened { token }: Tokened,
cookies: Cookies,
Extension(db): Extension<DB>,
Query(LogoutQuery { rd }): Query<LogoutQuery>,
) -> Result<Response> {
let mut cookie = Cookie::new(COOKIE_NAME, "");
cookie.set_path(COOKIE_PATH);
if COOKIE_DOMAIN.is_some() {
cookie.set_domain(COOKIE_DOMAIN.clone().unwrap());
}
cookies.remove(cookie);
let mut tx = db.begin().await?;
let email = sqlx::query_scalar!("DELETE FROM token WHERE token = $1 RETURNING email", token)
.fetch_optional(&mut *tx)
.await?;
if let Some(email) = email {
audit_log(
&mut *tx,
&email.unwrap_or("noemail".to_string()),
"users.logout",
ActionKind::Delete,
"global",
Some(&truncate_token(&token)),
None,
)
.await?;
}
tx.commit().await?;
if let Some(rd) = rd {
Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response())
} else {
Ok((StatusCode::OK, "logged out successfully".to_string()).into_response())
}
}
async fn whoami(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { username, email, is_admin, groups, folders, .. }: ApiAuthed,
) -> JsonResult<UserInfo> {
let user = get_user(&w_id, &username, &db).await?;
if let Some(user) = user {
Ok(Json(user))
} else {
Ok(Json(UserInfo {
workspace_id: w_id,
email: email.clone(),
username: email,
is_admin,
is_super_admin: is_admin,
created_at: chrono::Utc::now(),
groups: groups,
operator: false,
disabled: false,
role: Some("superadmin".to_string()),
folders: folders
.clone()
.into_iter()
.filter_map(|x| if x.1 { Some(x.0) } else { None })
.collect(),
folders_owners: folders
.into_iter()
.filter_map(|x| if x.2 { Some(x.0) } else { None })
.collect(),
}))
}
}
async fn global_whoami(
Extension(db): Extension<DB>,
ApiAuthed { email, .. }: ApiAuthed,
Tokened { token }: Tokened,
) -> JsonResult<GlobalUserInfo> {
let user = sqlx::query_as!(
GlobalUserInfo,
"SELECT email, login_type::TEXT, super_admin, verified, name, company FROM password WHERE \
email = $1",
email
)
.fetch_one(&db)
.await
.map_err(|e| Error::InternalErr(format!("fetching global identity: {e}")));
if let Ok(user) = user {
Ok(Json(user))
} else if std::env::var("SUPERADMIN_SECRET").ok() == Some(token) {
Ok(Json(GlobalUserInfo {
email: email.clone(),
login_type: Some("superadmin_secret".to_string()),
super_admin: true,
verified: true,
name: None,
company: None,
}))
} else {
Err(user.unwrap_err())
}
}
async fn exists_email(Extension(db): Extension<DB>, Path(email): Path<String>) -> JsonResult<bool> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)",
email
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
async fn get_email(ApiAuthed { email, .. }: ApiAuthed) -> Result<String> {
Ok(email)
}
async fn get_usage(
Extension(db): Extension<DB>,
ApiAuthed { email, .. }: ApiAuthed,
) -> Result<String> {
let usage = sqlx::query_scalar!(
"
SELECT usage.usage FROM usage
WHERE is_workspace = false
AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)
AND id = $1",
email
)
.fetch_optional(&db)
.await?
.unwrap_or(0);
Ok(usage.to_string())
}
async fn get_user(w_id: &str, username: &str, db: &DB) -> Result<Option<UserInfo>> {
let user = sqlx::query_as!(
User,
"SELECT * FROM usr where username = $1 AND workspace_id = $2",
username,
w_id
)
.fetch_optional(db)
.await?;
let is_super_admin = sqlx::query_scalar!(
"SELECT super_admin FROM password WHERE email = $1",
user.as_ref().map(|x| &x.email)
)
.fetch_optional(db)
.await?
.unwrap_or(false);
let groups = get_groups_for_user(
&w_id,
username,
&user
.as_ref()
.map(|x| x.email.to_string())
.unwrap_or_else(|| "".to_string()),
db,
)
.await?;
let folders = get_folders_for_user(&w_id, username, &groups, db).await?;
Ok(user.map(|usr| UserInfo {
groups,
workspace_id: usr.workspace_id,
email: usr.email,
username: usr.username,
is_admin: usr.is_admin,
is_super_admin,
created_at: usr.created_at,
operator: usr.operator,
disabled: usr.disabled,
role: usr.role,
folders: folders
.clone()
.into_iter()
.filter_map(|x| if x.1 { Some(x.0) } else { None })
.collect(),
folders_owners: folders
.into_iter()
.filter_map(|x| if x.2 { Some(x.0) } else { None })
.collect(),
}))
}
pub async fn get_groups_for_user(
w_id: &str,
username: &str,
email: &str,
db: &DB,
) -> Result<Vec<String>> {
let groups = sqlx::query_scalar!(
"SELECT group_ FROM usr_to_group where usr = $1 AND workspace_id = $2 UNION ALL SELECT igroup FROM email_to_igroup WHERE email = $3",
username,
w_id,
email
)
.fetch_all(db)
.await?
.into_iter().filter_map(|x| x)
.collect();
Ok(groups)
}
pub async fn is_owner_of_path(
authed: ApiAuthed,
Path((_w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<bool> {
let path = path.to_path();
if authed.is_admin {
Ok(Json(true))
} else {
Ok(Json(require_owner_of_path(&authed, path).is_ok()))
}
}
pub fn require_owner_of_path(authed: &ApiAuthed, path: &str) -> Result<()> {
if authed.is_admin {
return Ok(());
}
if !path.is_empty() {
let splitted = path.split("/").collect::<Vec<&str>>();
if splitted[0] == "u" {
if splitted[1] == authed.username {
Ok(())
} else {
Err(Error::BadRequest(format!(
"only the owner {} is authorized to perform this operation",
splitted[1]
)))
}
} else if splitted[0] == "f" {
crate::folders::require_is_owner(authed, splitted[1])
} else {
Err(Error::BadRequest(format!(
"Not recognized path kind: {}",
path
)))
}
} else {
Err(Error::BadRequest(format!(
"Cannot be owner of an empty path"
)))
}
}
pub fn get_perm_in_extra_perms_for_authed(
v: serde_json::Value,
authed: &ApiAuthed,
) -> Option<bool> {
match v {
serde_json::Value::Object(obj) => {
let mut keys = vec![format!("u/{}", authed.username)];
for g in authed.groups.iter() {
keys.push(format!("g/{}", g));
}
let mut res = None;
for k in keys {
if let Some(v) = obj.get(&k) {
if let Some(v) = v.as_bool() {
if v {
return Some(true);
}
res = Some(v);
}
}
}
res
}
_ => None,
}
}
pub async fn require_is_writer(
authed: &ApiAuthed,
path: &str,
w_id: &str,
db: DB,
query: &str,
kind: &str,
) -> Result<()> {
if authed.is_admin {
return Ok(());
}
if !path.is_empty() {
if require_owner_of_path(authed, path).is_ok() {
return Ok(());
}
if path.starts_with("f/") && path.split('/').count() >= 2 {
let folder = path.split('/').nth(1).unwrap();
let extra_perms = sqlx::query_scalar!(
"SELECT extra_perms FROM folder WHERE name = $1 AND workspace_id = $2",
folder,
w_id
)
.fetch_optional(&db)
.await?;
if let Some(perms) = extra_perms {
let is_folder_writer =
get_perm_in_extra_perms_for_authed(perms, authed).unwrap_or(false);
if is_folder_writer {
return Ok(());
}
}
}
let extra_perms = sqlx::query_scalar(query)
.bind(path)
.bind(w_id)
.fetch_optional(&db)
.await?;
if let Some(perms) = extra_perms {
let perm = get_perm_in_extra_perms_for_authed(perms, authed);
match perm {
Some(true) => Ok(()),
Some(false) => Err(Error::BadRequest(format!(
"User {} is not a writer of {kind} path {path}",
authed.username
))),
None => Err(Error::BadRequest(format!(
"User {} has neither read or write permission on {kind} {path}",
authed.username
))),
}
} else {
Err(Error::BadRequest(format!(
"{path} does not exist yet and user {} is not an owner of the parent folder",
authed.username
)))
}
} else {
Err(Error::BadRequest(format!(
"Cannot be writer of an empty path"
)))
}
}
async fn whois(
Extension(db): Extension<DB>,
Path((w_id, username)): Path<(String, String)>,
) -> JsonResult<UserInfo> {
let user_o = get_user(&w_id, &username, &db).await?;
let user = not_found_if_none(user_o, "User", username)?;
Ok(Json(user))
}
// async fn create_invite_code(
// ApiAuthed { email, .. }: ApiAuthed,
// Extension(db): Extension<DB>,
// Json(nu): Json<NewInviteCode>,
// ) -> Result<(StatusCode, String)> {
// let mut tx = db.begin().await?;
// require_super_admin(&mut tx, email).await?;
// sqlx::query!(
// "INSERT INTO invite_code
// (code, seats_left)
// VALUES ($1, $2)",
// nu.code,
// nu.seats
// )
// .execute(&mut tx)
// .await?;
// tx.commit().await?;
// Ok((
// StatusCode::CREATED,
// format!("new invite code {}", nu.code),
// ))
// }
async fn decline_invite(
ApiAuthed { email, .. }: ApiAuthed,
Extension(db): Extension<DB>,
Json(nu): Json<DeclineInvite>,
) -> Result<(StatusCode, String)> {
let mut tx = db.begin().await?;
let is_admin = sqlx::query_scalar!(
"DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin",
nu.workspace_id,
email,
)
.fetch_optional(&mut *tx)
.await?;
audit_log(
&mut *tx,
&email,
"users.decline_invite",
ActionKind::Delete,
&nu.workspace_id,
Some(&email),
None,
)
.await?;
tx.commit().await?;
if is_admin.is_some() {
Ok((
StatusCode::OK,
format!(
"user {} declined invite to workspace {}",
&email, nu.workspace_id
),
))
} else {
Err(Error::NotFound(format!("invite for {email} not found")))
}
}
lazy_static! {
pub static ref VALID_USERNAME: Regex = Regex::new(r#"^[a-zA-Z_0-9]+$"#).unwrap();
pub static ref JOB_RETENTION_SECS: u32 = std::env::var("JOB_RETENTION_SECS")
.ok()
.and_then(|x| x.parse::<u32>().ok())
.unwrap_or(60 * 60 * 24 * 60); // 60 days
}
async fn accept_invite(
ApiAuthed { email, .. }: ApiAuthed,
Extension(webhook): Extension<WebhookShared>,
Extension(db): Extension<DB>,
Json(nu): Json<AcceptInvite>,
) -> Result<(StatusCode, String)> {
if !VALID_USERNAME.is_match(&nu.username) {
return Err(windmill_common::error::Error::BadRequest(format!(
"Usermame can only contain alphanumeric characters and underscores"
)));
}
let mut tx = db.begin().await?;
let r = sqlx::query!(
"DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin, operator",
nu.workspace_id,
email,
)
.fetch_optional(&mut *tx)
.await?;
let is_some = r.is_some();
if let Some(r) = r {
tx = add_user_to_workspace(
&nu.workspace_id,
&email,
&nu.username,
r.is_admin,
r.operator,
tx,
)
.await?;
audit_log(
&mut *tx,
&nu.username,
"users.accept_invite",
ActionKind::Create,
&nu.workspace_id,
Some(&email),
None,
)
.await?;
tx.commit().await?;
}
if is_some {
webhook.send_instance_event(InstanceEvent::UserJoinedWorkspace {
email: email.clone(),
workspace: nu.workspace_id.clone(),
username: nu.username.clone(),
});
Ok((
StatusCode::CREATED,
format!(
"user {} accepted invite to workspace {}",
&email, nu.workspace_id
),
))
} else {
Err(Error::NotFound(format!("invite for {email} not found")))
}
}
async fn add_user_to_workspace<'c>(
w_id: &str,
email: &str,
username: &str,
is_admin: bool,
operator: bool,
mut tx: sqlx::Transaction<'c, sqlx::Postgres>,
) -> error::Result<sqlx::Transaction<'c, sqlx::Postgres>> {
let already_exists_username = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2)",
&w_id,
username,
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if already_exists_username {
return Err(Error::BadRequest(format!(
"user with username {} already exists in workspace {}",
username, w_id
)));
}
if !VALID_USERNAME.is_match(username) {
return Err(windmill_common::error::Error::BadRequest(format!(
"Usermame can only contain alphanumeric characters and underscores"
)));
}
let already_exists_email = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)",
&w_id,
username,
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if already_exists_email {
return Err(Error::BadRequest(format!(
"user with email {} already exists in workspace {}",
email, w_id
)));
}
sqlx::query!(
"INSERT INTO usr
(workspace_id, email, username, is_admin, operator)
VALUES ($1, $2, $3, $4, $5)",
&w_id,
email,
username,
is_admin,
operator
)
.execute(&mut *tx)
.await?;
sqlx::query_as!(
Group,
"INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
&w_id,
username,
"all",
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
username,
"users.add_to_workspace",
ActionKind::Create,
&w_id,
Some(email),
None,
)
.await?;
Ok(tx)
}
async fn update_workspace_user(
ApiAuthed { username, is_admin, .. }: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, username_to_update)): Path<(String, String)>,
Json(eu): Json<EditWorkspaceUser>,
) -> Result<String> {
let mut tx = db.begin().await?;
require_admin(is_admin, &username)?;
if let Some(a) = eu.is_admin {
sqlx::query_scalar!(
"UPDATE usr SET is_admin = $1 WHERE username = $2 AND workspace_id = $3",
a,
&username_to_update,
&w_id
)
.execute(&mut *tx)
.await?;
}
if let Some(a) = eu.operator {
sqlx::query_scalar!(
"UPDATE usr SET operator = $1 WHERE username = $2 AND workspace_id = $3",
a,
&username_to_update,
&w_id
)
.execute(&mut *tx)
.await?;
}
if let Some(a) = eu.disabled {
sqlx::query_scalar!(
"UPDATE usr SET disabled = $1 WHERE username = $2 AND workspace_id = $3",
a,
&username_to_update,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&username,
"users.update",
ActionKind::Update,
&w_id,
Some(&username_to_update),
None,
)
.await?;
tx.commit().await?;
Ok(format!("user {username} updated"))
}
async fn update_user(
ApiAuthed { email, .. }: ApiAuthed,
Path(email_to_update): Path<String>,
Extension(db): Extension<DB>,
Json(eu): Json<EditUser>,
) -> Result<String> {
require_super_admin(&db, &email).await?;
let mut tx = db.begin().await?;
if let Some(sa) = eu.is_super_admin {
sqlx::query_scalar!(
"UPDATE password SET super_admin = $1 WHERE email = $2",
sa,
&email_to_update
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&email,
"users.update",
ActionKind::Update,
"global",
Some(&email_to_update),
None,
)
.await?;
tx.commit().await?;
Ok(format!("email {} updated", &email_to_update))
}
async fn delete_user(
ApiAuthed { email, .. }: ApiAuthed,
Path(email_to_delete): Path<String>,
Extension(db): Extension<DB>,
) -> Result<String> {
require_super_admin(&db, &email).await?;
let mut tx = db.begin().await?;
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
.execute(&mut *tx)
.await?;
let usernames = sqlx::query_scalar!(
"DELETE FROM usr WHERE email = $1 RETURNING username",
&email_to_delete
)
.fetch_all(&mut *tx)
.await?;
for username in usernames {
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM usr_to_group WHERE usr = $1", &username)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM workspace_invite WHERE email = $1",
&email_to_delete
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&email,
"users.delete",
ActionKind::Delete,
"global",
Some(&email_to_delete),
None,
)
.await?;
tx.commit().await?;
Ok(format!("email {} deleted", &email_to_delete))
}
lazy_static::lazy_static! {
pub static ref NEW_USER_WEBHOOK: Option<String> = std::env::var("NEW_USER_WEBHOOK").ok();
}
async fn create_user(
ApiAuthed { email, .. }: ApiAuthed,
Extension(db): Extension<DB>,
Extension(webhook): Extension<WebhookShared>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
Json(mut nu): Json<NewUser>,
) -> Result<(StatusCode, String)> {
require_super_admin(&db, &email).await?;
let mut tx = db.begin().await?;
nu.email = nu.email.to_lowercase();
if nu.email == SUPERADMIN_SECRET_EMAIL {
return Err(Error::BadRequest(
"The superadmin email is a reserved email".into(),
));
}
sqlx::query!(
"INSERT INTO password(email, verified, password_hash, login_type, super_admin, name, \
company)
VALUES ($1, $2, $3, 'password', $4, $5, $6)",
&nu.email,
true,
&hash_password(argon2, nu.password.clone())?,
&nu.super_admin,
nu.name,
nu.company
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&email,
"users.add_global",
ActionKind::Create,
"global",
Some(&nu.email),
None,
)
.await?;
tx.commit().await?;
invite_user_to_all_auto_invite_worspaces(&db, &nu.email).await?;
send_email_if_possible(
"Invited to Windmill",
&format!(
"You have been granted access to Windmill by {email}.
Log in and change your password: {}/user/login?email={}&password={}&rd=%2F%23user-settings
You can then join or create a workspace. Happy building!",
*BASE_URL, &nu.email, &nu.password
),
&nu.email,
);
webhook.send_instance_event(InstanceEvent::UserAdded { email: nu.email.clone() });
Ok((StatusCode::CREATED, format!("email {} created", nu.email)))
}
pub fn send_email_if_possible(subject: &str, content: &str, to: &str) {
let subject = subject.to_string();
let content = content.to_string();
let to = to.to_string();
tokio::spawn(async move {
if let Err(e) = send_email_if_possible_intern(&subject, &content, &to).await {
tracing::error!("Failed to send email to {}: {}", &to, e);
}
});
}
pub async fn send_email_if_possible_intern(subject: &str, content: &str, to: &str) -> Result<()> {
if let Some(ref smtp) = *SMTP_CLIENT {
let message = MessageBuilder::new()
.from(("Windmill", SMTP_FROM.as_str()))
.to(to)
.subject(subject)
.text_body(content);
smtp.connect()
.await
.map_err(to_anyhow)?
.send(message)
.await
.map_err(to_anyhow)?;
tracing::info!("Sent email to {to}: {subject}");
}
return Ok(());
}
async fn delete_workspace_user(
ApiAuthed { username, is_admin, .. }: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, username_to_delete)): Path<(String, String)>,
) -> Result<String> {
let mut tx = db.begin().await?;
require_admin(is_admin, &username)?;
let email_to_delete_o = sqlx::query_scalar!(
"SELECT email FROM usr where username = $1 AND workspace_id = $2",
username_to_delete,
&w_id,
)
.fetch_optional(&db)
.await?;
let email_to_delete = not_found_if_none(email_to_delete_o, "User", &username_to_delete)?;
let username = sqlx::query_scalar!(
"DELETE FROM usr WHERE email = $1 AND workspace_id = $2 RETURNING username",
email_to_delete,
&w_id
)
.fetch_one(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM usr_to_group WHERE usr = $1 AND workspace_id = $2",
&username,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&username,
"users.delete",
ActionKind::Delete,
&w_id,
Some(&username_to_delete),
None,
)
.await?;
tx.commit().await?;
Ok(format!("username {} deleted", username_to_delete))
}
async fn set_password(
Extension(db): Extension<DB>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
ApiAuthed { username, email, .. }: ApiAuthed,
Json(EditPassword { password }): Json<EditPassword>,
) -> Result<String> {
let mut tx = db.begin().await?;
let custom_type = sqlx::query_scalar!(
"SELECT login_type::TEXT FROM password WHERE email = $1",
&email
)
.fetch_one(&mut *tx)
.await
.map_err(|e| Error::InternalErr(format!("setting password: {e}")))?
.unwrap_or("".to_string());
if custom_type != "password".to_string() {
return Err(Error::BadRequest(format!(
"login type for {email} is of type {custom_type}. Cannot set password."
)));
}
sqlx::query!(
"UPDATE password SET password_hash = $1 WHERE email = $2",
&hash_password(argon2, password)?,
&email,
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&username,
"users.setpassword",
ActionKind::Update,
"global",
Some(&email),
None,
)
.await?;
tx.commit().await?;
Ok(format!("password of {} updated", email))
}
pub fn hash_password(argon2: Arc<Argon2>, password: String) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| Error::InternalErr(e.to_string()))?
.to_string();
Ok(password_hash)
}
// async fn lost_password(
// Extension(db): Extension<DB>,
// Extension(es): Extension<Arc<EmailSender>>,
// TypedHeader(host): TypedHeader<headers::Host>,
// Json(LostPassword {
// email
// }): Json<LostPassword>,
// ) -> Result<String> {
// let mut tx = db.begin().await?;
// let exists = sqlx::query_scalar!(
// "SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)",
// &email)
// .fetch_one(&mut tx)
// .await?
// .unwrap_or(false);
// if !exists {
// return Err(Error::NotFound(format!("no user found at email {email}")))
// }
// let already = sqlx::query_scalar!(
// "SELECT EXISTS(SELECT 1 FROM magic_link WHERE email = $1)",
// &email)
// .fetch_one(&mut tx)
// .await?
// .unwrap_or(false);
// if already {
// return Err(Error::BadRequest(format!("a magic link was already sent at {email}")))
// }
// let tx = create_magic_link(&host.hostname(), &email, &es, tx).await?;
// tx.commit().await?;
// Ok(format!("Magic link sent to {email}"))
// }
// async fn use_magic_link(
// cookies: Cookies,
// Extension(db): Extension<DB>,
// Query(UseMagicLink {
// email,
// token
// }): Query<UseMagicLink>,
// ) -> Result<String> {
// let mut tx = db.begin().await?;
// let email_o = sqlx::query_scalar!(
// "DELETE FROM magic_link WHERE email = $1 AND token = $2
// RETURNING email", email, token
// )
// .fetch_optional(&mut tx)
// .await?;
// if let Some(email) = email_o {
// let is_super_admin = sqlx::query_scalar!("UPDATE password SET verified = true WHERE email = $1 RETURNING super_admin", email)
// .fetch_optional(&mut tx)
// .await?
// .unwrap_or(false);
// let token = create_session_token(&email, is_super_admin, &mut tx, cookies).await?;
// tx.commit().await?;
// Ok(token)
// } else {
// Err(Error::NotFound(format!("magic link for {email} not found")))
// }
// }
// async fn signup(
// TypedHeader(host): TypedHeader<headers::Host>,
// Extension(db): Extension<DB>,
// Extension(argon2): Extension<Arc<Argon2<'_>>>,
// Extension(es): Extension<Arc<EmailSender>>,
// Json(Signup {
// email,
// password,
// name,
// company
// }): Json<Signup>,
// ) -> Result<(StatusCode, String)> {
// let mut tx = db.begin().await?;
// let email = sqlx::query_scalar!(
// "INSERT INTO password (email, password_hash, name, company) VALUES ($1, $2, $3, $4) RETURNING email",
// &email, &hash_password(argon2, password)?, name, company)
// .fetch_optional(&mut tx)
// .await?;
// if let Some(email) = email {
// let tx = create_magic_link(&host.hostname(), &email, &es, tx).await?;
// tx.commit().await?;
// Ok((
// StatusCode::CREATED,
// format!("user with email {} created", email),
// ))
// } else {
// Err(Error::BadRequest("Invalid login".to_string()))
// }
// }
// async fn create_magic_link<'c>(host: &str, email: &str, es: &EmailSender, mut tx: sqlx::Transaction<'c, sqlx::Postgres>) -> error::Result<sqlx::Transaction<'c, sqlx::Postgres>> {
// let token = gen_token();
// sqlx::query!(
// "INSERT INTO magic_link
// (email, token)
// VALUES ($1, $2)",
// email,
// &token
// )
// .execute(&mut tx)
// .await?;
// let encoded_token = urlencoding::encode(&token);
// let encoded_email = urlencoding::encode(email);
// es.send_email(Message::builder()
// .to(email.parse().unwrap())
// .subject("New magic link")
// .body(format!("Magic link: https://{host}/magic_link?token={encoded_token}&email={encoded_email}"))
// .unwrap()).await?;
// audit_log(
// &mut tx,
// email,
// "users.magic_link",
// ActionKind::Create,
// "global",
// Some(email),
// None,
// )
// .await?;
// Ok(tx)
// }
async fn login(
cookies: Cookies,
Extension(db): Extension<DB>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
Json(Login { email, password }): Json<Login>,
) -> Result<String> {
let mut tx = db.begin().await?;
let email = email.to_lowercase();
let email_w_h: Option<(String, String, bool, bool)> = sqlx::query_as(
"SELECT email, password_hash, super_admin, first_time_user FROM password WHERE email = $1 AND login_type = \
'password'",
)
.bind(&email)
.fetch_optional(&mut *tx)
.await?;
if let Some((email, hash, super_admin, first_time_user)) = email_w_h {
let parsed_hash =
PasswordHash::new(&hash).map_err(|e| Error::InternalErr(e.to_string()))?;
if argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_err()
{
Err(Error::BadRequest("Invalid login".to_string()))
} else {
if first_time_user {
sqlx::query_scalar!(
"UPDATE password SET first_time_user = false WHERE email = $1",
&email
)
.execute(&mut *tx)
.await?;
let mut c = Cookie::new("first_time", "1");
if let Some(domain) = COOKIE_DOMAIN.as_ref() {
c.set_domain(domain);
}
c.set_secure(false);
c.set_expires(time::OffsetDateTime::now_utc() + time::Duration::minutes(15));
c.set_http_only(false);
c.set_path("/");
cookies.add(c);
}
let token = create_session_token(&email, super_admin, &mut tx, cookies).await?;
tx.commit().await?;
Ok(token)
}
} else {
Err(Error::BadRequest("Invalid login".to_string()))
}
}
async fn refresh_token(
Extension(db): Extension<DB>,
authed: ApiAuthed,
cookies: Cookies,
) -> Result<String> {
let mut tx = db.begin().await?;
let super_admin = sqlx::query_scalar!(
"SELECT super_admin FROM password WHERE email = $1",
&authed.email
)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(false);
let _ = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?;
tx.commit().await?;
Ok("token refreshed".to_string())
}
pub async fn create_session_token<'c>(
email: &str,
super_admin: bool,
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
cookies: Cookies,
) -> Result<String> {
let token = rd_string(30);
sqlx::query!(
"INSERT INTO token
(token, email, label, expiration, super_admin)
VALUES ($1, $2, $3, now() + ($4 || ' hours')::interval, $5)",
token,
email,
"session",
TTL_TOKEN_DB_H.to_string(),
super_admin
)
.execute(&mut **tx)
.await?;
let mut cookie = Cookie::new(COOKIE_NAME, token.clone());
cookie.set_secure(*IS_SECURE);
cookie.set_same_site(Some(cookie::SameSite::Lax));
cookie.set_http_only(true);
cookie.set_path(COOKIE_PATH);
if COOKIE_DOMAIN.is_some() {
cookie.set_domain(COOKIE_DOMAIN.clone().unwrap());
}
let mut expire: OffsetDateTime = time::OffsetDateTime::now_utc();
expire += time::Duration::days(3);
cookie.set_expires(expire);
cookies.add(cookie);
Ok(token)
}
async fn create_token(
Extension(db): Extension<DB>,
ApiAuthed { email, .. }: ApiAuthed,
Json(new_token): Json<NewToken>,
) -> Result<(StatusCode, String)> {
let token = rd_string(30);
let mut tx = db.begin().await?;
let is_super_admin =
sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", email)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(false);
sqlx::query!(
"INSERT INTO token
(token, email, label, expiration, super_admin, scopes)
VALUES ($1, $2, $3, $4, $5, $6)",
token,
email,
new_token.label,
new_token.expiration,
is_super_admin,
new_token.scopes.as_ref().map(|x| x.as_slice())
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&email,
"users.token.create",
ActionKind::Delete,
&"global",
Some(&token[0..10]),
None,
)
.instrument(tracing::info_span!("token", email = &email))
.await?;
tx.commit().await?;
Ok((StatusCode::CREATED, token))
}
async fn impersonate(
Extension(db): Extension<DB>,
ApiAuthed { email, username, .. }: ApiAuthed,
Json(new_token): Json<NewToken>,
) -> Result<(StatusCode, String)> {
let token = rd_string(30);
require_super_admin(&db, &email).await?;
if new_token.impersonate_email.is_none() {
return Err(Error::BadRequest(
"impersonate_username is required".to_string(),
));
}
let impersonated = new_token.impersonate_email.unwrap();
let is_super_admin = sqlx::query_scalar!(
"SELECT super_admin FROM password WHERE email = $1",
impersonated
)
.fetch_optional(&db)
.await?
.unwrap_or(false);
let mut tx = db.begin().await?;
sqlx::query!(
"INSERT INTO token
(token, email, label, expiration, super_admin)
VALUES ($1, $2, $3, $4, $5)",
token,
impersonated,
new_token.label,
new_token.expiration,
is_super_admin
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&username,
"users.impersonate",
ActionKind::Delete,
&"global",
Some(&token[0..10]),
Some([("impersonated", &format!("{impersonated}")[..])].into()),
)
.instrument(tracing::info_span!("token", email = &impersonated))
.await?;
tx.commit().await?;
Ok((StatusCode::CREATED, token))
}
async fn list_tokens(
Extension(db): Extension<DB>,
ApiAuthed { email, .. }: ApiAuthed,
) -> JsonResult<Vec<TruncatedToken>> {
let rows = sqlx::query_as!(
TruncatedToken,
"SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \
last_used_at, scopes FROM token WHERE email = $1
ORDER BY created_at DESC",
email,
)
.fetch_all(&db)
.await?;
Ok(Json(rows))
}
async fn delete_token(
Extension(db): Extension<DB>,
ApiAuthed { email, .. }: ApiAuthed,
Path(token_prefix): Path<String>,
) -> Result<String> {
let mut tx = db.begin().await?;
let tokens_deleted: Vec<String> = sqlx::query_scalar(
"DELETE FROM token
WHERE email = $1
AND token LIKE concat($2::text, '%')
RETURNING concat(substring(token for 10), '*****')",
)
.bind(&email)
.bind(&token_prefix)
.fetch_all(&mut *tx)
.await?;
audit_log(
&mut *tx,
&email,
"users.token.delete",
ActionKind::Delete,
&"global",
Some(&token_prefix),
None,
)
.await?;
tx.commit().await?;
Ok(format!(
"deleted {} tokens {:?} with prefix {}",
tokens_deleted.len(),
tokens_deleted,
token_prefix
))
}
async fn leave_workspace(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { username, .. }: ApiAuthed,
) -> Result<String> {
let mut tx = db.begin().await?;
sqlx::query!(
"DELETE FROM usr WHERE workspace_id = $1 AND username = $2",
&w_id,
username
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&username,
"users.leave_workspace",
ActionKind::Delete,
&w_id,
None,
None,
)
.await?;
tx.commit().await?;
Ok(format!("left workspace {w_id}"))
}
#[derive(Serialize)]
struct Runnable {
workspace: String,
endpoint_async: String,
endpoint_sync: String,
endpoint_openai_sync: String,
summary: String,
description: String,
schema: Option<serde_json::Value>,
kind: String,
path: String,
}
async fn get_all_runnables(
Extension(db): Extension<UserDB>,
authed: ApiAuthed,
Tokened { token }: Tokened,
Extension(cache): Extension<Arc<AuthCache>>,
) -> JsonResult<Vec<Runnable>> {
let mut tx = db.clone().begin(&authed).await?;
let mut runnables = Vec::new();
let workspaces = sqlx::query_scalar!(
"SELECT workspace.id as id FROM workspace, usr WHERE usr.workspace_id = workspace.id AND \
usr.email = $1 AND deleted = false",
authed.email
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
for workspace in workspaces {
let nauthed = cache
.get_authed(Some(workspace.clone()), &token)
.await
.ok_or_else(|| {
Error::BadRequest(format!("not authorized to access workspace: {workspace}"))
})?;
let mut tx = db.clone().begin(&nauthed).await?;
let flows = sqlx::query!(
"SELECT workspace_id as workspace, path, summary, description, schema FROM flow WHERE workspace_id = $1", workspace
)
.fetch_all(&mut *tx)
.await?;
runnables.extend(
flows
.into_iter()
.map(|f| Runnable {
workspace: f.workspace.clone(),
endpoint_async: format!("/w/{}/jobs/run/f/{}", &f.workspace, &f.path),
endpoint_sync: format!(
"/w/{}/jobs/run_wait_result/f/{}",
&f.workspace, &f.path
),
endpoint_openai_sync: format!(
"/w/{}/jobs/openai_sync/f/{}",
&f.workspace, &f.path
),
summary: f.summary,
description: f.description,
schema: f.schema,
kind: "flow".to_string(),
path: f.path,
})
.collect::<Vec<_>>(),
);
let scripts = sqlx::query!(
"SELECT workspace_id as workspace, path, summary, description, schema FROM script as o WHERE created_at = (select max(created_at) from script where o.path = path and workspace_id = $1) and workspace_id = $1", workspace
)
.fetch_all(&mut *tx)
.await?;
runnables.extend(
scripts
.into_iter()
.map(|s| Runnable {
workspace: s.workspace.clone(),
endpoint_async: format!("/w/{}/jobs/run/p/{}", &s.workspace, &s.path),
endpoint_sync: format!(
"/w/{}/jobs/run_wait_result/p/{}",
&s.workspace, &s.path
),
endpoint_openai_sync: format!(
"/w/{}/jobs/openai_sync/p/{}",
&s.workspace, &s.path
),
summary: s.summary,
description: s.description,
schema: s.schema,
kind: "script".to_string(),
path: s.path,
})
.collect::<Vec<_>>(),
);
tx.commit().await?;
}
Ok(Json(runnables))
}
#[derive(Deserialize, Debug, Clone)]
pub struct LoginUserInfo {
pub email: Option<String>,
pub name: Option<String>,
pub company: Option<String>,
pub displayName: Option<String>,
}
pub async fn login_externally(
db: DB,
email: &String,
client_name: String,
cookies: Cookies,
token: Option<String>,
user: Option<LoginUserInfo>,
) -> Result<()> {
let mut tx = db.begin().await?;
let login: Option<(String, String, bool)> =
sqlx::query_as("SELECT email, login_type, super_admin FROM password WHERE email = $1")
.bind(email)
.fetch_optional(&mut *tx)
.await?;
if let Some((email, login_type, super_admin)) = login {
let login_type = serde_json::json!(login_type);
if login_type == client_name {
crate::users::create_session_token(&email, super_admin, &mut tx, cookies).await?;
} else {
return Err(error::Error::BadRequest(format!(
"an user with the email associated to this login exists but with a different \
login type {login_type}"
)));
}
if token.is_some() {
audit_log(
&mut *tx,
&email,
"oauth.login",
ActionKind::Create,
"global",
Some(&truncate_token(&token.unwrap())[..]),
None,
)
.await?;
} else {
audit_log(
&mut *tx,
&email,
"oauth.login",
ActionKind::Create,
"global",
None,
None,
)
.await?;
};
} else {
let mut name = user.clone().and_then(|x| x.name);
if (name.is_none() || name == Some(String::new())) && user.is_some() {
name = user.clone().unwrap().displayName;
}
sqlx::query(&format!(
"INSERT INTO password (email, name, company, login_type, verified) VALUES ($1, \
$2, $3, '{}', true)",
&client_name
))
.bind(email)
.bind(&name)
.bind(user.map(|x| x.company))
.execute(&mut *tx)
.await?;
tx.commit().await?;
invite_user_to_all_auto_invite_worspaces(&db, email).await?;
tx = db.begin().await?;
crate::users::create_session_token(email, false, &mut tx, cookies).await?;
audit_log(
&mut *tx,
email,
"oauth.signup",
ActionKind::Create,
"global",
Some(email),
Some([("method", &client_name[..])].into()),
)
.await?;
let demo_exists =
sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'demo')")
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if demo_exists {
if let Err(e) = sqlx::query!(
"INSERT INTO workspace_invite
(workspace_id, email, is_admin)
VALUES ('demo', $1, false)
ON CONFLICT DO NOTHING",
&email
)
.execute(&mut *tx)
.await
{
tracing::error!("error inserting invite: {:#?}", e);
}
}
}
tx.commit().await?;
Ok(())
}
pub async fn delete_expired_items_perdiodically(
db: &DB,
mut rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
loop {
let tokens_deleted_r: std::result::Result<Vec<String>, _> = sqlx::query_scalar(
"DELETE FROM token WHERE expiration <= now()
RETURNING concat(substring(token for 10), '*****')",
)
.fetch_all(db)
.await;
match tokens_deleted_r {
Ok(tokens) => tracing::debug!("deleted {} tokens: {:?}", tokens.len(), tokens),
Err(e) => tracing::error!("Error deleting token: {}", e.to_string()),
}
let pip_resolution_r = sqlx::query_scalar!(
"DELETE FROM pip_resolution_cache WHERE expiration <= now() RETURNING hash",
)
.fetch_all(db)
.await;
match pip_resolution_r {
Ok(res) => tracing::debug!("deleted {} pip_resolution: {:?}", res.len(), res),
Err(e) => tracing::error!("Error deleting pip_resolution: {}", e.to_string()),
}
let deleted_cache = sqlx::query_scalar!(
"DELETE FROM resource WHERE resource_type = 'cache' AND to_timestamp((value->>'expire')::int) < now() RETURNING path",
)
.fetch_all(db)
.await;
match deleted_cache {
Ok(res) => tracing::debug!("deleted {} cache resource: {:?}", res.len(), res),
Err(e) => tracing::error!("Error deleting cache resource {}", e.to_string()),
}
if *JOB_RETENTION_SECS > 0 {
let deleted_jobs = sqlx::query_scalar!(
"DELETE FROM completed_job WHERE started_at + ((duration_ms/1000 + $1) || ' s')::interval <= now() RETURNING id",
*JOB_RETENTION_SECS as i64
)
.fetch_all(db)
.await;
match deleted_jobs {
Ok(deleted_jobs) => {
tracing::info!(
"deleted {} jobs completed JOB_RETENTION_SECS ago: {:?}",
deleted_jobs.len(),
deleted_jobs
)
}
Err(e) => tracing::error!("Error deleting jobs: {}", e.to_string()),
}
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(600)) => (),
_ = rx. recv() => {
println!("received killpill for delete expired tokens");
break;
}
}
}
}
pub fn truncate_token(token: &str) -> String {
let mut s = token[..10].to_owned();
s.push_str("*****");
s
}