backend signup
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE tenants
|
||||||
|
DROP COLUMN IF EXISTS status,
|
||||||
|
DROP COLUMN IF EXISTS created_by;
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS tenant_status;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
ALTER TABLE tenants
|
||||||
|
DROP COLUMN IF EXISTS status;
|
||||||
|
|
||||||
|
ALTER TABLE tenants
|
||||||
|
DROP COLUMN IF EXISTS created_by;
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS tenant_status;
|
||||||
|
|
||||||
|
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
|
||||||
|
|
||||||
|
ALTER TABLE tenants
|
||||||
|
ADD COLUMN status tenant_status,
|
||||||
|
ADD COLUMN created_by UUID;
|
||||||
|
|
||||||
|
UPDATE tenants
|
||||||
|
SET status = 'active';
|
||||||
|
|
||||||
|
ALTER TABLE tenants
|
||||||
|
ALTER COLUMN status SET NOT NULL;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{PasswordHash, PasswordVerifier},
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
Argon2,
|
Argon2,
|
||||||
};
|
};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
||||||
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
|
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
|
||||||
@@ -10,3 +11,11 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
|||||||
.verify_password(password.as_bytes(), &parsed_hash)
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
.is_ok())
|
.is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,21 +140,18 @@ pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
||||||
crate::auth::password::verify_password(secret, token_hash)
|
crate::auth::password::verify_password(secret, token_hash).map_err(|err| {
|
||||||
.map_err(|err| {
|
tracing::error!(error = ?err, "failed to verify token");
|
||||||
tracing::error!(error = ?err, "failed to verify token");
|
AppError::internal("failed to verify token")
|
||||||
AppError::internal("failed to verify token")
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_secret() -> Result<String, AppError> {
|
fn generate_secret() -> Result<String, AppError> {
|
||||||
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
||||||
OsRng
|
OsRng.try_fill_bytes(&mut buffer).map_err(|err| {
|
||||||
.try_fill_bytes(&mut buffer)
|
tracing::error!(error = ?err, "failed to generate token");
|
||||||
.map_err(|err| {
|
AppError::internal("failed to generate token")
|
||||||
tracing::error!(error = ?err, "failed to generate token");
|
})?;
|
||||||
AppError::internal("failed to generate token")
|
|
||||||
})?;
|
|
||||||
Ok(hex::encode(buffer))
|
Ok(hex::encode(buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-149
@@ -2,59 +2,28 @@ use std::env;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use argon2::{
|
|
||||||
password_hash::{PasswordHasher, SaltString},
|
|
||||||
Argon2,
|
|
||||||
};
|
|
||||||
use diesel::{dsl::exists, prelude::*, select};
|
use diesel::{dsl::exists, prelude::*, select};
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use reqwest::{Client, Method, StatusCode};
|
use reqwest::{Client, Method, StatusCode};
|
||||||
use serde_json::json;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use backend::{
|
use backend::{
|
||||||
|
auth::password,
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
db::{self, PgPool},
|
db::{self, PgPool},
|
||||||
|
documents::search::ensure_quickwit_index,
|
||||||
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
||||||
models::{DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, User},
|
models::{
|
||||||
|
DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, TenantStatus, User,
|
||||||
|
},
|
||||||
s3,
|
s3,
|
||||||
schema::{
|
schema::{
|
||||||
document_asset_objects, document_assets, documents, tenants, user_memberships, users,
|
document_asset_objects, document_assets, documents, tenants, user_memberships, users,
|
||||||
},
|
},
|
||||||
storage::{ObjectStorage, S3Storage, TenantStorage},
|
storage::{ObjectStorage, S3Storage, TenantStorage},
|
||||||
|
tenants::TenantService,
|
||||||
utils::tracing::init_tracing,
|
utils::tracing::init_tracing,
|
||||||
};
|
};
|
||||||
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
|
|
||||||
static QUICKWIT_INDEX_TEMPLATE: Lazy<serde_json::Value> = Lazy::new(|| {
|
|
||||||
json!({
|
|
||||||
"version": "0.8",
|
|
||||||
"index_id": "documents",
|
|
||||||
"doc_mapping": {
|
|
||||||
"tokenizers": [
|
|
||||||
{
|
|
||||||
"name": "substring",
|
|
||||||
"type": "ngram",
|
|
||||||
"min_gram": 2,
|
|
||||||
"max_gram": 20,
|
|
||||||
"prefix_only": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"field_mappings": [
|
|
||||||
{ "name": "tenant_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "document_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "version_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
|
|
||||||
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"search_settings": {
|
|
||||||
"default_search_fields": ["title", "text"]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Command {
|
enum Command {
|
||||||
CreateUser {
|
CreateUser {
|
||||||
@@ -218,7 +187,7 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
|||||||
bail!("user '{}' already exists", username);
|
bail!("user '{}' already exists", username);
|
||||||
}
|
}
|
||||||
|
|
||||||
let password_hash = hash_password(password)?;
|
let password_hash = password::hash_password(password).map_err(|err| anyhow!(err))?;
|
||||||
let new_user = NewUser {
|
let new_user = NewUser {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
@@ -239,7 +208,7 @@ fn set_password(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
let password_hash = hash_password(password)?;
|
let password_hash = password::hash_password(password).map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
let updated = diesel::update(users::table.filter(users::username.eq(username)))
|
let updated = diesel::update(users::table.filter(users::username.eq(username)))
|
||||||
.set(users::password_hash.eq(password_hash))
|
.set(users::password_hash.eq(password_hash))
|
||||||
@@ -253,14 +222,6 @@ fn set_password(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_password(password: &str) -> Result<String> {
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
|
||||||
let hash = Argon2::default()
|
|
||||||
.hash_password(password.as_bytes(), &salt)
|
|
||||||
.map_err(|err| anyhow!(err))?;
|
|
||||||
Ok(hash.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_users(pool: &PgPool) -> Result<()> {
|
fn list_users(pool: &PgPool) -> Result<()> {
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
@@ -315,46 +276,28 @@ fn create_tenant(
|
|||||||
storage_root_arg: Option<String>,
|
storage_root_arg: Option<String>,
|
||||||
quickwit_index_arg: Option<String>,
|
quickwit_index_arg: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if slug.trim().is_empty() {
|
let service = TenantService::new(pool.clone());
|
||||||
bail!("tenant slug must not be empty");
|
let tenant = service
|
||||||
}
|
.create_tenant(
|
||||||
|
slug,
|
||||||
|
storage_root_arg.as_deref(),
|
||||||
|
quickwit_index_arg.as_deref(),
|
||||||
|
TenantStatus::Creating,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|err| anyhow!(format!("{err:?}")))?;
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let storage_root = tenant.storage_root.as_deref().unwrap_or("<none>");
|
||||||
let exists: bool =
|
let quickwit_index = tenant.quickwit_index.as_deref().unwrap_or("<none>");
|
||||||
select(exists(tenants::table.filter(tenants::slug.eq(slug)))).get_result(&mut conn)?;
|
|
||||||
if exists {
|
|
||||||
bail!("tenant '{}' already exists", slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let storage_root = storage_root_arg
|
|
||||||
.map(|mut s| {
|
|
||||||
if s.is_empty() {
|
|
||||||
format!("tenants/{}/", id)
|
|
||||||
} else {
|
|
||||||
if !s.ends_with('/') {
|
|
||||||
s.push('/');
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| format!("tenants/{}/", id));
|
|
||||||
let quickwit_index = quickwit_index_arg.unwrap_or_else(|| format!("documents-{}", id));
|
|
||||||
|
|
||||||
diesel::insert_into(tenants::table)
|
|
||||||
.values((
|
|
||||||
tenants::id.eq(id),
|
|
||||||
tenants::slug.eq(slug),
|
|
||||||
tenants::storage_root.eq(Some(storage_root.clone())),
|
|
||||||
tenants::quickwit_index.eq(Some(quickwit_index.clone())),
|
|
||||||
tenants::status.eq("active"),
|
|
||||||
tenants::config.eq(serde_json::json!({})),
|
|
||||||
))
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}'",
|
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}', status '{}'",
|
||||||
slug, id, storage_root, quickwit_index
|
tenant.slug,
|
||||||
|
tenant.id,
|
||||||
|
storage_root,
|
||||||
|
quickwit_index,
|
||||||
|
tenant.status.as_str()
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -605,63 +548,19 @@ async fn quickwit_index(
|
|||||||
|
|
||||||
match method {
|
match method {
|
||||||
Method::POST => {
|
Method::POST => {
|
||||||
let payload = render_index_template(&index_id);
|
ensure_quickwit_index(&client, base_endpoint, &index_id)
|
||||||
let response = client
|
|
||||||
.post(format!("{}/api/v1/indexes", base_endpoint))
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(payload)
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.context("failed to send create index request")?;
|
.context("failed to ensure quickwit index")?;
|
||||||
|
|
||||||
match response.status() {
|
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
||||||
status if status.is_success() => {
|
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
||||||
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
.execute(&mut conn)
|
||||||
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
.context("failed to update tenant quickwit_index")?;
|
||||||
.execute(&mut conn)
|
|
||||||
.context("failed to update tenant quickwit_index")?;
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Tenant '{}' quickwit index set to '{}'.",
|
"Tenant '{}' quickwit index set to '{}'.",
|
||||||
tenant.slug, index_id
|
tenant.slug, index_id
|
||||||
);
|
);
|
||||||
}
|
|
||||||
StatusCode::CONFLICT => {
|
|
||||||
let lookup = client
|
|
||||||
.get(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("failed to verify existing quickwit index")?;
|
|
||||||
|
|
||||||
let lookup_status = lookup.status();
|
|
||||||
if !lookup_status.is_success() {
|
|
||||||
let body = lookup.text().await.unwrap_or_default();
|
|
||||||
bail!(
|
|
||||||
"quickwit reported conflict but index lookup failed with status {}: {}",
|
|
||||||
lookup_status,
|
|
||||||
body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
|
||||||
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.context("failed to update tenant quickwit_index")?;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Tenant '{}' quickwit index set to '{}'.",
|
|
||||||
tenant.slug, index_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
status => {
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
bail!(
|
|
||||||
"quickwit create index failed with status {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Method::DELETE => {
|
Method::DELETE => {
|
||||||
let response = client
|
let response = client
|
||||||
@@ -694,14 +593,3 @@ async fn quickwit_index(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_index_template(index_id: &str) -> String {
|
|
||||||
let mut template = QUICKWIT_INDEX_TEMPLATE.clone();
|
|
||||||
if let Some(obj) = template.as_object_mut() {
|
|
||||||
obj.insert(
|
|
||||||
"index_id".to_string(),
|
|
||||||
serde_json::Value::String(index_id.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
template.to_string()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use reqwest::Client;
|
use reqwest::{Client, StatusCode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
@@ -109,6 +109,60 @@ pub async fn quickwit_search(
|
|||||||
Ok(doc_ids)
|
Ok(doc_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn quickwit_index_template(index_id: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"version": "0.8",
|
||||||
|
"index_id": index_id,
|
||||||
|
"doc_mapping": {
|
||||||
|
"tokenizers": [
|
||||||
|
{
|
||||||
|
"name": "substring",
|
||||||
|
"type": "ngram",
|
||||||
|
"min_gram": 2,
|
||||||
|
"max_gram": 20,
|
||||||
|
"prefix_only": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"field_mappings": [
|
||||||
|
{ "name": "tenant_id", "type": "text", "stored": true },
|
||||||
|
{ "name": "document_id", "type": "text", "stored": true },
|
||||||
|
{ "name": "version_id", "type": "text", "stored": true },
|
||||||
|
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
|
||||||
|
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"search_settings": {
|
||||||
|
"default_search_fields": ["title", "text"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> {
|
||||||
|
let base = endpoint.trim_end_matches('/');
|
||||||
|
let create_url = format!("{}/api/v1/indexes", base);
|
||||||
|
let payload = quickwit_index_template(index_id);
|
||||||
|
|
||||||
|
let response = client.post(&create_url).json(&payload).send().await?;
|
||||||
|
match response.status() {
|
||||||
|
status if status.is_success() => Ok(()),
|
||||||
|
StatusCode::CONFLICT => {
|
||||||
|
let lookup_url = format!("{}/api/v1/indexes/{}", base, index_id);
|
||||||
|
let lookup = client.get(&lookup_url).send().await?;
|
||||||
|
if lookup.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let status = lookup.status();
|
||||||
|
let body = lookup.text().await.unwrap_or_default();
|
||||||
|
bail!("quickwit index lookup failed with status {status}: {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status => {
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
bail!("quickwit create index failed with status {status}: {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||||
if let Some(value) = hit.get(key) {
|
if let Some(value) = hit.get(key) {
|
||||||
@@ -226,7 +280,9 @@ pub async fn quickwit_ingest(
|
|||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
error!(%status, %body, "quickwit ingest request failed");
|
error!(%status, %body, "quickwit ingest request failed");
|
||||||
return Err(anyhow!("quickwit ingest failed with status {status}: {body}"));
|
return Err(anyhow!(
|
||||||
|
"quickwit ingest failed with status {status}: {body}"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("quickwit ingest request succeeded");
|
debug!("quickwit ingest request succeeded");
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
|
|||||||
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
|
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
|
||||||
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
|
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
|
||||||
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
|
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
|
||||||
|
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum JobQueueError {
|
pub enum JobQueueError {
|
||||||
|
|||||||
+71
-2
@@ -1,7 +1,16 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
|
use diesel::deserialize::FromSql;
|
||||||
|
use diesel::pg::{Pg, PgValue};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use diesel::serialize::{IsNull, Output, ToSql};
|
||||||
|
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fmt;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::str;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::schema::sql_types::TenantStatus as TenantStatusSql;
|
||||||
use crate::schema::*;
|
use crate::schema::*;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -26,6 +35,65 @@ pub struct NewUserMembership {
|
|||||||
pub role: String,
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
||||||
|
#[diesel(sql_type = TenantStatusSql)]
|
||||||
|
pub enum TenantStatus {
|
||||||
|
Creating,
|
||||||
|
Active,
|
||||||
|
Suspended,
|
||||||
|
Deleting,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TenantStatus {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TenantStatus::Creating => "creating",
|
||||||
|
TenantStatus::Active => "active",
|
||||||
|
TenantStatus::Suspended => "suspended",
|
||||||
|
TenantStatus::Deleting => "deleting",
|
||||||
|
TenantStatus::Error => "error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_str(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"creating" => Some(TenantStatus::Creating),
|
||||||
|
"active" => Some(TenantStatus::Active),
|
||||||
|
"suspended" => Some(TenantStatus::Suspended),
|
||||||
|
"deleting" => Some(TenantStatus::Deleting),
|
||||||
|
"error" => Some(TenantStatus::Error),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TenantStatus {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToSql<TenantStatusSql, Pg> for TenantStatus {
|
||||||
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
||||||
|
out.write_all(self.as_str().as_bytes())?;
|
||||||
|
Ok(IsNull::No)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql<TenantStatusSql, Pg> for TenantStatus {
|
||||||
|
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||||
|
let value = str::from_utf8(bytes.as_bytes())
|
||||||
|
.map_err(|err| Box::<dyn std::error::Error + Send + Sync>::from(err))?;
|
||||||
|
TenantStatus::from_str(value).ok_or_else(|| {
|
||||||
|
Box::<dyn std::error::Error + Send + Sync>::from(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("invalid tenant status '{value}'"),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
#[diesel(table_name = tenants)]
|
#[diesel(table_name = tenants)]
|
||||||
#[diesel(primary_key(id))]
|
#[diesel(primary_key(id))]
|
||||||
@@ -34,10 +102,11 @@ pub struct Tenant {
|
|||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub storage_root: Option<String>,
|
pub storage_root: Option<String>,
|
||||||
pub quickwit_index: Option<String>,
|
pub quickwit_index: Option<String>,
|
||||||
pub status: String,
|
pub config: Value,
|
||||||
pub config: serde_json::Value,
|
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub status: TenantStatus,
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use uuid::Uuid;
|
|||||||
paths(
|
paths(
|
||||||
doc::health_check,
|
doc::health_check,
|
||||||
doc::login,
|
doc::login,
|
||||||
|
doc::signup,
|
||||||
doc::refresh,
|
doc::refresh,
|
||||||
doc::logout,
|
doc::logout,
|
||||||
doc::me,
|
doc::me,
|
||||||
@@ -53,6 +54,7 @@ use uuid::Uuid;
|
|||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
schemas::LoginRequest,
|
schemas::LoginRequest,
|
||||||
|
schemas::SignupRequest,
|
||||||
schemas::AccessTokenResponse,
|
schemas::AccessTokenResponse,
|
||||||
schemas::TenantSnippet,
|
schemas::TenantSnippet,
|
||||||
schemas::TenantSelectionResponse,
|
schemas::TenantSelectionResponse,
|
||||||
@@ -151,6 +153,19 @@ mod doc {
|
|||||||
)]
|
)]
|
||||||
pub(super) fn login() {}
|
pub(super) fn login() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/signup",
|
||||||
|
request_body = SignupRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Signup succeeded", body = LoginResponseVariants),
|
||||||
|
(status = 400, description = "Invalid signup request"),
|
||||||
|
(status = 409, description = "Username already exists")
|
||||||
|
),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub(super) fn signup() {}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/auth/refresh",
|
path = "/api/auth/refresh",
|
||||||
@@ -597,6 +612,12 @@ pub mod schemas {
|
|||||||
pub preferred_tenant_slug: Option<String>,
|
pub preferred_tenant_slug: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SignupRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct AccessTokenResponse {
|
pub struct AccessTokenResponse {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::{password, AuthenticatedUser},
|
auth::{password, AuthenticatedUser},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
|
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
||||||
schema::{
|
schema::{
|
||||||
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
||||||
users::dsl,
|
users::dsl,
|
||||||
@@ -38,6 +38,12 @@ pub struct LoginRequest {
|
|||||||
pub preferred_tenant_slug: Option<String>,
|
pub preferred_tenant_slug: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SignupRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct LoginResponse {
|
pub struct LoginResponse {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
@@ -68,6 +74,50 @@ pub struct TenantSelectionRequest {
|
|||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn signup(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<SignupRequest>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let username = payload.username.trim();
|
||||||
|
let password = payload.password.trim();
|
||||||
|
|
||||||
|
if username.is_empty() || password.is_empty() {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"username and password must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
|
let exists: bool = dsl::users
|
||||||
|
.filter(dsl::username.eq(username))
|
||||||
|
.first::<User>(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
.is_some();
|
||||||
|
if exists {
|
||||||
|
return Err(AppError::conflict("username already exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let password_hash = password::hash_password(password)?;
|
||||||
|
|
||||||
|
insert_user(&mut conn, user_id, username, &password_hash)?;
|
||||||
|
|
||||||
|
let tenant_slug = username.to_lowercase();
|
||||||
|
let tenant = state.tenants.create_tenant(
|
||||||
|
&tenant_slug,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
TenantStatus::Creating,
|
||||||
|
&[user_id],
|
||||||
|
Some(user_id),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let user: User = dsl::users.find(user_id).first(&mut conn)?;
|
||||||
|
|
||||||
|
issue_session(&state, &mut conn, &user, tenant.id)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<LoginRequest>,
|
Json(payload): Json<LoginRequest>,
|
||||||
@@ -181,6 +231,25 @@ pub async fn refresh(
|
|||||||
issue_session(&state, &mut conn, &user, token.tenant_id)
|
issue_session(&state, &mut conn, &user, token.tenant_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn insert_user(
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
id: Uuid,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let new_user = NewUser {
|
||||||
|
id,
|
||||||
|
username: username.to_string(),
|
||||||
|
password_hash: password_hash.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(dsl::users)
|
||||||
|
.values(&new_user)
|
||||||
|
.execute(conn)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(AppError::from)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn select_tenant(
|
pub async fn select_tenant(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let auth_routes = Router::new()
|
let auth_routes = Router::new()
|
||||||
|
.route("/signup", post(auth::signup))
|
||||||
.route("/login", post(auth::login))
|
.route("/login", post(auth::login))
|
||||||
.route("/refresh", post(auth::refresh))
|
.route("/refresh", post(auth::refresh))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,11 @@
|
|||||||
// @generated automatically by Diesel CLI.
|
// @generated automatically by Diesel CLI.
|
||||||
|
|
||||||
|
pub mod sql_types {
|
||||||
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
|
#[diesel(postgres_type(name = "tenant_status"))]
|
||||||
|
pub struct TenantStatus;
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
correspondents (id) {
|
correspondents (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
@@ -148,15 +154,19 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
|
use diesel::sql_types::*;
|
||||||
|
use super::sql_types::TenantStatus;
|
||||||
|
|
||||||
tenants (id) {
|
tenants (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
slug -> Text,
|
slug -> Text,
|
||||||
storage_root -> Nullable<Text>,
|
storage_root -> Nullable<Text>,
|
||||||
quickwit_index -> Nullable<Text>,
|
quickwit_index -> Nullable<Text>,
|
||||||
status -> Text,
|
|
||||||
config -> Jsonb,
|
config -> Jsonb,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
|
status -> TenantStatus,
|
||||||
|
created_by -> Nullable<Uuid>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,12 +69,10 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
|
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
|
||||||
self.pool
|
self.pool.get().map_err(|err| {
|
||||||
.get()
|
tracing::error!(error = ?err, "database pool error");
|
||||||
.map_err(|err| {
|
AppError::internal("database pool error")
|
||||||
tracing::error!(error = ?err, "database pool error");
|
})
|
||||||
AppError::internal("database pool error")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
|
pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
|
||||||
|
|||||||
+91
-9
@@ -1,11 +1,18 @@
|
|||||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||||
use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
|
use diesel::{
|
||||||
|
dsl::{exists, select},
|
||||||
|
pg::PgConnection,
|
||||||
|
prelude::*,
|
||||||
|
sql_types::Text,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::PgPool,
|
db::PgPool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::Tenant,
|
jobs::{enqueue_job, JOB_PROVISION_TENANT},
|
||||||
|
models::{Tenant, TenantStatus},
|
||||||
schema::tenants::dsl,
|
schema::tenants::dsl,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -50,17 +57,72 @@ impl TenantService {
|
|||||||
Ok(self.get_by_slug(slug)?.id)
|
Ok(self.get_by_slug(slug)?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_tenant(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
storage_root: Option<&str>,
|
||||||
|
quickwit_index: Option<&str>,
|
||||||
|
status: TenantStatus,
|
||||||
|
initial_members: &[Uuid],
|
||||||
|
created_by: Option<Uuid>,
|
||||||
|
) -> AppResult<Tenant> {
|
||||||
|
let slug = slug.trim();
|
||||||
|
if slug.is_empty() {
|
||||||
|
return Err(AppError::bad_request("tenant slug must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = self.pool.get().map_err(|err| {
|
||||||
|
tracing::error!(error = ?err, "database pool error");
|
||||||
|
AppError::internal("database pool error")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let exists: bool =
|
||||||
|
select(exists(dsl::tenants.filter(dsl::slug.eq(slug)))).get_result(&mut conn)?;
|
||||||
|
if exists {
|
||||||
|
return Err(AppError::conflict(format!(
|
||||||
|
"tenant '{}' already exists",
|
||||||
|
slug
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let storage_root = normalize_storage_root(storage_root, id);
|
||||||
|
let quickwit_index = normalize_quickwit_index(quickwit_index, id);
|
||||||
|
|
||||||
|
diesel::insert_into(dsl::tenants)
|
||||||
|
.values((
|
||||||
|
dsl::id.eq(id),
|
||||||
|
dsl::slug.eq(slug),
|
||||||
|
dsl::storage_root.eq(Some(storage_root.clone())),
|
||||||
|
dsl::quickwit_index.eq(Some(quickwit_index.clone())),
|
||||||
|
dsl::config.eq(json!({})),
|
||||||
|
dsl::status.eq(status),
|
||||||
|
dsl::created_by.eq(created_by),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
if status == TenantStatus::Creating {
|
||||||
|
let payload = json!({
|
||||||
|
"members": initial_members,
|
||||||
|
});
|
||||||
|
|
||||||
|
enqueue_job(&mut conn, id, JOB_PROVISION_TENANT, payload, None).map_err(|err| {
|
||||||
|
tracing::error!(error = ?err, tenant_id = %id, "failed to enqueue tenant provisioning job");
|
||||||
|
AppError::internal("failed to enqueue tenant provisioning job")
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
TenantRepository::get_by_id(&mut conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
fn load<F>(&self, loader: F) -> AppResult<Tenant>
|
fn load<F>(&self, loader: F) -> AppResult<Tenant>
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
|
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
|
||||||
{
|
{
|
||||||
let mut conn = self
|
let mut conn = self.pool.get().map_err(|err| {
|
||||||
.pool
|
tracing::error!(error = ?err, "database pool error");
|
||||||
.get()
|
AppError::internal("database pool error")
|
||||||
.map_err(|err| {
|
})?;
|
||||||
tracing::error!(error = ?err, "database pool error");
|
|
||||||
AppError::internal("database pool error")
|
|
||||||
})?;
|
|
||||||
let tenant = loader(&mut conn)?;
|
let tenant = loader(&mut conn)?;
|
||||||
Ok(tenant)
|
Ok(tenant)
|
||||||
}
|
}
|
||||||
@@ -92,3 +154,23 @@ impl FromRequestParts<AppState> for TenantContext {
|
|||||||
Ok(Self { tenant })
|
Ok(Self { tenant })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_storage_root(raw: Option<&str>, tenant_id: Uuid) -> String {
|
||||||
|
match raw.map(str::trim) {
|
||||||
|
Some(root) if !root.is_empty() => {
|
||||||
|
let mut owned = root.to_owned();
|
||||||
|
if !owned.ends_with('/') {
|
||||||
|
owned.push('/');
|
||||||
|
}
|
||||||
|
owned
|
||||||
|
}
|
||||||
|
_ => format!("tenants/{tenant_id}/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_quickwit_index(raw: Option<&str>, tenant_id: Uuid) -> String {
|
||||||
|
match raw.map(str::trim) {
|
||||||
|
Some(value) if !value.is_empty() => value.to_owned(),
|
||||||
|
_ => format!("documents-{tenant_id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
fetch_version_object,
|
fetch_version_object, handle_fetch_error, ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler,
|
||||||
handle_fetch_error,
|
|
||||||
ocr::OCR_TEXT_ASSET_TYPE,
|
|
||||||
JobExecution,
|
|
||||||
JobHandler,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -148,12 +144,8 @@ impl JobHandler for IndexDocumentTextJob {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let record = build_quickwit_ingest_record(
|
let record =
|
||||||
&context.document,
|
build_quickwit_ingest_record(&context.document, &context.version, job.tenant_id, &text);
|
||||||
&context.version,
|
|
||||||
job.tenant_id,
|
|
||||||
&text,
|
|
||||||
);
|
|
||||||
|
|
||||||
match quickwit_ingest(&client, &quickwit_endpoint, &quickwit_index, &[record]).await {
|
match quickwit_ingest(&client, &quickwit_endpoint, &quickwit_index, &[record]).await {
|
||||||
Ok(()) => JobExecution::Success,
|
Ok(()) => JobExecution::Success,
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ use crate::{
|
|||||||
pub mod analyze;
|
pub mod analyze;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod ocr;
|
pub mod ocr;
|
||||||
|
pub mod tenants;
|
||||||
pub mod thumbnails;
|
pub mod thumbnails;
|
||||||
|
|
||||||
|
use tenants::ProvisionTenantJob;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum JobExecution {
|
pub enum JobExecution {
|
||||||
Success,
|
Success,
|
||||||
@@ -146,6 +149,7 @@ pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> {
|
|||||||
Arc::new(thumbnails::GenerateThumbnailsJob::new()),
|
Arc::new(thumbnails::GenerateThumbnailsJob::new()),
|
||||||
Arc::new(ocr::GenerateOcrTextJob::new()),
|
Arc::new(ocr::GenerateOcrTextJob::new()),
|
||||||
Arc::new(index::IndexDocumentTextJob::new()),
|
Arc::new(index::IndexDocumentTextJob::new()),
|
||||||
|
Arc::new(ProvisionTenantJob::new()),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,12 +176,8 @@ pub(crate) async fn fetch_version_object(
|
|||||||
s3_key: &str,
|
s3_key: &str,
|
||||||
limit_bytes: u64,
|
limit_bytes: u64,
|
||||||
) -> Result<Vec<u8>, FetchVersionError> {
|
) -> Result<Vec<u8>, FetchVersionError> {
|
||||||
check_worker_document_limit(version.size_bytes, limit_bytes).map_err(|(size, limit)| {
|
check_worker_document_limit(version.size_bytes, limit_bytes)
|
||||||
FetchVersionError::TooLarge {
|
.map_err(|(size, limit)| FetchVersionError::TooLarge { size, limit })?;
|
||||||
size,
|
|
||||||
limit,
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
storage
|
storage
|
||||||
.get_object(s3_key)
|
.get_object(s3_key)
|
||||||
@@ -199,9 +199,7 @@ pub(crate) fn handle_fetch_error(
|
|||||||
"document exceeds worker size limit"
|
"document exceeds worker size limit"
|
||||||
);
|
);
|
||||||
JobExecution::Failed {
|
JobExecution::Failed {
|
||||||
error: format!(
|
error: format!("document size {size} bytes exceeds worker limit of {limit} bytes"),
|
||||||
"document size {size} bytes exceeds worker limit of {limit} bytes"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FetchVersionError::Storage(err) => {
|
FetchVersionError::Storage(err) => {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::warn;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::documents::search::ensure_quickwit_index;
|
||||||
|
use crate::jobs::JOB_PROVISION_TENANT;
|
||||||
|
use crate::models::{NewUserMembership, TenantStatus};
|
||||||
|
use crate::schema::{tenants, user_memberships};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::tenants::TenantRepository;
|
||||||
|
use crate::workers::{JobExecution, JobHandler};
|
||||||
|
|
||||||
|
pub struct ProvisionTenantJob;
|
||||||
|
|
||||||
|
impl ProvisionTenantJob {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl JobHandler for ProvisionTenantJob {
|
||||||
|
fn job_type(&self) -> &'static str {
|
||||||
|
JOB_PROVISION_TENANT
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
job: crate::models::Job,
|
||||||
|
_storage: crate::storage::TenantStorage,
|
||||||
|
) -> JobExecution {
|
||||||
|
let mut conn = match state.db_unscoped() {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(job_id = %job.id, error = ?err, "failed to get connection for tenant provisioning");
|
||||||
|
return JobExecution::Retry {
|
||||||
|
delay: std::time::Duration::from_secs(30),
|
||||||
|
error: "database connection unavailable".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tenant = match TenantRepository::get_by_id(&mut conn, job.tenant_id) {
|
||||||
|
Ok(tenant) => tenant,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(job_id = %job.id, error = ?err, "tenant not found for provisioning");
|
||||||
|
return JobExecution::Failed {
|
||||||
|
error: "tenant not found".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if tenant.status == TenantStatus::Active {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
tenant_id = %tenant.id,
|
||||||
|
"tenant already active; skipping provisioning"
|
||||||
|
);
|
||||||
|
return JobExecution::Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
if tenant.status != TenantStatus::Creating {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
tenant_id = %tenant.id,
|
||||||
|
status = %tenant.status.as_str(),
|
||||||
|
"tenant not in creating state; skipping provisioning"
|
||||||
|
);
|
||||||
|
return JobExecution::Failed {
|
||||||
|
error: format!(
|
||||||
|
"tenant status '{}' not eligible for provisioning",
|
||||||
|
tenant.status.as_str()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let endpoint = match &state.config.quickwit_endpoint {
|
||||||
|
Some(endpoint) => endpoint.trim_end_matches('/').to_owned(),
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
tenant_id = %tenant.id,
|
||||||
|
"quickwit endpoint not configured; retrying"
|
||||||
|
);
|
||||||
|
return JobExecution::Retry {
|
||||||
|
delay: std::time::Duration::from_secs(30),
|
||||||
|
error: "quickwit endpoint not configured".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let index_id = tenant
|
||||||
|
.quickwit_index
|
||||||
|
.as_deref()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| format!("documents-{}", tenant.id));
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
if let Err(err) = ensure_quickwit_index(&client, &endpoint, &index_id).await {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
tenant_id = %tenant.id,
|
||||||
|
error = %err,
|
||||||
|
"failed to ensure quickwit index"
|
||||||
|
);
|
||||||
|
return JobExecution::Retry {
|
||||||
|
delay: std::time::Duration::from_secs(30),
|
||||||
|
error: err.to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(members) = ProvisionPayload::from_job(&job) {
|
||||||
|
for member in members {
|
||||||
|
let new_membership = NewUserMembership {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: member,
|
||||||
|
tenant_id: tenant.id,
|
||||||
|
role: "admin".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = diesel::insert_into(user_memberships::table)
|
||||||
|
.values(&new_membership)
|
||||||
|
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
||||||
|
.do_nothing()
|
||||||
|
.execute(&mut conn)
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
tenant_id = %tenant.id,
|
||||||
|
user_id = %member,
|
||||||
|
error = %err,
|
||||||
|
"failed to assign initial membership"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = diesel::update(tenants::table.find(tenant.id))
|
||||||
|
.set((
|
||||||
|
tenants::status.eq(TenantStatus::Active),
|
||||||
|
tenants::quickwit_index.eq(Some(index_id)),
|
||||||
|
tenants::updated_at.eq(Utc::now().naive_utc()),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
{
|
||||||
|
warn!(job_id = %job.id, error = %err, "failed to activate tenant");
|
||||||
|
return JobExecution::Retry {
|
||||||
|
delay: std::time::Duration::from_secs(30),
|
||||||
|
error: format!("failed to update tenant status: {err}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
JobExecution::Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ProvisionPayload {
|
||||||
|
#[serde(default)]
|
||||||
|
members: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProvisionPayload {
|
||||||
|
fn from_job(job: &crate::models::Job) -> Option<Vec<Uuid>> {
|
||||||
|
serde_json::from_value(job.payload.clone())
|
||||||
|
.map(|payload: ProvisionPayload| payload.members)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,10 +25,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
analyze::determine_thumbnail_support,
|
analyze::determine_thumbnail_support, fetch_version_object, handle_fetch_error, JobExecution,
|
||||||
fetch_version_object,
|
|
||||||
handle_fetch_error,
|
|
||||||
JobExecution,
|
|
||||||
JobHandler,
|
JobHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ mod common;
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||||
use backend::models::NewUserMembership;
|
use backend::jobs::JOB_PROVISION_TENANT;
|
||||||
use backend::schema::{tenants, user_memberships};
|
use backend::models::{Job, NewUserMembership, TenantStatus};
|
||||||
|
use backend::schema::{jobs, tenants, user_memberships, users};
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -81,6 +82,77 @@ async fn login_rejects_unknown_user() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn signup_creates_user_tenant_and_membership() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"username": "signup-user",
|
||||||
|
"password": "super-secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = app.post_json("/api/auth/signup", &payload, None).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
let login: LoginResponse = serde_json::from_slice(&body)?;
|
||||||
|
|
||||||
|
// session works immediately
|
||||||
|
let me = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||||
|
assert_eq!(me.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
app.with_conn(|conn| {
|
||||||
|
let user: backend::models::User = users::table
|
||||||
|
.filter(users::username.eq("signup-user"))
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
let tenant: backend::models::Tenant = tenants::table
|
||||||
|
.filter(tenants::slug.eq("signup-user"))
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
assert_eq!(tenant.status, TenantStatus::Creating);
|
||||||
|
assert_eq!(tenant.created_by, Some(user.id));
|
||||||
|
|
||||||
|
let membership_exists: bool = diesel::select(diesel::dsl::exists(
|
||||||
|
user_memberships::table
|
||||||
|
.filter(user_memberships::user_id.eq(user.id))
|
||||||
|
.filter(user_memberships::tenant_id.eq(tenant.id)),
|
||||||
|
))
|
||||||
|
.get_result(conn)?;
|
||||||
|
assert!(
|
||||||
|
!membership_exists,
|
||||||
|
"membership should be enqueued, not created synchronously"
|
||||||
|
);
|
||||||
|
|
||||||
|
let job: Job = jobs::table
|
||||||
|
.filter(jobs::tenant_id.eq(tenant.id))
|
||||||
|
.filter(jobs::job_type.eq(JOB_PROVISION_TENANT))
|
||||||
|
.first(conn)
|
||||||
|
.context("provision job missing")?;
|
||||||
|
|
||||||
|
let members = job
|
||||||
|
.payload
|
||||||
|
.get("members")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
assert!(members.iter().any(|value| {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|id| Uuid::parse_str(id).ok())
|
||||||
|
.map(|parsed| parsed == user.id)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_rejects_invalid_password() -> Result<()> {
|
async fn login_rejects_invalid_password() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
@@ -198,6 +270,7 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
.values((
|
.values((
|
||||||
tenants::id.eq(secondary_id),
|
tenants::id.eq(secondary_id),
|
||||||
tenants::slug.eq(&slug_for_insert),
|
tenants::slug.eq(&slug_for_insert),
|
||||||
|
tenants::status.eq(TenantStatus::Active),
|
||||||
))
|
))
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use axum::Router;
|
|||||||
use backend::auth::jwt::JwtService;
|
use backend::auth::jwt::JwtService;
|
||||||
use backend::config::AppConfig;
|
use backend::config::AppConfig;
|
||||||
use backend::db::{self, PgPool};
|
use backend::db::{self, PgPool};
|
||||||
use backend::models::{Job, NewUser, NewUserMembership, Tenant};
|
use backend::models::{Job, NewUser, NewUserMembership, Tenant, TenantStatus};
|
||||||
use backend::routes;
|
use backend::routes;
|
||||||
use backend::state::AppState;
|
use backend::state::AppState;
|
||||||
use backend::storage::ObjectStorage;
|
use backend::storage::ObjectStorage;
|
||||||
@@ -278,6 +278,7 @@ impl TestApp {
|
|||||||
tenants_dsl::slug.eq(&slug_value),
|
tenants_dsl::slug.eq(&slug_value),
|
||||||
tenants_dsl::storage_root.eq(Some(root)),
|
tenants_dsl::storage_root.eq(Some(root)),
|
||||||
tenants_dsl::quickwit_index.eq(quickwit_value),
|
tenants_dsl::quickwit_index.eq(quickwit_value),
|
||||||
|
tenants_dsl::status.eq(TenantStatus::Active),
|
||||||
))
|
))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.context("failed to insert default tenant")?;
|
.context("failed to insert default tenant")?;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ mod common;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use backend::models::{NewUser, NewUserMembership, Tag};
|
use backend::models::{NewUser, NewUserMembership, Tag, TenantStatus};
|
||||||
use backend::schema::{
|
use backend::schema::{
|
||||||
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
|
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
|
||||||
users::dsl as users_dsl,
|
users::dsl as users_dsl,
|
||||||
@@ -213,6 +213,7 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
|
|||||||
tenants_dsl::id.eq(tenant_b_id),
|
tenants_dsl::id.eq(tenant_b_id),
|
||||||
tenants_dsl::slug.eq("tenant-b"),
|
tenants_dsl::slug.eq("tenant-b"),
|
||||||
tenants_dsl::storage_root.eq(Some(storage_root)),
|
tenants_dsl::storage_root.eq(Some(storage_root)),
|
||||||
|
tenants_dsl::status.eq(TenantStatus::Active),
|
||||||
))
|
))
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user