From 4f29ff73c6a452cb95dbd45201ce666ed9645095 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 11 Nov 2025 02:45:05 +0100 Subject: [PATCH] tenant gate active --- backend/src/auth/mod.rs | 19 +++++-- backend/src/error.rs | 8 ++- backend/src/jobs.rs | 1 - backend/src/routes/documents.rs | 4 +- backend/src/routes/webdav/mod.rs | 10 +++- backend/src/services/auth.rs | 51 +++++++++++------- backend/src/services/documents.rs | 9 ++-- backend/src/workers/analyze.rs | 9 +++- backend/src/workers/index.rs | 90 +------------------------------ backend/src/workers/mod.rs | 1 - backend/src/workers/purge.rs | 7 +++ backend/tests/auth_flow.rs | 82 +++++++++++++++++++++++++++- 12 files changed, 168 insertions(+), 123 deletions(-) diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index cb1bafe..7a1fd9c 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -7,7 +7,10 @@ pub mod password; use std::sync::{Arc, Mutex}; -use axum::{extract::FromRequestParts, http::request::Parts}; +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; use axum_extra::headers::{authorization::Bearer, Authorization}; use axum_extra::TypedHeader; use serde::{Deserialize, Serialize}; @@ -15,8 +18,8 @@ use utoipa::ToSchema; use crate::{ auth::capability_sets::{load_capabilities_for_set, load_capability_set}, - error::AppError, - models::ApiCapability, + error::{AppError, AppResult}, + models::{ApiCapability, TenantStatus}, state::{AppState, PgPooledConnection}, }; use uuid::Uuid; @@ -133,6 +136,8 @@ impl FromRequestParts for TenantScopedConn { async move { let user = AuthenticatedUser::from_request_parts(parts, &state).await?; let tenant_id = user.tenant_id; + ensure_active_tenant(&state, tenant_id)?; + let conn = if let Some(holder) = parts.extensions.remove::() { holder .into_conn() @@ -150,3 +155,11 @@ impl FromRequestParts for TenantScopedConn { } } } + +pub(crate) fn ensure_active_tenant(state: &AppState, tenant_id: Uuid) -> AppResult<()> { + let tenant = state.tenants.get_by_id(tenant_id)?; + if tenant.status != TenantStatus::Active { + return Err(AppError::new(StatusCode::FORBIDDEN, "tenant is not active")); + } + Ok(()) +} diff --git a/backend/src/error.rs b/backend/src/error.rs index 7a75b8c..6df113a 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -5,7 +5,7 @@ use axum::{ }; use serde::Serialize; use serde_json::Value; -use std::fmt::Display; +use std::fmt::{self, Display}; use utoipa::ToSchema; pub type AppResult = Result; @@ -59,6 +59,12 @@ impl AppError { } } +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.status, self.message) + } +} + impl IntoResponse for AppError { fn into_response(self) -> Response { let status = self.status; diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs index b6c21d4..a5eb538 100644 --- a/backend/src/jobs.rs +++ b/backend/src/jobs.rs @@ -18,7 +18,6 @@ pub const STATUS_FAILED: &str = "failed"; pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document"; pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails"; pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text"; -pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text"; pub const JOB_PROVISION_TENANT: &str = "provision-tenant"; pub const JOB_PURGE_DOCUMENT: &str = "purge-document"; diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 237d10b..4af8fc5 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -14,7 +14,7 @@ use tracing::{error, info}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; -use crate::auth::{jwt::DownloadSubject, TenantScopedConn}; +use crate::auth::{ensure_active_tenant, jwt::DownloadSubject, TenantScopedConn}; use crate::documents::asset::{ asset_object_disposition, DocumentAssetDetailResponse, DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse, @@ -573,6 +573,8 @@ pub async fn download_with_token( .verify_download_token(&token) .map_err(|_| AppError::unauthorized())?; + ensure_active_tenant(&state, claims.tenant_id)?; + let mut conn = state.db_for_tenant(claims.tenant_id)?; let now = Utc::now().naive_utc(); diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs index a4889ba..8783c04 100644 --- a/backend/src/routes/webdav/mod.rs +++ b/backend/src/routes/webdav/mod.rs @@ -16,7 +16,10 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}; use quick_xml::Writer; use uuid::Uuid; -use crate::auth::api_tokens::{find_active_token_by_secret, touch_api_token}; +use crate::auth::{ + api_tokens::{find_active_token_by_secret, touch_api_token}, + ensure_active_tenant, +}; use crate::error::{AppError, AppResult}; use crate::models::{ApiCapability, Document, DocumentVersion, Folder, User}; use crate::schema::{ @@ -483,6 +486,11 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result AuthService<'a> { let mut tenants = Vec::with_capacity(tenant_ids.len()); for tenant_id in tenant_ids { - let name: String = tenant_dsl::tenants + let (name, status): (String, TenantStatus) = tenant_dsl::tenants .find(tenant_id) - .select(tenant_dsl::name) + .select((tenant_dsl::name, tenant_dsl::status)) .first(&mut conn) .map_err(AppError::from)?; + if status != TenantStatus::Active { + continue; + } tenants.push(TenantSnippet { id: tenant_id, name, @@ -575,14 +578,33 @@ impl<'a> AuthService<'a> { return Err(AppError::unauthorized()); } + let mut active_tenants = Vec::new(); + for tenant_id in tenant_ids { + let (name, status): (String, TenantStatus) = tenant_dsl::tenants + .find(tenant_id) + .select((tenant_dsl::name, tenant_dsl::status)) + .first(conn) + .map_err(AppError::from)?; + if status == TenantStatus::Active { + active_tenants.push((tenant_id, name)); + } + } + + if active_tenants.is_empty() { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "no active tenants available", + )); + } + if let Some(preferred_id) = preferred_tenant_id { - if tenant_ids.iter().any(|id| *id == preferred_id) { + if active_tenants.iter().any(|(id, _)| *id == preferred_id) { return self.issue_session(conn, user, preferred_id); } } - if tenant_ids.len() == 1 { - return self.issue_session(conn, user, tenant_ids[0]); + if active_tenants.len() == 1 { + return self.issue_session(conn, user, active_tenants[0].0); } let selection_token = self @@ -591,20 +613,10 @@ impl<'a> AuthService<'a> { .generate_tenant_selector_token(user.id) .map_err(AppError::from)?; - let mut tenants = Vec::with_capacity(tenant_ids.len()); - for tenant_id in tenant_ids { - apply_tenant_guc(conn, tenant_id)?; - clear_user_guc(conn)?; - let name: String = tenant_dsl::tenants - .find(tenant_id) - .select(tenant_dsl::name) - .first(conn) - .map_err(AppError::from)?; - tenants.push(TenantSnippet { - id: tenant_id, - name, - }); - } + let tenants = active_tenants + .into_iter() + .map(|(id, name)| TenantSnippet { id, name }) + .collect(); let response = ok_json(LoginResponseVariants::Selection(TenantSelectionResponse { access_token: selection_token, @@ -677,6 +689,7 @@ impl<'a> AuthService<'a> { user: &User, tenant_id: Uuid, ) -> AppResult { + crate::auth::ensure_active_tenant(&self.state, tenant_id)?; apply_tenant_guc(conn, tenant_id)?; clear_user_guc(conn)?; clear_user_session_hash(conn)?; diff --git a/backend/src/services/documents.rs b/backend/src/services/documents.rs index 8b704ed..3a39fe3 100644 --- a/backend/src/services/documents.rs +++ b/backend/src/services/documents.rs @@ -37,9 +37,7 @@ use crate::documents::{ tags::assign_tags as assign_tags_to_document, }; use crate::error::{AppError, AppResult}; -use crate::jobs::{ - enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT, JOB_PURGE_DOCUMENT, -}; +use crate::jobs::{enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_PURGE_DOCUMENT}; use crate::models::{ Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentVersion, Tag, @@ -1163,10 +1161,11 @@ impl<'a> DocumentsService<'a> { if let Err(err) = enqueue_job( conn, tenant_id, - JOB_INDEX_DOCUMENT_TEXT, + JOB_ANALYZE_DOCUMENT, json!({ "document_id": document.id, "document_version_id": current_version.id, + "force": false, }), None, ) { @@ -1174,7 +1173,7 @@ impl<'a> DocumentsService<'a> { document_id = %document.id, version_id = %current_version.id, error = %err, - "failed to enqueue reindex job after title change" + "failed to enqueue analyze job after title change" ); } } diff --git a/backend/src/workers/analyze.rs b/backend/src/workers/analyze.rs index 344cd87..835fb72 100644 --- a/backend/src/workers/analyze.rs +++ b/backend/src/workers/analyze.rs @@ -6,7 +6,8 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - jobs::JOB_ANALYZE_DOCUMENT, models::Document, state::AppState, storage::TenantStorage, + auth::ensure_active_tenant, jobs::JOB_ANALYZE_DOCUMENT, models::Document, state::AppState, + storage::TenantStorage, }; use super::{ @@ -49,6 +50,12 @@ impl JobHandler for AnalyzeDocumentJob { job: crate::models::Job, storage: TenantStorage, ) -> JobExecution { + if let Err(err) = ensure_active_tenant(&state, job.tenant_id) { + return JobExecution::Failed { + error: err.to_string(), + }; + } + let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) { Ok(payload) => payload, Err(err) => { diff --git a/backend/src/workers/index.rs b/backend/src/workers/index.rs index 18d235e..5e090ec 100644 --- a/backend/src/workers/index.rs +++ b/backend/src/workers/index.rs @@ -1,101 +1,15 @@ -use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use reqwest::Client; -use serde::Deserialize; -use uuid::Uuid; -use crate::{ - documents::search::{build_quickwit_ingest_record, quickwit_ingest}, - jobs::JOB_INDEX_DOCUMENT_TEXT, - state::AppState, - storage::TenantStorage, -}; +use crate::documents::search::{build_quickwit_ingest_record, quickwit_ingest}; use super::{ - job_execution_from_task_error, ocr::OCR_TEXT_ASSET_TYPE, - taskflow::{ - document::DocumentVersionTaskContext, BoxedTask, Task, TaskError, TaskExecutor, - TaskPlanner, TaskResult, - }, - JobExecution, JobHandler, + taskflow::{document::DocumentVersionTaskContext, Task, TaskError, TaskResult}, }; -#[derive(Debug, Deserialize)] -struct IndexPayload { - document_id: Uuid, - document_version_id: Uuid, -} - -pub struct IndexDocumentTextJob; - -impl IndexDocumentTextJob { - pub fn new() -> Self { - Self - } -} - -#[async_trait] -impl JobHandler for IndexDocumentTextJob { - fn job_type(&self) -> &'static str { - JOB_INDEX_DOCUMENT_TEXT - } - - async fn handle( - &self, - state: Arc, - job: crate::models::Job, - storage: TenantStorage, - ) -> JobExecution { - let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) { - Ok(payload) => payload, - Err(err) => { - return JobExecution::Failed { - error: format!("invalid index payload: {err}"), - }; - } - }; - - let mut context = DocumentVersionTaskContext::new( - job.id, - JOB_INDEX_DOCUMENT_TEXT, - job.tenant_id, - payload.document_id, - payload.document_version_id, - false, - state.config.worker_max_document_bytes, - state.clone(), - storage, - ); - - let planner = IndexPlanner::new(); - match TaskExecutor::run(&planner, &mut context).await { - Ok(()) => JobExecution::Success, - Err(err) => job_execution_from_task_error(err), - } - } -} - -struct IndexPlanner; - -impl IndexPlanner { - fn new() -> Self { - Self - } -} - -#[async_trait] -impl TaskPlanner for IndexPlanner { - async fn plan( - &self, - _ctx: &mut DocumentVersionTaskContext, - ) -> TaskResult>> { - Ok(vec![Box::new(IndexDocumentTask::new())]) - } -} - pub struct IndexDocumentTask; impl IndexDocumentTask { diff --git a/backend/src/workers/mod.rs b/backend/src/workers/mod.rs index c805ad6..61c9619 100644 --- a/backend/src/workers/mod.rs +++ b/backend/src/workers/mod.rs @@ -152,7 +152,6 @@ pub fn default_handlers() -> Vec> { vec![ Arc::new(analyze::AnalyzeDocumentJob::new()), Arc::new(purge::PurgeDocumentJob::new()), - Arc::new(index::IndexDocumentTextJob::new()), Arc::new(ProvisionTenantJob::new()), ] } diff --git a/backend/src/workers/purge.rs b/backend/src/workers/purge.rs index 3244635..ad43aa8 100644 --- a/backend/src/workers/purge.rs +++ b/backend/src/workers/purge.rs @@ -8,6 +8,7 @@ use diesel::result::Error as DieselError; use serde::Deserialize; use uuid::Uuid; +use crate::auth::ensure_active_tenant; use crate::jobs::JOB_PURGE_DOCUMENT; use crate::models::{Document, DocumentVersion}; use crate::schema::{document_asset_objects, document_assets, document_versions}; @@ -52,6 +53,12 @@ impl JobHandler for PurgeDocumentJob { job: crate::models::Job, storage: TenantStorage, ) -> JobExecution { + if let Err(err) = ensure_active_tenant(&state, job.tenant_id) { + return JobExecution::Failed { + error: err.to_string(), + }; + } + let payload: PurgeDocumentPayload = match serde_json::from_value(job.payload.clone()) { Ok(payload) => payload, Err(err) => { diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index 4c9f395..8d83e14 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -8,9 +8,19 @@ use papercrate::auth::passkeys::{ PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, RegistrationChallengeResponse, }; -use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey}; +use papercrate::models::{ + MagicToken, MagicTokenKind, NewUserMembership, NewUserSession, TenantStatus, UserPasskey, +}; use papercrate::openapi::schemas::PasskeySummary; -use papercrate::schema::{capability_sets, tenants, user_memberships, user_sessions, users}; +use papercrate::schema::{ + capability_sets, + magic_tokens::dsl as magic_dsl, + tenants, + tenants::dsl as tenant_dsl, + user_memberships, + user_sessions, + users, +}; use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; use rand::{rngs::OsRng, TryRngCore}; use serde::Deserialize; @@ -678,3 +688,71 @@ fn hash_session_token(value: &str) -> String { hasher.update(value.as_bytes()); hex::encode(hasher.finalize()) } +#[tokio::test] +async fn tenant_selection_excludes_inactive_tenants() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let user_id = app.insert_user("tenant-user", TestUserRole::Owner).await?; + + let tenant_id = app + .with_conn(|conn| { + let tenant: papercrate::models::Tenant = tenant_dsl::tenants + .filter(tenant_dsl::name.eq("test_tenant")) + .first(conn)?; + Ok::<_, anyhow::Error>(tenant.id) + }) + .await?; + + app.with_conn(move |conn| { + diesel::update(tenant_dsl::tenants.find(tenant_id)) + .set(tenant_dsl::status.eq(TenantStatus::Suspended)) + .execute(conn)?; + Ok(()) + }) + .await?; + + let magic_value = "tenant-status-token"; + let token_hash = { + let mut hasher = Sha256::new(); + hasher.update(magic_value.as_bytes()); + hex::encode(hasher.finalize()) + }; + + let user_id_for_token = user_id; + app.with_conn(move |conn| { + let token = MagicToken { + id: Uuid::new_v4(), + user_id: user_id_for_token, + kind: MagicTokenKind::EmailLogin, + token_hash, + metadata: json!({}), + expires_at: (Utc::now() + ChronoDuration::hours(1)).naive_utc(), + max_uses: None, + used_count: 0, + created_at: Utc::now().naive_utc(), + created_by: None, + last_used_at: None, + }; + + diesel::insert_into(magic_dsl::magic_tokens) + .values(&token) + .execute(conn)?; + Ok(()) + }) + .await?; + + let payload = json!({ + "username": "tenant-user", + "magic_token": magic_value, + }); + + let response = app.post_json("/api/auth/login", &payload, None).await?; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = body_to_vec(response.into_body()).await?; + let err: ApiErrorResponse = serde_json::from_slice(&body)?; + assert_eq!(err.error, "no active tenants available"); + + app.cleanup().await?; + Ok(()) +}