multi tenancy part 1

This commit is contained in:
2025-10-22 22:52:59 +02:00
parent 8e3f09774a
commit e175d28c2c
35 changed files with 1288 additions and 314 deletions
@@ -0,0 +1 @@
DROP TABLE IF EXISTS public.tenants;
@@ -0,0 +1,10 @@
CREATE TABLE public.tenants (
tenant_id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
storage_root TEXT,
quickwit_index TEXT,
status TEXT NOT NULL DEFAULT 'active',
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,2 @@
DELETE FROM public.tenants
WHERE slug = 'admin';
@@ -0,0 +1,10 @@
INSERT INTO public.tenants (tenant_id, slug, storage_root, quickwit_index, status, config)
VALUES (
gen_random_uuid(),
'admin',
NULL,
NULL,
'active',
'{}'::jsonb
)
ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS public.user_memberships;
@@ -0,0 +1,12 @@
CREATE TABLE public.user_memberships (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES public.tenants(tenant_id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, tenant_id)
);
CREATE INDEX user_memberships_tenant_id_idx ON public.user_memberships (tenant_id);
CREATE INDEX user_memberships_user_id_idx ON public.user_memberships (user_id);
@@ -0,0 +1,7 @@
DELETE FROM public.user_memberships
WHERE user_id IN (
SELECT id FROM public.users WHERE username = 'admin'
)
AND tenant_id IN (
SELECT tenant_id FROM public.tenants WHERE slug = 'admin'
);
@@ -0,0 +1,7 @@
INSERT INTO public.user_memberships (id, user_id, tenant_id, role)
SELECT gen_random_uuid(), u.id, t.tenant_id, 'admin'
FROM public.users AS u
JOIN public.tenants AS t ON t.slug = 'admin'
LEFT JOIN public.user_memberships AS um ON um.user_id = u.id AND um.tenant_id = t.tenant_id
WHERE u.username = 'admin' AND um.id IS NULL
ON CONFLICT (user_id, tenant_id) DO NOTHING;
@@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN role VARCHAR(16) NOT NULL DEFAULT 'user';
@@ -0,0 +1,2 @@
ALTER TABLE users
DROP COLUMN role;
@@ -0,0 +1,47 @@
DROP INDEX IF EXISTS jobs_tenant_id_idx;
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_tenant_id_fkey;
ALTER TABLE jobs DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS refresh_tokens_tenant_id_idx;
ALTER TABLE refresh_tokens DROP CONSTRAINT IF EXISTS refresh_tokens_tenant_id_fkey;
ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS users_tenant_id_idx;
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_tenant_id_fkey;
ALTER TABLE users DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS folders_tenant_id_idx;
ALTER TABLE folders DROP CONSTRAINT IF EXISTS folders_tenant_id_fkey;
ALTER TABLE folders DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS tags_tenant_id_idx;
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_tenant_id_fkey;
ALTER TABLE tags DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS correspondents_tenant_id_idx;
ALTER TABLE correspondents DROP CONSTRAINT IF EXISTS correspondents_tenant_id_fkey;
ALTER TABLE correspondents DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS document_correspondents_tenant_id_idx;
ALTER TABLE document_correspondents DROP CONSTRAINT IF EXISTS document_correspondents_tenant_id_fkey;
ALTER TABLE document_correspondents DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS document_tags_tenant_id_idx;
ALTER TABLE document_tags DROP CONSTRAINT IF EXISTS document_tags_tenant_id_fkey;
ALTER TABLE document_tags DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS document_asset_objects_tenant_id_idx;
ALTER TABLE document_asset_objects DROP CONSTRAINT IF EXISTS document_asset_objects_tenant_id_fkey;
ALTER TABLE document_asset_objects DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS document_assets_tenant_id_idx;
ALTER TABLE document_assets DROP CONSTRAINT IF EXISTS document_assets_tenant_id_fkey;
ALTER TABLE document_assets DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS document_versions_tenant_id_idx;
ALTER TABLE document_versions DROP CONSTRAINT IF EXISTS document_versions_tenant_id_fkey;
ALTER TABLE document_versions DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS documents_tenant_id_idx;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_tenant_id_fkey;
ALTER TABLE documents DROP COLUMN IF EXISTS tenant_id;
@@ -0,0 +1,119 @@
-- Add tenant_id to documents
ALTER TABLE documents ADD COLUMN tenant_id UUID;
UPDATE documents
SET tenant_id = (SELECT tenant_id FROM public.tenants WHERE slug = 'admin');
ALTER TABLE documents ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE documents
ADD CONSTRAINT documents_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id);
-- Add tenant_id to document_versions
ALTER TABLE document_versions ADD COLUMN tenant_id UUID;
UPDATE document_versions AS dv
SET tenant_id = d.tenant_id
FROM documents AS d
WHERE dv.document_id = d.id;
ALTER TABLE document_versions ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE document_versions
ADD CONSTRAINT document_versions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id);
-- Add tenant_id to document_assets
ALTER TABLE document_assets ADD COLUMN tenant_id UUID;
UPDATE document_assets AS da
SET tenant_id = dv.tenant_id
FROM document_versions AS dv
WHERE da.document_version_id = dv.id;
ALTER TABLE document_assets ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE document_assets
ADD CONSTRAINT document_assets_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id);
-- Add tenant_id to document_asset_objects
ALTER TABLE document_asset_objects ADD COLUMN tenant_id UUID;
UPDATE document_asset_objects AS dao
SET tenant_id = da.tenant_id
FROM document_assets AS da
WHERE dao.asset_id = da.id;
ALTER TABLE document_asset_objects ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE document_asset_objects
ADD CONSTRAINT document_asset_objects_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX document_asset_objects_tenant_id_idx ON document_asset_objects (tenant_id);
-- Add tenant_id to document_tags
ALTER TABLE document_tags ADD COLUMN tenant_id UUID;
UPDATE document_tags AS dt
SET tenant_id = d.tenant_id
FROM documents AS d
WHERE dt.document_id = d.id;
ALTER TABLE document_tags ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE document_tags
ADD CONSTRAINT document_tags_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX document_tags_tenant_id_idx ON document_tags (tenant_id);
-- Add tenant_id to document_correspondents
ALTER TABLE document_correspondents ADD COLUMN tenant_id UUID;
UPDATE document_correspondents AS dc
SET tenant_id = d.tenant_id
FROM documents AS d
WHERE dc.document_id = d.id;
ALTER TABLE document_correspondents ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE document_correspondents
ADD CONSTRAINT document_correspondents_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
-- Add tenant_id to correspondents
ALTER TABLE correspondents ADD COLUMN tenant_id UUID;
UPDATE correspondents
SET tenant_id = (SELECT tenant_id FROM public.tenants WHERE slug = 'admin');
ALTER TABLE correspondents ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE correspondents
ADD CONSTRAINT correspondents_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX correspondents_tenant_id_idx ON correspondents (tenant_id);
-- Add tenant_id to tags
ALTER TABLE tags ADD COLUMN tenant_id UUID;
UPDATE tags
SET tenant_id = (SELECT tenant_id FROM public.tenants WHERE slug = 'admin');
ALTER TABLE tags ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE tags
ADD CONSTRAINT tags_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX tags_tenant_id_idx ON tags (tenant_id);
-- Add tenant_id to folders
ALTER TABLE folders ADD COLUMN tenant_id UUID;
UPDATE folders
SET tenant_id = (SELECT tenant_id FROM public.tenants WHERE slug = 'admin');
ALTER TABLE folders ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE folders
ADD CONSTRAINT folders_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX folders_tenant_id_idx ON folders (tenant_id);
-- Add tenant_id to users
ALTER TABLE users ADD COLUMN tenant_id UUID;
UPDATE users
SET tenant_id = (SELECT tenant_id FROM public.tenants WHERE slug = 'admin');
ALTER TABLE users ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE users
ADD CONSTRAINT users_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX users_tenant_id_idx ON users (tenant_id);
-- Add tenant_id to refresh_tokens
ALTER TABLE refresh_tokens ADD COLUMN tenant_id UUID;
UPDATE refresh_tokens AS rt
SET tenant_id = u.tenant_id
FROM users AS u
WHERE rt.user_id = u.id;
ALTER TABLE refresh_tokens ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE refresh_tokens
ADD CONSTRAINT refresh_tokens_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens (tenant_id);
-- Add tenant_id to jobs
ALTER TABLE jobs ADD COLUMN tenant_id UUID;
UPDATE jobs
SET tenant_id = (SELECT tenant_id FROM public.tenants WHERE slug = 'admin');
ALTER TABLE jobs ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE jobs
ADD CONSTRAINT jobs_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(tenant_id);
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id);
+46 -4
View File
@@ -15,6 +15,8 @@ pub struct JwtService {
expiry: Duration, expiry: Duration,
download_audience: String, download_audience: String,
download_expiry: Duration, download_expiry: Duration,
selector_audience: String,
selector_expiry: Duration,
} }
impl JwtService { impl JwtService {
@@ -27,16 +29,18 @@ impl JwtService {
expiry: Duration::minutes(config.jwt_expiry_minutes), expiry: Duration::minutes(config.jwt_expiry_minutes),
download_audience: config.download_token_audience.clone(), download_audience: config.download_token_audience.clone(),
download_expiry: Duration::minutes(config.download_token_expiry_minutes), download_expiry: Duration::minutes(config.download_token_expiry_minutes),
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
selector_expiry: Duration::minutes(15),
}) })
} }
pub fn generate_token(&self, user_id: Uuid, username: &str, role: &str) -> Result<String> { pub fn generate_token(&self, user_id: Uuid, tenant_id: Uuid, username: &str) -> Result<String> {
let now = Utc::now(); let now = Utc::now();
let exp = now + self.expiry; let exp = now + self.expiry;
let claims = Claims { let claims = Claims {
sub: user_id, sub: user_id,
tenant_id,
username: username.to_owned(), username: username.to_owned(),
role: role.to_owned(),
iss: self.issuer.clone(), iss: self.issuer.clone(),
aud: self.audience.clone(), aud: self.audience.clone(),
iat: now.timestamp() as usize, iat: now.timestamp() as usize,
@@ -54,12 +58,18 @@ impl JwtService {
Ok(data.claims) Ok(data.claims)
} }
pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result<String> { pub fn generate_download_token(
&self,
document_id: Uuid,
user_id: Uuid,
tenant_id: Uuid,
) -> Result<String> {
let now = Utc::now(); let now = Utc::now();
let exp = now + self.download_expiry; let exp = now + self.download_expiry;
let claims = DownloadClaims { let claims = DownloadClaims {
doc_id: document_id, doc_id: document_id,
user_id, user_id,
tenant_id,
iss: self.issuer.clone(), iss: self.issuer.clone(),
aud: self.download_audience.clone(), aud: self.download_audience.clone(),
iat: now.timestamp() as usize, iat: now.timestamp() as usize,
@@ -76,13 +86,35 @@ impl JwtService {
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?; let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
Ok(data.claims) Ok(data.claims)
} }
pub fn generate_tenant_selector_token(&self, user_id: Uuid) -> Result<String> {
let now = Utc::now();
let exp = now + self.selector_expiry;
let claims = TenantSelectionClaims {
sub: user_id,
iss: self.issuer.clone(),
aud: self.selector_audience.clone(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
};
Ok(encode(&Header::default(), &claims, &self.encoding)?)
}
pub fn verify_tenant_selector_token(&self, token: &str) -> Result<TenantSelectionClaims> {
let mut validation = Validation::default();
validation.set_audience(&[self.selector_audience.clone()]);
validation.set_issuer(&[self.issuer.clone()]);
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims { pub struct Claims {
pub sub: Uuid, pub sub: Uuid,
pub tenant_id: Uuid,
pub username: String, pub username: String,
pub role: String,
pub iss: String, pub iss: String,
pub aud: String, pub aud: String,
pub iat: usize, pub iat: usize,
@@ -93,6 +125,16 @@ pub struct Claims {
pub struct DownloadClaims { pub struct DownloadClaims {
pub doc_id: Uuid, pub doc_id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub tenant_id: Uuid,
pub iss: String,
pub aud: String,
pub iat: usize,
pub exp: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TenantSelectionClaims {
pub sub: Uuid,
pub iss: String, pub iss: String,
pub aud: String, pub aud: String,
pub iat: usize, pub iat: usize,
+50 -4
View File
@@ -6,13 +6,17 @@ use axum_extra::headers::{authorization::Bearer, Authorization};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{error::AppError, state::AppState}; use crate::{
error::AppError,
state::{AppState, PgPooledConnection},
};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthenticatedUser { pub struct AuthenticatedUser {
pub user_id: uuid::Uuid, pub user_id: uuid::Uuid,
pub username: String, pub username: String,
pub role: String, pub tenant_id: uuid::Uuid,
} }
#[async_trait] #[async_trait]
@@ -23,6 +27,10 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
parts: &mut Parts, parts: &mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
return Ok(user.clone());
}
let TypedHeader(Authorization(bearer)) = let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state) TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
.await .await
@@ -33,10 +41,48 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
.verify_token(bearer.token()) .verify_token(bearer.token())
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
Ok(AuthenticatedUser { let user = AuthenticatedUser {
user_id: claims.sub, user_id: claims.sub,
username: claims.username, username: claims.username,
role: claims.role, tenant_id: claims.tenant_id,
};
parts.extensions.insert(user.clone());
Ok(user)
}
}
pub struct TenantScopedConn {
pub conn: PgPooledConnection,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub user: AuthenticatedUser,
}
impl TenantScopedConn {
pub fn conn(&mut self) -> &mut PgPooledConnection {
&mut self.conn
}
}
#[async_trait]
impl FromRequestParts<AppState> for TenantScopedConn {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
let tenant_id = user.tenant_id;
let conn = state.db_for_tenant(tenant_id)?;
Ok(Self {
conn,
tenant_id,
user_id: user.user_id,
user,
}) })
} }
} }
+4
View File
@@ -30,6 +30,7 @@ pub struct AppConfig {
pub s3_bucket: String, pub s3_bucket: String,
pub quickwit_endpoint: Option<String>, pub quickwit_endpoint: Option<String>,
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
pub default_tenant_slug: String,
} }
impl AppConfig { impl AppConfig {
@@ -79,6 +80,8 @@ impl AppConfig {
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?; let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok(); let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
let quickwit_index = env::var("QUICKWIT_INDEX").ok(); let quickwit_index = env::var("QUICKWIT_INDEX").ok();
let default_tenant_slug =
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
Ok(Self { Ok(Self {
database_url, database_url,
@@ -104,6 +107,7 @@ impl AppConfig {
s3_bucket, s3_bucket,
quickwit_endpoint, quickwit_endpoint,
quickwit_index, quickwit_index,
default_tenant_slug,
}) })
} }
+2
View File
@@ -30,6 +30,7 @@ pub type JobQueueResult<T> = Result<T, JobQueueError>;
pub fn enqueue_job( pub fn enqueue_job(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
job_type: &str, job_type: &str,
payload: Value, payload: Value,
run_after: Option<NaiveDateTime>, run_after: Option<NaiveDateTime>,
@@ -40,6 +41,7 @@ pub fn enqueue_job(
payload, payload,
status: STATUS_QUEUED.to_string(), status: STATUS_QUEUED.to_string(),
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()), run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
tenant_id,
}; };
diesel::insert_into(jobs::table) diesel::insert_into(jobs::table)
+1
View File
@@ -9,6 +9,7 @@ pub mod s3;
pub mod schema; pub mod schema;
pub mod state; pub mod state;
pub mod storage; pub mod storage;
pub mod tenants;
pub mod utils; pub mod utils;
pub mod workers; pub mod workers;
pub use workers::{default_handlers, Worker}; pub use workers::{default_handlers, Worker};
+60 -2
View File
@@ -4,15 +4,51 @@ use uuid::Uuid;
use crate::schema::*; use crate::schema::*;
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = user_memberships)]
#[diesel(belongs_to(User, foreign_key = user_id))]
#[diesel(belongs_to(Tenant, foreign_key = tenant_id))]
pub struct UserMembership {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub role: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = user_memberships)]
pub struct NewUserMembership {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub role: String,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = tenants)]
#[diesel(primary_key(tenant_id))]
pub struct Tenant {
pub tenant_id: Uuid,
pub slug: String,
pub storage_root: Option<String>,
pub quickwit_index: Option<String>,
pub status: String,
pub config: serde_json::Value,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = users)] #[diesel(table_name = users)]
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: String, pub password_hash: String,
pub role: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -21,7 +57,7 @@ pub struct NewUser {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: String, pub password_hash: String,
pub role: String, pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -32,6 +68,7 @@ pub struct Folder {
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -40,6 +77,7 @@ pub struct NewFolder {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -58,6 +96,7 @@ pub struct Document {
pub issued_at: Option<NaiveDateTime>, pub issued_at: Option<NaiveDateTime>,
pub title: String, pub title: String,
pub current_version_id: Uuid, pub current_version_id: Uuid,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -72,6 +111,7 @@ pub struct NewDocument {
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub issued_at: Option<NaiveDateTime>, pub issued_at: Option<NaiveDateTime>,
pub title: String, pub title: String,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -87,6 +127,7 @@ pub struct DocumentVersion {
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub operations_summary: serde_json::Value, pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -100,6 +141,7 @@ pub struct NewDocumentVersion {
pub checksum: String, pub checksum: String,
pub operations_summary: serde_json::Value, pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -113,6 +155,7 @@ pub struct DocumentAsset {
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub cardinality: Option<i32>, pub cardinality: Option<i32>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -124,6 +167,7 @@ pub struct NewDocumentAsset {
pub mime_type: String, pub mime_type: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub cardinality: Option<i32>, pub cardinality: Option<i32>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -135,6 +179,7 @@ pub struct DocumentAssetObject {
pub ordinal: i32, pub ordinal: i32,
pub s3_key: String, pub s3_key: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -145,6 +190,7 @@ pub struct NewDocumentAssetObject {
pub ordinal: i32, pub ordinal: i32,
pub s3_key: String, pub s3_key: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -159,6 +205,7 @@ pub struct Job {
pub last_error: Option<String>, pub last_error: Option<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -169,6 +216,7 @@ pub struct NewJob {
pub payload: serde_json::Value, pub payload: serde_json::Value,
pub status: String, pub status: String,
pub run_after: NaiveDateTime, pub run_after: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -178,6 +226,7 @@ pub struct Tag {
pub label: String, pub label: String,
pub color: Option<String>, pub color: Option<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -186,6 +235,7 @@ pub struct NewTag {
pub id: Uuid, pub id: Uuid,
pub label: String, pub label: String,
pub color: Option<String>, pub color: Option<String>,
pub tenant_id: Uuid,
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -199,6 +249,7 @@ pub struct DocumentTag {
pub tag_id: Uuid, pub tag_id: Uuid,
pub assigned_at: NaiveDateTime, pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -207,6 +258,7 @@ pub struct NewDocumentTag {
pub document_id: Uuid, pub document_id: Uuid,
pub tag_id: Uuid, pub tag_id: Uuid,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -217,6 +269,7 @@ pub struct Correspondent {
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -225,6 +278,7 @@ pub struct NewCorrespondent {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Associations)] #[derive(Debug, Clone, Queryable, Associations)]
@@ -238,6 +292,7 @@ pub struct DocumentCorrespondent {
pub role: String, pub role: String,
pub assigned_at: NaiveDateTime, pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -247,6 +302,7 @@ pub struct NewDocumentCorrespondent {
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String, pub role: String,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -261,6 +317,7 @@ pub struct RefreshToken {
pub revoked_at: Option<NaiveDateTime>, pub revoked_at: Option<NaiveDateTime>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -271,4 +328,5 @@ pub struct NewRefreshToken {
pub token_hash: String, pub token_hash: String,
pub issued_at: NaiveDateTime, pub issued_at: NaiveDateTime,
pub expires_at: NaiveDateTime, pub expires_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
+152 -72
View File
@@ -1,11 +1,15 @@
use axum::{ use axum::{
extract::State, extract::State,
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json, Json,
}; };
use axum_extra::{headers::Cookie, typed_header::TypedHeader}; use axum_extra::{
headers::{authorization::Bearer, Authorization, Cookie},
typed_header::TypedHeader,
};
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use diesel::prelude::*; use diesel::{pg::PgConnection, prelude::*};
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -14,8 +18,11 @@ use uuid::Uuid;
use crate::{ use crate::{
auth::{password, AuthenticatedUser}, auth::{password, AuthenticatedUser},
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{NewRefreshToken, RefreshToken, User}, models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
schema::{refresh_tokens, users::dsl}, schema::{
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
users::dsl,
},
state::AppState, state::AppState,
}; };
@@ -27,6 +34,8 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
pub struct LoginRequest { pub struct LoginRequest {
pub username: String, pub username: String,
pub password: String, pub password: String,
#[serde(default)]
pub preferred_tenant_slug: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -36,11 +45,28 @@ pub struct LoginResponse {
pub expires_in: i64, pub expires_in: i64,
} }
#[derive(Serialize)]
pub struct TenantSummary {
pub tenant_id: Uuid,
pub slug: String,
}
#[derive(Serialize)]
pub struct TenantSelectionResponse {
pub selection_token: String,
pub tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
pub async fn login( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> AppResult<(HeaderMap, Json<LoginResponse>)> { ) -> AppResult<Response> {
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let user: User = dsl::users let user: User = dsl::users
.filter(dsl::username.eq(&payload.username)) .filter(dsl::username.eq(&payload.username))
@@ -53,55 +79,67 @@ pub async fn login(
return Err(AppError::unauthorized()); return Err(AppError::unauthorized());
} }
let access_token = state let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id))
.load(&mut conn)?;
if memberships.is_empty() {
return Err(AppError::unauthorized());
}
let preferred_slug = payload
.preferred_tenant_slug
.as_ref()
.map(|slug| slug.trim().to_string())
.filter(|slug| !slug.is_empty());
if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| {
memberships
.iter()
.find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug))
}) {
return issue_session(&state, &mut conn, &user, tenant.1.tenant_id);
}
if memberships.len() == 1 {
let tenant_id = memberships[0].1.tenant_id;
return issue_session(&state, &mut conn, &user, tenant_id);
}
let selection_token = state
.jwt .jwt
.generate_token(user.id, &user.username, &user.role) .generate_tenant_selector_token(user.id)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let now = Utc::now(); let tenants = memberships
let refresh_value = generate_refresh_token(); .into_iter()
let refresh_hash = hash_refresh_token(&refresh_value); .map(|(_, tenant)| TenantSummary {
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); tenant_id: tenant.tenant_id,
slug: tenant.slug,
})
.collect();
let new_refresh = NewRefreshToken { let response = Json(TenantSelectionResponse {
id: Uuid::new_v4(), selection_token,
user_id: user.id, tenants,
token_hash: refresh_hash, })
issued_at: now.naive_utc(), .into_response();
expires_at: refresh_expires_at.naive_utc(),
};
diesel::insert_into(refresh_tokens::table) Ok(response)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &refresh_value, refresh_expires_at),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
} }
pub async fn refresh( pub async fn refresh(
State(state): State<AppState>, State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, Json<LoginResponse>)> { ) -> AppResult<Response> {
let cookies = jar.ok_or_else(AppError::unauthorized)?; let cookies = jar.ok_or_else(AppError::unauthorized)?;
let refresh_value = cookies let refresh_value = cookies
.get(REFRESH_COOKIE_NAME) .get(REFRESH_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?; .ok_or_else(AppError::unauthorized)?;
let hashed = hash_refresh_token(refresh_value); let hashed = hash_refresh_token(refresh_value);
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let now = Utc::now(); let now = Utc::now();
let now_naive = now.naive_utc(); let now_naive = now.naive_utc();
@@ -128,41 +166,39 @@ pub async fn refresh(
.first(&mut conn) .first(&mut conn)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let access_token = state issue_session(&state, &mut conn, &user, token.tenant_id)
}
pub async fn select_tenant(
State(state): State<AppState>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> {
let claims = state
.jwt .jwt
.generate_token(user.id, &user.username, &user.role) .verify_tenant_selector_token(bearer.token())
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db_unscoped()?;
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(claims.sub))
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
.inner_join(tenant_dsl::tenants)
.select(memberships_dsl::id)
.first::<Uuid>(&mut conn)
.optional()?;
if membership_exists.is_none() {
return Err(AppError::unauthorized());
}
let user: User = dsl::users
.find(claims.sub)
.first(&mut conn)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let new_refresh_value = generate_refresh_token(); issue_session(&state, &mut conn, &user, payload.tenant_id)
let new_refresh_hash = hash_refresh_token(&new_refresh_value);
let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: new_refresh_hash,
issued_at: now_naive,
expires_at: new_refresh_expires.naive_utc(),
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
} }
pub async fn logout( pub async fn logout(
@@ -170,7 +206,7 @@ pub async fn logout(
user: AuthenticatedUser, user: AuthenticatedUser,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> { ) -> AppResult<(HeaderMap, StatusCode)> {
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let mut rows_affected = 0; let mut rows_affected = 0;
@@ -214,6 +250,50 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user) Json(user)
} }
fn issue_session(
state: &AppState,
conn: &mut PgConnection,
user: &User,
tenant_id: Uuid,
) -> AppResult<Response> {
let now = Utc::now();
let access_token = state
.jwt
.generate_token(user.id, tenant_id, &user.username)
.map_err(AppError::from)?;
let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: refresh_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id,
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(conn)?;
let mut response = Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
})
.into_response();
response.headers_mut().insert(
SET_COOKIE,
build_refresh_cookie(state, &refresh_value, refresh_expires_at),
);
Ok(response)
}
fn hash_refresh_token(token: &str) -> String { fn hash_refresh_token(token: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
+44 -22
View File
@@ -1,11 +1,6 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use axum::{ use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json};
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc; use chrono::Utc;
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection}; use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -13,10 +8,10 @@ use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{Correspondent, NewCorrespondent}, models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents}, schema::{correspondents, document_correspondents},
state::AppState,
}; };
use super::documents::to_iso; use super::documents::to_iso;
@@ -59,15 +54,19 @@ struct CorrespondentChangeset<'a> {
} }
pub async fn list_correspondents( pub async fn list_correspondents(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<CorrespondentSummary>>> { ) -> AppResult<Json<Vec<CorrespondentSummary>>> {
let mut conn = state.db()?;
let correspondents_list: Vec<Correspondent> = correspondents::table let correspondents_list: Vec<Correspondent> = correspondents::table
.filter(correspondents::tenant_id.eq(tenant_id))
.order(correspondents::name.asc()) .order(correspondents::name.asc())
.load(&mut conn)?; .load(&mut conn)?;
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(( .group_by((
document_correspondents::correspondent_id, document_correspondents::correspondent_id,
document_correspondents::role, document_correspondents::role,
@@ -97,7 +96,11 @@ pub async fn list_correspondents(
} }
pub async fn create_correspondent( pub async fn create_correspondent(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>, Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<Json<CorrespondentSummary>> {
let name = payload.name.trim(); let name = payload.name.trim();
@@ -111,9 +114,9 @@ pub async fn create_correspondent(
id: new_id, id: new_id,
name: name.to_string(), name: name.to_string(),
metadata: metadata_value, metadata: metadata_value,
tenant_id,
}; };
let mut conn = state.db()?;
match diesel::insert_into(correspondents::table) match diesel::insert_into(correspondents::table)
.values(&new_correspondent) .values(&new_correspondent)
.execute(&mut conn) .execute(&mut conn)
@@ -130,13 +133,17 @@ pub async fn create_correspondent(
} }
pub async fn update_correspondent( pub async fn update_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>, Path(correspondent_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateCorrespondentRequest>, Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<Json<CorrespondentSummary>> {
let mut conn = state.db()?;
let existing: Correspondent = correspondents::table let existing: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)?; .first(&mut conn)?;
let mut new_name: Option<String> = None; let mut new_name: Option<String> = None;
@@ -149,6 +156,7 @@ pub async fn update_correspondent(
let duplicate = correspondents::table let duplicate = correspondents::table
.filter(correspondents::name.eq(trimmed)) .filter(correspondents::name.eq(trimmed))
.filter(correspondents::id.ne(correspondent_id)) .filter(correspondents::id.ne(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id))
.first::<Correspondent>(&mut conn) .first::<Correspondent>(&mut conn)
.optional()?; .optional()?;
if duplicate.is_some() { if duplicate.is_some() {
@@ -167,7 +175,7 @@ pub async fn update_correspondent(
} }
if new_name.is_none() && new_metadata.is_none() { if new_name.is_none() && new_metadata.is_none() {
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?; let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
return Ok(Json(build_summary(existing.clone(), usage))); return Ok(Json(build_summary(existing.clone(), usage)));
} }
@@ -180,24 +188,32 @@ pub async fn update_correspondent(
} }
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
diesel::update(correspondents::table.find(correspondent_id)) diesel::update(
correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.set((&changeset, correspondents::updated_at.eq(now))) .set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?; .execute(&mut conn)?;
let updated: Correspondent = correspondents::table let updated: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)?; .first(&mut conn)?;
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?; let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
Ok(Json(build_summary(updated, usage))) Ok(Json(build_summary(updated, usage)))
} }
pub async fn delete_correspondent( pub async fn delete_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>, Path(correspondent_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
let usage: i64 = document_correspondents::table let usage: i64 = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
@@ -208,8 +224,12 @@ pub async fn delete_correspondent(
)); ));
} }
let deleted = let deleted = diesel::delete(
diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?; correspondents::table
.filter(correspondents::id.eq(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 { if deleted == 0 {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
@@ -243,10 +263,12 @@ fn normalize_metadata(input: Option<Value>) -> Value {
fn load_usage_for_correspondent( fn load_usage_for_correspondent(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
correspondent_id: Uuid, correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> { ) -> AppResult<BTreeMap<String, i64>> {
let rows: Vec<(String, i64)> = document_correspondents::table let rows: Vec<(String, i64)> = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::role) .group_by(document_correspondents::role)
.select((document_correspondents::role, count_star())) .select((document_correspondents::role, count_star()))
.load(conn)?; .load(conn)?;
+249 -97
View File
@@ -18,7 +18,7 @@ use tracing::{debug, error, info, warn};
use uuid::Uuid; use uuid::Uuid;
use super::folders::gather_descendant_folder_ids; use super::folders::gather_descendant_folder_ids;
use crate::auth::AuthenticatedUser; use crate::auth::TenantScopedConn;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}; use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
use crate::models::{ use crate::models::{
@@ -372,10 +372,13 @@ pub struct AssetObjectsQuery {
pub async fn list_documents( pub async fn list_documents(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<DocumentListQuery>, Query(params): Query<DocumentListQuery>,
user: AuthenticatedUser, TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentResponse>>> { ) -> AppResult<Json<Vec<DocumentResponse>>> {
let mut conn = state.db()?;
let DocumentListQuery { let DocumentListQuery {
folder_id, folder_id,
include_deleted, include_deleted,
@@ -385,7 +388,9 @@ pub async fn list_documents(
correspondents, correspondents,
} = params; } = params;
let mut docs_query = documents::table.into_boxed(); let mut docs_query = documents::table
.filter(documents::tenant_id.eq(tenant_id))
.into_boxed();
if !include_deleted { if !include_deleted {
docs_query = docs_query.filter(documents::deleted_at.is_null()); docs_query = docs_query.filter(documents::deleted_at.is_null());
@@ -414,7 +419,7 @@ pub async fn list_documents(
match (folder_id, include_descendants) { match (folder_id, include_descendants) {
(Some(folder_id), true) => { (Some(folder_id), true) => {
let descendant_ids = gather_descendant_folder_ids(&mut conn, folder_id)?; let descendant_ids = gather_descendant_folder_ids(&mut conn, tenant_id, folder_id)?;
docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids)); docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids));
} }
(Some(folder_id), false) => { (Some(folder_id), false) => {
@@ -598,7 +603,7 @@ pub async fn list_documents(
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn); drop(conn);
let primary_versions = load_primary_assets(&state, &docs).await?; let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
let mut response = Vec::with_capacity(doc_ids.len()); let mut response = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
let tags = tags_map.get(&doc.id).cloned(); let tags = tags_map.get(&doc.id).cloned();
@@ -606,7 +611,7 @@ pub async fn list_documents(
let current_version = primary_versions.get(&doc.id).cloned(); let current_version = primary_versions.get(&doc.id).cloned();
response.push(to_document_response( response.push(to_document_response(
&state, &state,
user.user_id, user_id,
doc, doc,
tags, tags,
correspondents, correspondents,
@@ -620,11 +625,17 @@ pub async fn list_documents(
pub async fn get_document( pub async fn get_document(
State(state): State<AppState>, State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
user: AuthenticatedUser, TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentDetailResponse>> { ) -> AppResult<Json<DocumentDetailResponse>> {
let mut conn = state.db()?; let doc: Document = documents::table
.find(document_id)
let doc: Document = documents::table.find(document_id).first(&mut conn)?; .filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if doc.deleted_at.is_some() { if doc.deleted_at.is_some() {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
@@ -638,13 +649,13 @@ pub async fn get_document(
let version_id = current_version.id; let version_id = current_version.id;
drop(conn); drop(conn);
let assets = load_asset_responses(&state, version_id).await?; let assets = load_asset_responses(&state, tenant_id, version_id).await?;
let version_response = to_version_response(current_version, true); let version_response = to_version_response(current_version, true);
Ok(Json(DocumentDetailResponse { Ok(Json(DocumentDetailResponse {
document: to_document_response( document: to_document_response(
&state, &state,
user.user_id, user_id,
doc, doc,
tags_map.get(&document_id).cloned(), tags_map.get(&document_id).cloned(),
correspondents_map.remove(&document_id).unwrap_or_default(), correspondents_map.remove(&document_id).unwrap_or_default(),
@@ -655,7 +666,9 @@ pub async fn get_document(
pub async fn upload_document( pub async fn upload_document(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, TenantScopedConn {
tenant_id, user_id, ..
}: TenantScopedConn,
mut multipart: Multipart, mut multipart: Multipart,
) -> AppResult<(StatusCode, Json<DocumentDetailResponse>)> { ) -> AppResult<(StatusCode, Json<DocumentDetailResponse>)> {
let mut file_bytes: Option<Vec<u8>> = None; let mut file_bytes: Option<Vec<u8>> = None;
@@ -733,7 +746,7 @@ pub async fn upload_document(
metadata, metadata,
}; };
let outcome = match process_upload(&state, request, user.user_id).await { let outcome = match process_upload(&state, request, tenant_id, user_id).await {
Ok(outcome) => { Ok(outcome) => {
info!( info!(
document_id = %outcome.detail.document.id, document_id = %outcome.detail.document.id,
@@ -759,18 +772,25 @@ pub async fn upload_document(
} }
pub async fn request_document_assets( pub async fn request_document_assets(
State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
Query(query): Query<AssetRequestQuery>, Query(query): Query<AssetRequestQuery>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
let mut conn = state.db()?; let document: Document = documents::table
let document: Document = documents::table.find(document_id).first(&mut conn)?; .find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if document.deleted_at.is_some() { if document.deleted_at.is_some() {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
enqueue_job( enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_ANALYZE_DOCUMENT, JOB_ANALYZE_DOCUMENT,
json!({ json!({
"document_id": document_id, "document_id": document_id,
@@ -785,13 +805,15 @@ pub async fn request_document_assets(
} }
pub async fn reanalyze_all_documents( pub async fn reanalyze_all_documents(
State(state): State<AppState>, TenantScopedConn {
_user: AuthenticatedUser, mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> { ) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
let mut conn = state.db()?;
let targets: Vec<(Uuid, Uuid)> = documents::table let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::deleted_at.is_null()) .filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::current_version_id)) .select((documents::id, documents::current_version_id))
.load(&mut conn)?; .load(&mut conn)?;
@@ -799,6 +821,7 @@ pub async fn reanalyze_all_documents(
for (document_id, version_id) in targets { for (document_id, version_id) in targets {
enqueue_job( enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_ANALYZE_DOCUMENT, JOB_ANALYZE_DOCUMENT,
json!({ json!({
"document_id": document_id, "document_id": document_id,
@@ -815,7 +838,11 @@ pub async fn reanalyze_all_documents(
} }
pub async fn reanalyze_selected_documents( pub async fn reanalyze_selected_documents(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkReanalyzeSelectionRequest>, Json(payload): Json<BulkReanalyzeSelectionRequest>,
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> { ) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
let BulkReanalyzeSelectionRequest { let BulkReanalyzeSelectionRequest {
@@ -830,11 +857,10 @@ pub async fn reanalyze_selected_documents(
document_ids.sort(); document_ids.sort();
document_ids.dedup(); document_ids.dedup();
let mut conn = state.db()?;
let targets: Vec<(Uuid, Uuid)> = documents::table let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::id.eq_any(&document_ids)) .filter(documents::id.eq_any(&document_ids))
.filter(documents::deleted_at.is_null()) .filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::current_version_id)) .select((documents::id, documents::current_version_id))
.load(&mut conn)?; .load(&mut conn)?;
@@ -848,6 +874,7 @@ pub async fn reanalyze_selected_documents(
for (document_id, version_id) in targets { for (document_id, version_id) in targets {
enqueue_job( enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_ANALYZE_DOCUMENT, JOB_ANALYZE_DOCUMENT,
json!({ json!({
"document_id": document_id, "document_id": document_id,
@@ -866,9 +893,16 @@ pub async fn reanalyze_selected_documents(
pub async fn list_document_assets( pub async fn list_document_assets(
State(state): State<AppState>, State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentAssetResponse>>> { ) -> AppResult<Json<Vec<DocumentAssetResponse>>> {
let mut conn = state.db()?; let document: Document = documents::table
let document: Document = documents::table.find(document_id).first(&mut conn)?; .find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if document.deleted_at.is_some() { if document.deleted_at.is_some() {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
@@ -876,7 +910,7 @@ pub async fn list_document_assets(
let version_id = document.current_version_id; let version_id = document.current_version_id;
drop(conn); drop(conn);
let assets = load_asset_responses(&state, version_id).await?; let assets = load_asset_responses(&state, tenant_id, version_id).await?;
Ok(Json(assets)) Ok(Json(assets))
} }
@@ -884,11 +918,15 @@ pub async fn get_document_asset(
State(state): State<AppState>, State(state): State<AppState>,
Path(asset_id): Path<Uuid>, Path(asset_id): Path<Uuid>,
Query(query): Query<AssetObjectsQuery>, Query(query): Query<AssetObjectsQuery>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentAssetDetailResponse>> { ) -> AppResult<Json<DocumentAssetDetailResponse>> {
let mut conn = state.db()?;
let asset: DocumentAsset = match document_assets::table let asset: DocumentAsset = match document_assets::table
.find(asset_id) .find(asset_id)
.filter(document_assets::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.optional()? .optional()?
{ {
@@ -911,6 +949,7 @@ pub async fn get_document_asset(
let objects: Vec<DocumentAssetObject> = document_asset_objects::table let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset_id)) .filter(document_asset_objects::asset_id.eq(asset_id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.filter(document_asset_objects::ordinal.ge(start)) .filter(document_asset_objects::ordinal.ge(start))
.filter(document_asset_objects::ordinal.le(end)) .filter(document_asset_objects::ordinal.le(end))
.order(document_asset_objects::ordinal.asc()) .order(document_asset_objects::ordinal.asc())
@@ -951,9 +990,16 @@ pub async fn get_document_asset(
pub async fn download_document( pub async fn download_document(
State(state): State<AppState>, State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentDownloadResponse>> { ) -> AppResult<Json<DocumentDownloadResponse>> {
let mut conn = state.db()?; let doc: Document = documents::table
let doc: Document = documents::table.find(document_id).first(&mut conn)?; .find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if doc.deleted_at.is_some() { if doc.deleted_at.is_some() {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
@@ -989,9 +1035,12 @@ pub async fn download_with_token(
.verify_download_token(&token) .verify_download_token(&token)
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
let mut conn = state.db()?; let mut conn = state.db_for_tenant(claims.tenant_id)?;
let doc: Document = documents::table.find(claims.doc_id).first(&mut conn)?; let doc: Document = documents::table
.find(claims.doc_id)
.filter(documents::tenant_id.eq(claims.tenant_id))
.first(&mut conn)?;
if doc.deleted_at.is_some() { if doc.deleted_at.is_some() {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
@@ -1004,6 +1053,7 @@ pub async fn download_with_token(
let has_active_refresh: bool = select(exists( let has_active_refresh: bool = select(exists(
refresh_dsl::refresh_tokens refresh_dsl::refresh_tokens
.filter(refresh_dsl::user_id.eq(claims.user_id)) .filter(refresh_dsl::user_id.eq(claims.user_id))
.filter(refresh_dsl::tenant_id.eq(claims.tenant_id))
.filter(refresh_dsl::revoked_at.is_null()) .filter(refresh_dsl::revoked_at.is_null())
.filter(refresh_dsl::expires_at.gt(now)), .filter(refresh_dsl::expires_at.gt(now)),
)) ))
@@ -1028,12 +1078,19 @@ pub async fn download_with_token(
} }
pub async fn delete_document( pub async fn delete_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document_id)) diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(( .set((
documents::deleted_at.eq(Some(now)), documents::deleted_at.eq(Some(now)),
documents::updated_at.eq(now), documents::updated_at.eq(now),
@@ -1045,12 +1102,18 @@ pub async fn delete_document(
pub async fn update_document( pub async fn update_document(
State(state): State<AppState>, State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
user: AuthenticatedUser, TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateDocumentRequest>, Json(payload): Json<UpdateDocumentRequest>,
) -> AppResult<Json<DocumentDetailResponse>> { ) -> AppResult<Json<DocumentDetailResponse>> {
let mut conn = state.db()?; let mut document: Document = documents::table
.find(document_id)
let mut document: Document = documents::table.find(document_id).first(&mut conn)?; .filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if document.deleted_at.is_some() { if document.deleted_at.is_some() {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
@@ -1074,7 +1137,11 @@ pub async fn update_document(
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let new_filename = filename_with_retained_extension(&title, &document.filename); let new_filename = filename_with_retained_extension(&title, &document.filename);
let update_result = diesel::update(documents::table.find(document_id)).set(( let target = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id));
let update_result = diesel::update(target).set((
documents::title.eq(&title), documents::title.eq(&title),
documents::filename.eq(&new_filename), documents::filename.eq(&new_filename),
documents::updated_at.eq(now), documents::updated_at.eq(now),
@@ -1090,7 +1157,10 @@ pub async fn update_document(
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
} }
document = documents::table.find(document_id).first(&mut conn)?; document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
} }
let current_version: DocumentVersion = document_versions::table let current_version: DocumentVersion = document_versions::table
@@ -1102,13 +1172,13 @@ pub async fn update_document(
let version_id = current_version.id; let version_id = current_version.id;
drop(conn); drop(conn);
let assets = load_asset_responses(&state, version_id).await?; let assets = load_asset_responses(&state, tenant_id, version_id).await?;
let version_response = to_version_response(current_version, true); let version_response = to_version_response(current_version, true);
Ok(Json(DocumentDetailResponse { Ok(Json(DocumentDetailResponse {
document: to_document_response( document: to_document_response(
&state, &state,
user.user_id, user_id,
document, document,
tags_map.get(&document_id).cloned(), tags_map.get(&document_id).cloned(),
correspondents_map.remove(&document_id).unwrap_or_default(), correspondents_map.remove(&document_id).unwrap_or_default(),
@@ -1120,15 +1190,23 @@ pub async fn update_document(
pub async fn move_document( pub async fn move_document(
State(state): State<AppState>, State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<MoveDocumentRequest>, Json(payload): Json<MoveDocumentRequest>,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
if let Some(folder_id) = payload.folder_id { if let Some(folder_id) = payload.folder_id {
ensure_folder_exists(&state, folder_id)?; ensure_folder_exists(&state, tenant_id, folder_id)?;
} }
let mut conn = state.db()?;
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document_id)) diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(( .set((
documents::folder_id.eq(payload.folder_id), documents::folder_id.eq(payload.folder_id),
documents::updated_at.eq(now), documents::updated_at.eq(now),
@@ -1140,6 +1218,11 @@ pub async fn move_document(
pub async fn bulk_move_documents( pub async fn bulk_move_documents(
State(state): State<AppState>, State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkMoveRequest>, Json(payload): Json<BulkMoveRequest>,
) -> AppResult<(StatusCode, Json<BulkMoveResponse>)> { ) -> AppResult<(StatusCode, Json<BulkMoveResponse>)> {
let BulkMoveRequest { let BulkMoveRequest {
@@ -1155,13 +1238,12 @@ pub async fn bulk_move_documents(
document_ids.dedup(); document_ids.dedup();
if let Some(target_folder) = folder_id { if let Some(target_folder) = folder_id {
ensure_folder_exists(&state, target_folder)?; ensure_folder_exists(&state, tenant_id, target_folder)?;
} }
let mut conn = state.db()?;
let existing: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table let existing: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids)) .filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::deleted_at)) .select((documents::id, documents::deleted_at))
.load(&mut conn)?; .load(&mut conn)?;
@@ -1176,7 +1258,11 @@ pub async fn bulk_move_documents(
} }
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let updated = diesel::update(documents::table.filter(documents::id.eq_any(&document_ids))) let updated = diesel::update(
documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(( .set((
documents::folder_id.eq(folder_id), documents::folder_id.eq(folder_id),
documents::updated_at.eq(now), documents::updated_at.eq(now),
@@ -1187,9 +1273,13 @@ pub async fn bulk_move_documents(
} }
pub async fn assign_correspondents( pub async fn assign_correspondents(
State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
user: AuthenticatedUser, TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<AssignCorrespondentsRequest>, Json(payload): Json<AssignCorrespondentsRequest>,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
if payload.assignments.is_empty() { if payload.assignments.is_empty() {
@@ -1199,11 +1289,13 @@ pub async fn assign_correspondents(
let (normalized_pairs, correspondents_vec, roles_vec) = let (normalized_pairs, correspondents_vec, roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?; normalize_correspondent_assignments(&payload.assignments)?;
let replace = payload.replace; let replace = payload.replace;
let user_id = user.user_id; let user_id_val = user_id;
let mut conn = state.db()?;
conn.transaction::<(), AppError, _>(|conn| { conn.transaction::<(), AppError, _>(|conn| {
let document: Document = documents::table.find(document_id).first(conn)?; let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(conn)?;
if document.deleted_at.is_some() { if document.deleted_at.is_some() {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
@@ -1211,6 +1303,7 @@ pub async fn assign_correspondents(
if !correspondents_vec.is_empty() { if !correspondents_vec.is_empty() {
let existing: Vec<Correspondent> = correspondents::table let existing: Vec<Correspondent> = correspondents::table
.filter(correspondents::id.eq_any(&correspondents_vec)) .filter(correspondents::id.eq_any(&correspondents_vec))
.filter(correspondents::tenant_id.eq(tenant_id))
.load(conn)?; .load(conn)?;
if existing.len() != correspondents_vec.len() { if existing.len() != correspondents_vec.len() {
return Err(AppError::bad_request( return Err(AppError::bad_request(
@@ -1224,6 +1317,7 @@ pub async fn assign_correspondents(
let deleted = diesel::delete( let deleted = diesel::delete(
document_correspondents::table document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id)) .filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::role.eq_any(&roles_vec)), .filter(document_correspondents::role.eq_any(&roles_vec)),
) )
.execute(conn)?; .execute(conn)?;
@@ -1238,7 +1332,8 @@ pub async fn assign_correspondents(
document_id, document_id,
correspondent_id: *correspondent_id, correspondent_id: *correspondent_id,
role: role.clone(), role: role.clone(),
assigned_by: Some(user_id), assigned_by: Some(user_id_val),
tenant_id,
}) })
.collect(); .collect();
@@ -1253,7 +1348,11 @@ pub async fn assign_correspondents(
} }
if changed { if changed {
diesel::update(documents::table.find(document_id)) diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc())) .set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?; .execute(conn)?;
} }
@@ -1265,8 +1364,12 @@ pub async fn assign_correspondents(
} }
pub async fn bulk_assign_correspondents( pub async fn bulk_assign_correspondents(
State(state): State<AppState>, TenantScopedConn {
user: AuthenticatedUser, mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkCorrespondentsRequest>, Json(payload): Json<BulkCorrespondentsRequest>,
) -> AppResult<(StatusCode, Json<BulkCorrespondentResponse>)> { ) -> AppResult<(StatusCode, Json<BulkCorrespondentResponse>)> {
if payload.document_ids.is_empty() { if payload.document_ids.is_empty() {
@@ -1283,12 +1386,11 @@ pub async fn bulk_assign_correspondents(
let (normalized_pairs, correspondents_vec, roles_vec) = let (normalized_pairs, correspondents_vec, roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?; normalize_correspondent_assignments(&payload.assignments)?;
let action = payload.action; let action = payload.action;
let user_id = user.user_id; let user_id_val = user_id;
let mut conn = state.db()?;
let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| { let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| {
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids)) .filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::deleted_at)) .select((documents::id, documents::deleted_at))
.load(conn)?; .load(conn)?;
@@ -1307,6 +1409,7 @@ pub async fn bulk_assign_correspondents(
if !correspondents_vec.is_empty() { if !correspondents_vec.is_empty() {
let existing: Vec<Correspondent> = correspondents::table let existing: Vec<Correspondent> = correspondents::table
.filter(correspondents::id.eq_any(&correspondents_vec)) .filter(correspondents::id.eq_any(&correspondents_vec))
.filter(correspondents::tenant_id.eq(tenant_id))
.load(conn)?; .load(conn)?;
if existing.len() != correspondents_vec.len() { if existing.len() != correspondents_vec.len() {
return Err(AppError::bad_request( return Err(AppError::bad_request(
@@ -1322,6 +1425,7 @@ pub async fn bulk_assign_correspondents(
removed = diesel::delete( removed = diesel::delete(
document_correspondents::table document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids)) .filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::role.eq_any(&roles_vec)), .filter(document_correspondents::role.eq_any(&roles_vec)),
) )
.execute(conn)?; .execute(conn)?;
@@ -1334,7 +1438,8 @@ pub async fn bulk_assign_correspondents(
document_id: *doc_id, document_id: *doc_id,
correspondent_id: *correspondent_id, correspondent_id: *correspondent_id,
role: role.clone(), role: role.clone(),
assigned_by: Some(user_id), assigned_by: Some(user_id_val),
tenant_id,
}); });
} }
} }
@@ -1349,7 +1454,11 @@ pub async fn bulk_assign_correspondents(
}; };
if assigned > 0 || removed > 0 { if assigned > 0 || removed > 0 {
diesel::update(documents::table.filter(documents::id.eq_any(&document_ids))) diesel::update(
documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc())) .set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?; .execute(conn)?;
} }
@@ -1371,6 +1480,7 @@ pub async fn bulk_assign_correspondents(
removed += diesel::delete( removed += diesel::delete(
document_correspondents::table document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids)) .filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::role.eq(role.as_str())) .filter(document_correspondents::role.eq(role.as_str()))
.filter(document_correspondents::correspondent_id.eq_any(&ids)), .filter(document_correspondents::correspondent_id.eq_any(&ids)),
) )
@@ -1379,7 +1489,11 @@ pub async fn bulk_assign_correspondents(
} }
if removed > 0 { if removed > 0 {
diesel::update(documents::table.filter(documents::id.eq_any(&document_ids))) diesel::update(
documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc())) .set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?; .execute(conn)?;
} }
@@ -1396,9 +1510,13 @@ pub async fn bulk_assign_correspondents(
} }
pub async fn remove_correspondent( pub async fn remove_correspondent(
State(state): State<AppState>,
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>, Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
Query(query): Query<CorrespondentRoleQuery>, Query(query): Query<CorrespondentRoleQuery>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
let role = normalize_role(&query.role); let role = normalize_role(&query.role);
if role.is_empty() { if role.is_empty() {
@@ -1411,8 +1529,10 @@ pub async fn remove_correspondent(
))); )));
} }
let mut conn = state.db()?; let document: Document = documents::table
let document: Document = documents::table.find(document_id).first(&mut conn)?; .find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if document.deleted_at.is_some() { if document.deleted_at.is_some() {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
@@ -1420,6 +1540,7 @@ pub async fn remove_correspondent(
let deleted = diesel::delete( let deleted = diesel::delete(
document_correspondents::table document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id)) .filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::role.eq(&role)), .filter(document_correspondents::role.eq(&role)),
) )
@@ -1429,7 +1550,11 @@ pub async fn remove_correspondent(
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
diesel::update(documents::table.find(document_id)) diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc())) .set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(&mut conn)?; .execute(&mut conn)?;
@@ -1437,25 +1562,29 @@ pub async fn remove_correspondent(
} }
pub async fn assign_tags( pub async fn assign_tags(
State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
user: AuthenticatedUser, TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<AssignTagsRequest>, Json(payload): Json<AssignTagsRequest>,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
if payload.tag_ids.is_empty() { if payload.tag_ids.is_empty() {
return Err(AppError::bad_request("tag_ids must not be empty")); return Err(AppError::bad_request("tag_ids must not be empty"));
} }
let mut conn = state.db()?;
// Ensure document exists // Ensure document exists
documents::table documents::table
.find(document_id) .find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first::<Document>(&mut conn)?; .first::<Document>(&mut conn)?;
// Ensure tags exist // Ensure tags exist
let existing_tags: Vec<Tag> = tags::table let existing_tags: Vec<Tag> = tags::table
.filter(tags::id.eq_any(&payload.tag_ids)) .filter(tags::id.eq_any(&payload.tag_ids))
.filter(tags::tenant_id.eq(tenant_id))
.load(&mut conn)?; .load(&mut conn)?;
if existing_tags.len() != payload.tag_ids.len() { if existing_tags.len() != payload.tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist")); return Err(AppError::bad_request("one or more tags do not exist"));
@@ -1467,7 +1596,8 @@ pub async fn assign_tags(
.map(|tag_id| NewDocumentTag { .map(|tag_id| NewDocumentTag {
document_id, document_id,
tag_id: *tag_id, tag_id: *tag_id,
assigned_by: Some(user.user_id), assigned_by: Some(user_id),
tenant_id,
}) })
.collect(); .collect();
@@ -1480,8 +1610,12 @@ pub async fn assign_tags(
} }
pub async fn bulk_update_tags( pub async fn bulk_update_tags(
State(state): State<AppState>, TenantScopedConn {
user: AuthenticatedUser, mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkTagRequest>, Json(payload): Json<BulkTagRequest>,
) -> AppResult<(StatusCode, Json<BulkTagResponse>)> { ) -> AppResult<(StatusCode, Json<BulkTagResponse>)> {
let BulkTagRequest { let BulkTagRequest {
@@ -1502,10 +1636,9 @@ pub async fn bulk_update_tags(
tag_ids.sort(); tag_ids.sort();
tag_ids.dedup(); tag_ids.dedup();
let mut conn = state.db()?;
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids)) .filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::deleted_at)) .select((documents::id, documents::deleted_at))
.load(&mut conn)?; .load(&mut conn)?;
@@ -1523,6 +1656,7 @@ pub async fn bulk_update_tags(
let existing_tags: Vec<Tag> = tags::table let existing_tags: Vec<Tag> = tags::table
.filter(tags::id.eq_any(&tag_ids)) .filter(tags::id.eq_any(&tag_ids))
.filter(tags::tenant_id.eq(tenant_id))
.load(&mut conn)?; .load(&mut conn)?;
if existing_tags.len() != tag_ids.len() { if existing_tags.len() != tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist")); return Err(AppError::bad_request("one or more tags do not exist"));
@@ -1536,7 +1670,8 @@ pub async fn bulk_update_tags(
inserts.push(NewDocumentTag { inserts.push(NewDocumentTag {
document_id: *doc_id, document_id: *doc_id,
tag_id: *tag_id, tag_id: *tag_id,
assigned_by: Some(user.user_id), assigned_by: Some(user_id),
tenant_id,
}); });
} }
} }
@@ -1556,6 +1691,7 @@ pub async fn bulk_update_tags(
let removed = diesel::delete( let removed = diesel::delete(
document_tags::table document_tags::table
.filter(document_tags::document_id.eq_any(&document_ids)) .filter(document_tags::document_id.eq_any(&document_ids))
.filter(document_tags::tenant_id.eq(tenant_id))
.filter(document_tags::tag_id.eq_any(&tag_ids)), .filter(document_tags::tag_id.eq_any(&tag_ids)),
) )
.execute(&mut conn)?; .execute(&mut conn)?;
@@ -1568,13 +1704,17 @@ pub async fn bulk_update_tags(
} }
pub async fn remove_tag( pub async fn remove_tag(
State(state): State<AppState>,
Path((document_id, tag_id)): Path<(Uuid, Uuid)>, Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
diesel::delete( diesel::delete(
document_tags::table document_tags::table
.filter(document_tags::document_id.eq(document_id)) .filter(document_tags::document_id.eq(document_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.filter(document_tags::tag_id.eq(tag_id)), .filter(document_tags::tag_id.eq(tag_id)),
) )
.execute(&mut conn)?; .execute(&mut conn)?;
@@ -1585,6 +1725,7 @@ pub async fn remove_tag(
async fn process_upload( async fn process_upload(
state: &AppState, state: &AppState,
request: UploadRequest, request: UploadRequest,
tenant_id: Uuid,
user_id: Uuid, user_id: Uuid,
) -> AppResult<UploadOutcome> { ) -> AppResult<UploadOutcome> {
let UploadRequest { let UploadRequest {
@@ -1596,7 +1737,7 @@ async fn process_upload(
} = request; } = request;
if let Some(folder) = folder_id { if let Some(folder) = folder_id {
ensure_folder_exists(state, folder)?; ensure_folder_exists(state, tenant_id, folder)?;
} }
let doc_id = Uuid::new_v4(); let doc_id = Uuid::new_v4();
@@ -1610,13 +1751,14 @@ async fn process_upload(
let s3_key = document_version_object_key(doc_id, version_number, version_id); let s3_key = document_version_object_key(doc_id, version_number, version_id);
{ {
let mut conn = state.db()?; let mut conn = state.db_for_tenant(tenant_id)?;
let existing = documents::table let existing = documents::table
.inner_join( .inner_join(
document_versions::table document_versions::table
.on(document_versions::id.eq(documents::current_version_id)), .on(document_versions::id.eq(documents::current_version_id)),
) )
.filter(documents::tenant_id.eq(tenant_id))
.filter(document_versions::checksum.eq(&checksum_hex)) .filter(document_versions::checksum.eq(&checksum_hex))
.select((documents::all_columns, document_versions::all_columns)) .select((documents::all_columns, document_versions::all_columns))
.first::<(Document, DocumentVersion)>(&mut conn) .first::<(Document, DocumentVersion)>(&mut conn)
@@ -1641,7 +1783,7 @@ async fn process_upload(
let tags = tags_map.get(&document.id).cloned(); let tags = tags_map.get(&document.id).cloned();
let correspondents = correspondents_map.remove(&document.id).unwrap_or_default(); let correspondents = correspondents_map.remove(&document.id).unwrap_or_default();
drop(conn); drop(conn);
let assets = load_asset_responses(state, version.id).await?; let assets = load_asset_responses(state, tenant_id, version.id).await?;
let version_response = to_version_response(version.clone(), true); let version_response = to_version_response(version.clone(), true);
info!( info!(
@@ -1689,7 +1831,7 @@ async fn process_upload(
}; };
let (document, version) = { let (document, version) = {
let mut conn = state.db()?; let mut conn = state.db_for_tenant(tenant_id)?;
conn.transaction(|conn| { conn.transaction(|conn| {
let new_document = NewDocument { let new_document = NewDocument {
id: doc_id, id: doc_id,
@@ -1701,6 +1843,7 @@ async fn process_upload(
issued_at: None, issued_at: None,
title: derive_document_title(&original_name), title: derive_document_title(&original_name),
metadata: metadata_value.clone(), metadata: metadata_value.clone(),
tenant_id,
}; };
diesel::insert_into(documents::table) diesel::insert_into(documents::table)
.values(&new_document) .values(&new_document)
@@ -1715,6 +1858,7 @@ async fn process_upload(
checksum: checksum_hex.clone(), checksum: checksum_hex.clone(),
metadata: Value::Object(Default::default()), metadata: Value::Object(Default::default()),
operations_summary: Value::Object(Default::default()), operations_summary: Value::Object(Default::default()),
tenant_id,
}; };
diesel::insert_into(document_versions::table) diesel::insert_into(document_versions::table)
@@ -1739,9 +1883,10 @@ async fn process_upload(
)?, )?,
}; };
if let Ok(mut conn) = state.db() { if let Ok(mut conn) = state.db_for_tenant(tenant_id) {
if let Err(err) = enqueue_job( if let Err(err) = enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_ANALYZE_DOCUMENT, JOB_ANALYZE_DOCUMENT,
json!({ json!({
"document_id": doc_id, "document_id": doc_id,
@@ -1762,9 +1907,13 @@ async fn process_upload(
}) })
} }
fn ensure_folder_exists(state: &AppState, folder_id: Uuid) -> AppResult<()> { fn ensure_folder_exists(state: &AppState, tenant_id: Uuid, folder_id: Uuid) -> AppResult<()> {
let mut conn = state.db()?; let mut conn = state.db_for_tenant(tenant_id)?;
let exists: bool = diesel::select(exists(folders::table.filter(folders::id.eq(folder_id)))) let exists: bool = diesel::select(exists(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
))
.get_result(&mut conn)?; .get_result(&mut conn)?;
if !exists { if !exists {
return Err(AppError::bad_request("folder does not exist")); return Err(AppError::bad_request("folder does not exist"));
@@ -1829,6 +1978,7 @@ pub(crate) fn load_correspondents_for_documents(
pub(crate) async fn load_primary_assets( pub(crate) async fn load_primary_assets(
state: &AppState, state: &AppState,
tenant_id: Uuid,
documents: &[Document], documents: &[Document],
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> { ) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
if documents.is_empty() { if documents.is_empty() {
@@ -1845,7 +1995,7 @@ pub(crate) async fn load_primary_assets(
version_ids.sort(); version_ids.sort();
version_ids.dedup(); version_ids.dedup();
let mut conn = state.db()?; let mut conn = state.db_for_tenant(tenant_id)?;
let versions: Vec<DocumentVersion> = document_versions::table let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::id.eq_any(&version_ids)) .filter(document_versions::id.eq_any(&version_ids))
.load(&mut conn)?; .load(&mut conn)?;
@@ -1905,7 +2055,7 @@ pub(crate) fn to_document_response(
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>, current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
) -> AppResult<DocumentResponse> { ) -> AppResult<DocumentResponse> {
let current_version = if let Some((version, assets)) = current_version { let current_version = if let Some((version, assets)) = current_version {
let download_path = build_download_path(state, doc.id, user_id)?; let download_path = build_download_path(state, &doc, user_id)?;
Some(DocumentCurrentVersionResponse { Some(DocumentCurrentVersionResponse {
version, version,
assets, assets,
@@ -1937,10 +2087,10 @@ pub(crate) fn to_document_response(
}) })
} }
fn build_download_path(state: &AppState, document_id: Uuid, user_id: Uuid) -> AppResult<String> { fn build_download_path(state: &AppState, document: &Document, user_id: Uuid) -> AppResult<String> {
state state
.jwt .jwt
.generate_download_token(document_id, user_id) .generate_download_token(document.id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}")) .map(|token| format!("/download/{token}"))
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}"))) .map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
} }
@@ -2042,9 +2192,10 @@ fn filename_with_retained_extension(title: &str, current_filename: &str) -> Stri
async fn load_asset_responses( async fn load_asset_responses(
state: &AppState, state: &AppState,
tenant_id: Uuid,
version_id: Uuid, version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> { ) -> AppResult<Vec<DocumentAssetResponse>> {
let mut conn = state.db()?; let mut conn = state.db_for_tenant(tenant_id)?;
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
.left_outer_join( .left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id document_asset_objects::table.on(document_asset_objects::asset_id
@@ -2052,6 +2203,7 @@ async fn load_asset_responses(
.and(document_asset_objects::ordinal.eq(1))), .and(document_asset_objects::ordinal.eq(1))),
) )
.filter(document_assets::document_version_id.eq(version_id)) .filter(document_assets::document_version_id.eq(version_id))
.filter(document_assets::tenant_id.eq(tenant_id))
.order(document_assets::created_at.asc()) .order(document_assets::created_at.asc())
.select(( .select((
document_assets::all_columns, document_assets::all_columns,
+72 -26
View File
@@ -10,7 +10,7 @@ use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders}; use crate::schema::{documents, folders};
use crate::state::AppState; use crate::state::AppState;
use crate::{ use crate::{
auth::AuthenticatedUser, auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
}; };
@@ -70,15 +70,17 @@ pub struct FolderInfo {
} }
pub async fn ensure_folder_path( pub async fn ensure_folder_path(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<EnsureFolderPathRequest>, Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<Json<FolderResponse>> {
if payload.segments.is_empty() { if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be empty")); return Err(AppError::bad_request("segments must not be empty"));
} }
let mut conn = state.db()?;
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| { let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id; let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None; let mut last_folder: Option<Folder> = None;
@@ -93,12 +95,14 @@ pub async fn ensure_folder_path(
folders::table folders::table
.filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name)) .filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn) .first(conn)
.optional()? .optional()?
} else { } else {
folders::table folders::table
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::name.eq(name)) .filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn) .first(conn)
.optional()? .optional()?
}; };
@@ -110,6 +114,7 @@ pub async fn ensure_folder_path(
id: Uuid::new_v4(), id: Uuid::new_v4(),
name: name.to_string(), name: name.to_string(),
parent_id: current_parent, parent_id: current_parent,
tenant_id,
}; };
diesel::insert_into(folders::table) diesel::insert_into(folders::table)
@@ -132,19 +137,22 @@ pub async fn ensure_folder_path(
} }
pub async fn create_folder( pub async fn create_folder(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>, Json(payload): Json<CreateFolderRequest>,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<Json<FolderResponse>> {
if payload.name.trim().is_empty() { if payload.name.trim().is_empty() {
return Err(AppError::bad_request("name must not be empty")); return Err(AppError::bad_request("name must not be empty"));
} }
let mut conn = state.db()?;
let new_folder = NewFolder { let new_folder = NewFolder {
id: Uuid::new_v4(), id: Uuid::new_v4(),
name: payload.name.trim().to_string(), name: payload.name.trim().to_string(),
parent_id: payload.parent_id, parent_id: payload.parent_id,
tenant_id,
}; };
diesel::insert_into(folders::table) diesel::insert_into(folders::table)
@@ -161,10 +169,13 @@ pub async fn list_folder_contents(
State(state): State<AppState>, State(state): State<AppState>,
Path(folder_identifier): Path<String>, Path(folder_identifier): Path<String>,
Query(query): Query<FolderContentsQuery>, Query(query): Query<FolderContentsQuery>,
user: AuthenticatedUser, TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<FolderContentsResponse>> { ) -> AppResult<Json<FolderContentsResponse>> {
let mut conn = state.db()?;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None None
} else { } else {
@@ -176,7 +187,10 @@ pub async fn list_folder_contents(
let folder = match folder_id { let folder = match folder_id {
Some(id) => Some(folder_to_info( Some(id) => Some(folder_to_info(
folders::table.find(id).first::<Folder>(&mut conn)?, folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(&mut conn)?,
)), )),
None => None, None => None,
}; };
@@ -184,11 +198,13 @@ pub async fn list_folder_contents(
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id { let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
folders::table folders::table
.filter(folders::parent_id.eq(parent_id)) .filter(folders::parent_id.eq(parent_id))
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc()) .order(folders::name.asc())
.load(&mut conn)? .load(&mut conn)?
} else { } else {
folders::table folders::table
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc()) .order(folders::name.asc())
.load(&mut conn)? .load(&mut conn)?
}; };
@@ -197,6 +213,7 @@ pub async fn list_folder_contents(
let documents = if query.include_documents { let documents = if query.include_documents {
let docs_query = documents::table let docs_query = documents::table
.filter(documents::deleted_at.is_null()) .filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.order(documents::uploaded_at.desc()); .order(documents::uploaded_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id { let docs: Vec<Document> = if let Some(current_folder) = folder_id {
@@ -214,7 +231,7 @@ pub async fn list_folder_contents(
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn); drop(conn);
let primary_versions = load_primary_assets(&state, &docs).await?; let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
let mut documents = Vec::with_capacity(doc_ids.len()); let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
@@ -223,7 +240,7 @@ pub async fn list_folder_contents(
let current_version = primary_versions.get(&doc.id).cloned(); let current_version = primary_versions.get(&doc.id).cloned();
documents.push(to_document_response( documents.push(to_document_response(
&state, &state,
user.user_id, user_id,
doc, doc,
tags, tags,
correspondents, correspondents,
@@ -244,16 +261,23 @@ pub async fn list_folder_contents(
} }
pub async fn delete_folder( pub async fn delete_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
let mut conn = state.db()?;
conn.transaction::<_, AppError, _>(|conn| { conn.transaction::<_, AppError, _>(|conn| {
folders::table.find(folder_id).first::<Folder>(conn)?; folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?;
let has_child_folders: bool = diesel::select(exists( let has_child_folders: bool = diesel::select(exists(
folders::table.filter(folders::parent_id.eq(Some(folder_id))), folders::table
.filter(folders::parent_id.eq(Some(folder_id)))
.filter(folders::tenant_id.eq(tenant_id)),
)) ))
.get_result(conn)?; .get_result(conn)?;
@@ -266,6 +290,7 @@ pub async fn delete_folder(
let has_documents: bool = diesel::select(exists( let has_documents: bool = diesel::select(exists(
documents::table documents::table
.filter(documents::folder_id.eq(Some(folder_id))) .filter(documents::folder_id.eq(Some(folder_id)))
.filter(documents::tenant_id.eq(tenant_id))
.filter(documents::deleted_at.is_null()), .filter(documents::deleted_at.is_null()),
)) ))
.get_result(conn)?; .get_result(conn)?;
@@ -276,7 +301,12 @@ pub async fn delete_folder(
)); ));
} }
diesel::delete(folders::table.find(folder_id)).execute(conn)?; diesel::delete(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
)
.execute(conn)?;
Ok(()) Ok(())
})?; })?;
@@ -285,14 +315,19 @@ pub async fn delete_folder(
} }
pub async fn update_folder( pub async fn update_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateFolderRequest>, Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
let mut conn = state.db()?;
conn.transaction::<(), AppError, _>(|conn| { conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table.find(folder_id).first(conn)?; let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let mut next_parent = folder.parent_id; let mut next_parent = folder.parent_id;
let mut parent_changed = false; let mut parent_changed = false;
@@ -303,9 +338,12 @@ pub async fn update_folder(
} }
if let Some(parent_id) = parent_request { if let Some(parent_id) = parent_request {
let _parent: Folder = folders::table.find(parent_id).first(conn)?; let _parent: Folder = folders::table
.find(parent_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?; let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
if descendant_ids.contains(&parent_id) { if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request( return Err(AppError::bad_request(
"cannot move folder into itself or a descendant", "cannot move folder into itself or a descendant",
@@ -341,6 +379,7 @@ pub async fn update_folder(
.filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&new_name)) .filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id)) .filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn) .first::<Folder>(conn)
.optional()? .optional()?
} else { } else {
@@ -348,6 +387,7 @@ pub async fn update_folder(
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::name.eq(&new_name)) .filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id)) .filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn) .first::<Folder>(conn)
.optional()? .optional()?
}; };
@@ -358,7 +398,11 @@ pub async fn update_folder(
)); ));
} }
diesel::update(folders::table.find(folder_id)) diesel::update(
folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id)),
)
.set(( .set((
folders::parent_id.eq(next_parent), folders::parent_id.eq(next_parent),
folders::name.eq(&new_name), folders::name.eq(&new_name),
@@ -383,6 +427,7 @@ fn folder_to_info(folder: Folder) -> FolderInfo {
pub(super) fn gather_descendant_folder_ids( pub(super) fn gather_descendant_folder_ids(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid, folder_id: Uuid,
) -> AppResult<Vec<Uuid>> { ) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id]; let mut ids = vec![folder_id];
@@ -391,6 +436,7 @@ pub(super) fn gather_descendant_folder_ids(
while let Some(current) = queue.pop() { while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = folders::table let child_ids: Vec<Uuid> = folders::table
.filter(folders::parent_id.eq(Some(current))) .filter(folders::parent_id.eq(Some(current)))
.filter(folders::tenant_id.eq(tenant_id))
.select(folders::id) .select(folders::id)
.load(conn)?; .load(conn)?;
queue.extend(child_ids.iter().copied()); queue.extend(child_ids.iter().copied());
+1
View File
@@ -50,6 +50,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.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))
.route("/select-tenant", post(auth::select_tenant))
.route("/me", get(auth::me)); .route("/me", get(auth::me));
let documents_routes = Router::new() let documents_routes = Router::new()
+53 -21
View File
@@ -1,19 +1,15 @@
use crate::utils::json::{classify_nullable, NullableValue}; use crate::utils::json::{classify_nullable, NullableValue};
use axum::{ use axum::{extract::Path, http::StatusCode, Json};
extract::{Path, State},
http::StatusCode,
Json,
};
use diesel::{dsl::count_star, prelude::*}; use diesel::{dsl::count_star, prelude::*};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::TenantScopedConn;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::models::{NewTag, Tag}; use crate::models::{NewTag, Tag};
use crate::schema::{document_tags, tags}; use crate::schema::{document_tags, tags};
use crate::state::AppState;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateTagRequest { pub struct CreateTagRequest {
@@ -36,12 +32,17 @@ pub struct TagCatalogEntry {
pub usage_count: i64, pub usage_count: i64,
} }
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> { pub async fn list_tags(
let mut conn = state.db()?; TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<TagCatalogEntry>>> {
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?; let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
let usage_rows: Vec<(Uuid, i64)> = document_tags::table let usage_rows: Vec<(Uuid, i64)> = document_tags::table
.filter(document_tags::tenant_id.eq(tenant_id))
.group_by(document_tags::tag_id) .group_by(document_tags::tag_id)
.select((document_tags::tag_id, count_star())) .select((document_tags::tag_id, count_star()))
.load(&mut conn)?; .load(&mut conn)?;
@@ -62,18 +63,22 @@ pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagC
} }
pub async fn create_tag( pub async fn create_tag(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateTagRequest>, Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<Json<TagCatalogEntry>> {
if payload.label.trim().is_empty() { if payload.label.trim().is_empty() {
return Err(AppError::bad_request("label must not be empty")); return Err(AppError::bad_request("label must not be empty"));
} }
let mut conn = state.db()?;
let new_tag = NewTag { let new_tag = NewTag {
id: Uuid::new_v4(), id: Uuid::new_v4(),
label: payload.label.trim().to_string(), label: payload.label.trim().to_string(),
color: payload.color, color: payload.color,
tenant_id,
}; };
match diesel::insert_into(tags::table) match diesel::insert_into(tags::table)
@@ -90,7 +95,10 @@ pub async fn create_tag(
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
} }
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?; let tag: Tag = tags::table
.find(new_tag.id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)?;
Ok(Json(TagCatalogEntry { Ok(Json(TagCatalogEntry {
id: tag.id, id: tag.id,
label: tag.label, label: tag.label,
@@ -100,12 +108,18 @@ pub async fn create_tag(
} }
pub async fn update_tag( pub async fn update_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<Json<TagCatalogEntry>> {
let mut conn = state.db()?; let existing: Tag = tags::table
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?; .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)?;
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?; let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?; let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
@@ -140,6 +154,7 @@ pub async fn update_tag(
let duplicate = tags::table let duplicate = tags::table
.filter(tags::label.eq(trimmed)) .filter(tags::label.eq(trimmed))
.filter(tags::id.ne(tag_id)) .filter(tags::id.ne(tag_id))
.filter(tags::tenant_id.eq(tenant_id))
.first::<Tag>(&mut conn) .first::<Tag>(&mut conn)
.optional()?; .optional()?;
if duplicate.is_some() { if duplicate.is_some() {
@@ -174,6 +189,7 @@ pub async fn update_tag(
if !label_changed && !color_changed { if !label_changed && !color_changed {
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return Ok(Json(TagCatalogEntry { return Ok(Json(TagCatalogEntry {
@@ -191,13 +207,21 @@ pub async fn update_tag(
.map(|opt| opt.as_ref().map(|value| value.as_str())), .map(|opt| opt.as_ref().map(|value| value.as_str())),
}; };
diesel::update(tags::table.find(tag_id)) diesel::update(
tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)),
)
.set(&changeset) .set(&changeset)
.execute(&mut conn)?; .execute(&mut conn)?;
let updated: Tag = tags::table.find(tag_id).first(&mut conn)?; let updated: Tag = tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)?;
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
@@ -210,13 +234,16 @@ pub async fn update_tag(
} }
pub async fn delete_tag( pub async fn delete_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl axum::response::IntoResponse> { ) -> AppResult<impl axum::response::IntoResponse> {
let mut conn = state.db()?;
let usage: i64 = document_tags::table let usage: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
@@ -226,7 +253,12 @@ pub async fn delete_tag(
)); ));
} }
let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?; let deleted = diesel::delete(
tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 { if deleted == 0 {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
+3 -3
View File
@@ -221,7 +221,7 @@ fn fetch_folder_contents(
state: &AppState, state: &AppState,
folder_id: Option<Uuid>, folder_id: Option<Uuid>,
) -> AppResult<WebDavFolderContents> { ) -> AppResult<WebDavFolderContents> {
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let folder = match folder_id { let folder = match folder_id {
Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?), Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?),
@@ -405,7 +405,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
}; };
tracing::debug!(%username, "attempting webdav login"); tracing::debug!(%username, "attempting webdav login");
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let user: User = match users_dsl::users let user: User = match users_dsl::users
.filter(users_dsl::username.eq(username)) .filter(users_dsl::username.eq(username))
@@ -654,7 +654,7 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
return Ok(Some(ResolvedPath::Root)); return Ok(Some(ResolvedPath::Root));
} }
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let mut parent_id: Option<Uuid> = None; let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = Vec::new(); let mut chain: Vec<String> = Vec::new();
let mut current_folder: Option<Folder> = None; let mut current_folder: Option<Folder> = None;
+52 -2
View File
@@ -8,6 +8,7 @@ diesel::table! {
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -18,6 +19,7 @@ diesel::table! {
ordinal -> Int4, ordinal -> Int4,
s3_key -> Text, s3_key -> Text,
metadata -> Jsonb, metadata -> Jsonb,
tenant_id -> Uuid,
} }
} }
@@ -30,6 +32,7 @@ diesel::table! {
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
cardinality -> Nullable<Int4>, cardinality -> Nullable<Int4>,
tenant_id -> Uuid,
} }
} }
@@ -41,6 +44,7 @@ diesel::table! {
role -> Varchar, role -> Varchar,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid,
} }
} }
@@ -50,6 +54,7 @@ diesel::table! {
tag_id -> Uuid, tag_id -> Uuid,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid,
} }
} }
@@ -66,6 +71,7 @@ diesel::table! {
created_at -> Timestamptz, created_at -> Timestamptz,
operations_summary -> Jsonb, operations_summary -> Jsonb,
metadata -> Jsonb, metadata -> Jsonb,
tenant_id -> Uuid,
} }
} }
@@ -87,6 +93,7 @@ diesel::table! {
#[max_length = 255] #[max_length = 255]
title -> Varchar, title -> Varchar,
current_version_id -> Uuid, current_version_id -> Uuid,
tenant_id -> Uuid,
} }
} }
@@ -98,6 +105,7 @@ diesel::table! {
parent_id -> Nullable<Uuid>, parent_id -> Nullable<Uuid>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -112,6 +120,7 @@ diesel::table! {
last_error -> Nullable<Text>, last_error -> Nullable<Text>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -125,6 +134,7 @@ diesel::table! {
revoked_at -> Nullable<Timestamptz>, revoked_at -> Nullable<Timestamptz>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -136,6 +146,31 @@ diesel::table! {
#[max_length = 7] #[max_length = 7]
color -> Nullable<Varchar>, color -> Nullable<Varchar>,
created_at -> Timestamptz, created_at -> Timestamptz,
tenant_id -> Uuid,
}
}
diesel::table! {
tenants (tenant_id) {
tenant_id -> Uuid,
slug -> Text,
storage_root -> Nullable<Text>,
quickwit_index -> Nullable<Text>,
status -> Text,
config -> Jsonb,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! {
user_memberships (id) {
id -> Uuid,
user_id -> Uuid,
tenant_id -> Uuid,
role -> Text,
created_at -> Timestamptz,
updated_at -> Timestamptz,
} }
} }
@@ -146,23 +181,36 @@ diesel::table! {
username -> Varchar, username -> Varchar,
#[max_length = 255] #[max_length = 255]
password_hash -> Varchar, password_hash -> Varchar,
#[max_length = 16]
role -> Varchar,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
diesel::joinable!(correspondents -> tenants (tenant_id));
diesel::joinable!(document_asset_objects -> document_assets (asset_id)); diesel::joinable!(document_asset_objects -> document_assets (asset_id));
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
diesel::joinable!(document_assets -> document_versions (document_version_id)); diesel::joinable!(document_assets -> document_versions (document_version_id));
diesel::joinable!(document_assets -> tenants (tenant_id));
diesel::joinable!(document_correspondents -> correspondents (correspondent_id)); diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
diesel::joinable!(document_correspondents -> documents (document_id)); diesel::joinable!(document_correspondents -> documents (document_id));
diesel::joinable!(document_correspondents -> tenants (tenant_id));
diesel::joinable!(document_correspondents -> users (assigned_by)); diesel::joinable!(document_correspondents -> users (assigned_by));
diesel::joinable!(document_tags -> documents (document_id)); diesel::joinable!(document_tags -> documents (document_id));
diesel::joinable!(document_tags -> tags (tag_id)); diesel::joinable!(document_tags -> tags (tag_id));
diesel::joinable!(document_tags -> tenants (tenant_id));
diesel::joinable!(document_tags -> users (assigned_by)); diesel::joinable!(document_tags -> users (assigned_by));
diesel::joinable!(document_versions -> tenants (tenant_id));
diesel::joinable!(documents -> folders (folder_id)); diesel::joinable!(documents -> folders (folder_id));
diesel::joinable!(documents -> tenants (tenant_id));
diesel::joinable!(folders -> tenants (tenant_id));
diesel::joinable!(jobs -> tenants (tenant_id));
diesel::joinable!(refresh_tokens -> tenants (tenant_id));
diesel::joinable!(refresh_tokens -> users (user_id)); diesel::joinable!(refresh_tokens -> users (user_id));
diesel::joinable!(tags -> tenants (tenant_id));
diesel::joinable!(user_memberships -> tenants (tenant_id));
diesel::joinable!(user_memberships -> users (user_id));
diesel::joinable!(users -> tenants (tenant_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
correspondents, correspondents,
@@ -176,5 +224,7 @@ diesel::allow_tables_to_appear_in_same_query!(
jobs, jobs,
refresh_tokens, refresh_tokens,
tags, tags,
tenants,
user_memberships,
users, users,
); );
+16 -3
View File
@@ -4,6 +4,7 @@ use diesel::{
pg::PgConnection, pg::PgConnection,
r2d2::{ConnectionManager, PooledConnection}, r2d2::{ConnectionManager, PooledConnection},
}; };
use uuid::Uuid;
use crate::{ use crate::{
auth::jwt::JwtService, auth::jwt::JwtService,
@@ -11,9 +12,10 @@ use crate::{
db::PgPool, db::PgPool,
error::{AppError, AppResult}, error::{AppError, AppResult},
storage::ObjectStorage, storage::ObjectStorage,
tenants::{apply_tenant_guc, TenantService},
}; };
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>; pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@@ -21,6 +23,7 @@ pub struct AppState {
pub config: Arc<AppConfig>, pub config: Arc<AppConfig>,
pub storage: Arc<dyn ObjectStorage>, pub storage: Arc<dyn ObjectStorage>,
pub jwt: JwtService, pub jwt: JwtService,
pub tenants: TenantService,
} }
impl AppState { impl AppState {
@@ -30,15 +33,25 @@ impl AppState {
storage: Arc<dyn ObjectStorage>, storage: Arc<dyn ObjectStorage>,
jwt: JwtService, jwt: JwtService,
) -> Self { ) -> Self {
let config = Arc::new(config);
let tenants = TenantService::new(pool.clone());
Self { Self {
pool, pool,
config: Arc::new(config), config,
storage, storage,
jwt, jwt,
tenants,
} }
} }
pub fn db(&self) -> AppResult<PgPooledConnection> { pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> {
let mut conn = self.db_unscoped()?;
apply_tenant_guc(&mut conn, tenant_id)?;
Ok(conn)
}
pub fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
self.pool self.pool
.get() .get()
.map_err(|err| AppError::internal(format!("database pool error: {err}"))) .map_err(|err| AppError::internal(format!("database pool error: {err}")))
+123
View File
@@ -0,0 +1,123 @@
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
use uuid::Uuid;
use crate::{
db::PgPool,
error::{AppError, AppResult},
models::Tenant,
schema::tenants::dsl,
state::AppState,
};
pub struct TenantRepository;
impl TenantRepository {
pub fn get_by_id(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<Tenant> {
dsl::tenants.find(tenant_id).first(conn).map_err(Into::into)
}
pub fn get_by_slug(conn: &mut PgConnection, slug: &str) -> AppResult<Tenant> {
dsl::tenants
.filter(dsl::slug.eq(slug))
.first(conn)
.map_err(Into::into)
}
}
#[derive(Clone)]
pub struct TenantService {
pool: PgPool,
cache_by_id: Arc<RwLock<HashMap<Uuid, Tenant>>>,
cache_by_slug: Arc<RwLock<HashMap<String, Tenant>>>,
}
impl TenantService {
pub fn new(pool: PgPool) -> Self {
Self {
pool,
cache_by_id: Arc::new(RwLock::new(HashMap::new())),
cache_by_slug: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn get_by_id(&self, tenant_id: Uuid) -> AppResult<Tenant> {
if let Some(tenant) = self.cache_by_id.read().unwrap().get(&tenant_id) {
return Ok(tenant.clone());
}
let tenant = self.load(|conn| TenantRepository::get_by_id(conn, tenant_id))?;
self.store(&tenant);
Ok(tenant)
}
pub fn get_by_slug(&self, slug: &str) -> AppResult<Tenant> {
if let Some(tenant) = self.cache_by_slug.read().unwrap().get(slug) {
return Ok(tenant.clone());
}
let slug_owned = slug.to_owned();
let tenant = self.load(|conn| TenantRepository::get_by_slug(conn, &slug_owned))?;
self.store(&tenant);
Ok(tenant)
}
pub fn tenant_id_for_slug(&self, slug: &str) -> AppResult<Uuid> {
Ok(self.get_by_slug(slug)?.tenant_id)
}
fn load<F>(&self, loader: F) -> AppResult<Tenant>
where
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
{
let mut conn = self
.pool
.get()
.map_err(|err| AppError::internal(format!("database pool error: {err}")))?;
let tenant = loader(&mut conn)?;
Ok(tenant)
}
fn store(&self, tenant: &Tenant) {
{
let mut by_id = self.cache_by_id.write().unwrap();
by_id.insert(tenant.tenant_id, tenant.clone());
}
{
let mut by_slug = self.cache_by_slug.write().unwrap();
by_slug.insert(tenant.slug.clone(), tenant.clone());
}
}
}
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, true)")
.bind::<Text, _>(tenant_id.to_string())
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
pub struct TenantContext {
pub tenant: Tenant,
}
#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
type Rejection = AppError;
async fn from_request_parts(
_parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let tenant = state
.tenants
.get_by_slug(&state.config.default_tenant_slug)?;
Ok(Self { tenant })
}
}
+16 -3
View File
@@ -51,7 +51,9 @@ impl JobHandler for AnalyzeDocumentJob {
}; };
let state_clone = state.clone(); let state_clone = state.clone();
match task::spawn_blocking(move || analyze_document(state_clone, payload)).await { let tenant_id = job.tenant_id;
match task::spawn_blocking(move || analyze_document(state_clone, tenant_id, payload)).await
{
Ok(Ok(execution)) => execution, Ok(Ok(execution)) => execution,
Ok(Err(err)) => { Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "analyze job will retry"); warn!(job_id = %job.id, error = %err, "analyze job will retry");
@@ -71,8 +73,14 @@ impl JobHandler for AnalyzeDocumentJob {
} }
} }
fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<JobExecution, String> { fn analyze_document(
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; state: Arc<AppState>,
tenant_id: Uuid,
payload: AnalyzePayload,
) -> Result<JobExecution, String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
@@ -88,12 +96,15 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
let (supported, reason) = determine_thumbnail_support(&document); let (supported, reason) = determine_thumbnail_support(&document);
let ocr_supported = document_is_pdf(&document); let ocr_supported = document_is_pdf(&document);
let existing_ocr: Option<DocumentAsset> = document_assets::table let existing_ocr: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_assets::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.optional() .optional()
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -129,6 +140,7 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
if supported { if supported {
let enqueue_result = enqueue_job( let enqueue_result = enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_GENERATE_THUMBNAILS, JOB_GENERATE_THUMBNAILS,
json!({ json!({
"document_id": payload.document_id, "document_id": payload.document_id,
@@ -146,6 +158,7 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
if ocr_supported && !skip_ocr { if ocr_supported && !skip_ocr {
let enqueue_result = enqueue_job( let enqueue_result = enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_GENERATE_OCR_TEXT, JOB_GENERATE_OCR_TEXT,
json!({ json!({
"document_id": payload.document_id, "document_id": payload.document_id,
+12 -3
View File
@@ -173,11 +173,11 @@ struct IndexContext {
} }
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> { fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
.first(&mut conn) .first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id { if version.document_id != payload.document_id {
@@ -186,7 +186,14 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
let document: Document = documents::table let document: Document = documents::table
.find(payload.document_id) .find(payload.document_id)
.first(&mut conn) .first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
drop(base_conn);
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let text_s3_key: Option<String> = document_asset_objects::table let text_s3_key: Option<String> = document_asset_objects::table
@@ -195,7 +202,9 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
) )
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_assets::tenant_id.eq(tenant_id))
.filter(document_asset_objects::ordinal.eq(1)) .filter(document_asset_objects::ordinal.eq(1))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.select(document_asset_objects::s3_key) .select(document_asset_objects::s3_key)
.first(&mut conn) .first(&mut conn)
.optional() .optional()
+5 -5
View File
@@ -71,7 +71,7 @@ impl Worker {
return Ok(false); return Ok(false);
} }
let mut conn = match self.state.db() { let mut conn = match self.state.db_unscoped() {
Ok(conn) => conn, Ok(conn) => conn,
Err(err) => { Err(err) => {
error!(?err, "failed to obtain database connection in worker"); error!(?err, "failed to obtain database connection in worker");
@@ -87,7 +87,7 @@ impl Worker {
let result = handler.handle(self.state.clone(), job.clone()).await; let result = handler.handle(self.state.clone(), job.clone()).await;
match result { match result {
JobExecution::Success => { JobExecution::Success => {
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
mark_job_succeeded(&mut conn, job.id)?; mark_job_succeeded(&mut conn, job.id)?;
info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully"); info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully");
} else { } else {
@@ -96,7 +96,7 @@ impl Worker {
} }
JobExecution::Retry { delay, error } => { JobExecution::Retry { delay, error } => {
warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry"); warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry");
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
retry_job_after(&mut conn, job.id, delay, &error)?; retry_job_after(&mut conn, job.id, delay, &error)?;
} else { } else {
error!("failed to requeue job for retry due to pool error"); error!("failed to requeue job for retry due to pool error");
@@ -104,7 +104,7 @@ impl Worker {
} }
JobExecution::Failed { error } => { JobExecution::Failed { error } => {
error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed"); error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed");
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
mark_job_failed(&mut conn, job.id, &error)?; mark_job_failed(&mut conn, job.id, &error)?;
} else { } else {
error!("failed to mark job failed due to pool error"); error!("failed to mark job failed due to pool error");
@@ -113,7 +113,7 @@ impl Worker {
} }
} else { } else {
error!(job_type = %job.job_type, "no handler registered for job type"); error!(job_type = %job.job_type, "no handler registered for job type");
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
mark_job_failed(&mut conn, job.id, "no handler registered")?; mark_job_failed(&mut conn, job.id, "no handler registered")?;
} else { } else {
error!("failed to mark job failed for missing handler due to pool error"); error!("failed to mark job failed for missing handler due to pool error");
+31 -5
View File
@@ -213,11 +213,11 @@ struct OcrGeneration {
} }
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> { fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
.first(&mut conn) .first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id { if version.document_id != payload.document_id {
@@ -226,12 +226,20 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
let document: Document = documents::table let document: Document = documents::table
.find(payload.document_id) .find(payload.document_id)
.first(&mut conn) .first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
drop(base_conn);
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing_asset: Option<DocumentAsset> = document_assets::table let existing_asset: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_assets::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.optional() .optional()
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -239,6 +247,7 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset { let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
document_asset_objects::table document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id)) .filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc()) .order(document_asset_objects::ordinal.asc())
.load(&mut conn) .load(&mut conn)
.map_err(|err| format!("{err:?}"))? .map_err(|err| format!("{err:?}"))?
@@ -392,7 +401,10 @@ fn persist_ocr_metadata(
s3_key: &str, s3_key: &str,
source: &'static str, source: &'static str,
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let tenant_id = context.document.tenant_id;
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
if let Some(existing_asset) = &context.existing_asset { if let Some(existing_asset) = &context.existing_asset {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id))) diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
@@ -410,6 +422,7 @@ fn persist_ocr_metadata(
"source": source, "source": source,
}), }),
cardinality: Some(1), cardinality: Some(1),
tenant_id,
}; };
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
@@ -430,6 +443,7 @@ fn persist_ocr_metadata(
let existing_object_id: Option<Uuid> = document_asset_objects::table let existing_object_id: Option<Uuid> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset_id)) .filter(document_asset_objects::asset_id.eq(asset_id))
.filter(document_asset_objects::ordinal.eq(1)) .filter(document_asset_objects::ordinal.eq(1))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.select(document_asset_objects::id) .select(document_asset_objects::id)
.first(&mut conn) .first(&mut conn)
.optional() .optional()
@@ -443,6 +457,7 @@ fn persist_ocr_metadata(
ordinal: 1, ordinal: 1,
s3_key: s3_key.to_string(), s3_key: s3_key.to_string(),
metadata: json!({}), metadata: json!({}),
tenant_id,
}; };
diesel::insert_into(document_asset_objects::table) diesel::insert_into(document_asset_objects::table)
@@ -463,9 +478,20 @@ fn persist_ocr_metadata(
} }
fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> { fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let tenant_id: Uuid = documents::table
.find(payload.document_id)
.select(documents::tenant_id)
.first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?;
drop(base_conn);
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
enqueue_job( enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_INDEX_DOCUMENT_TEXT, JOB_INDEX_DOCUMENT_TEXT,
json!({ json!({
"document_id": payload.document_id, "document_id": payload.document_id,
+23 -5
View File
@@ -343,7 +343,7 @@ fn load_thumbnail_context(
state: Arc<AppState>, state: Arc<AppState>,
payload: &ThumbnailPayload, payload: &ThumbnailPayload,
) -> Result<ThumbnailContext, String> { ) -> Result<ThumbnailContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
@@ -359,12 +359,15 @@ fn load_thumbnail_context(
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
let existing_assets: Vec<DocumentAsset> = document_assets::table let existing_assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq_any(vec![ .filter(document_assets::asset_type.eq_any(vec![
THUMBNAIL_ASSET_TYPE.to_string(), THUMBNAIL_ASSET_TYPE.to_string(),
PREVIEW_ASSET_TYPE.to_string(), PREVIEW_ASSET_TYPE.to_string(),
])) ]))
.filter(document_assets::tenant_id.eq(tenant_id))
.load(&mut conn) .load(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -377,6 +380,7 @@ fn load_thumbnail_context(
THUMBNAIL_ASSET_TYPE => { THUMBNAIL_ASSET_TYPE => {
existing_thumbnail_objects = document_asset_objects::table existing_thumbnail_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id)) .filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc()) .order(document_asset_objects::ordinal.asc())
.load(&mut conn) .load(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -385,6 +389,7 @@ fn load_thumbnail_context(
PREVIEW_ASSET_TYPE => { PREVIEW_ASSET_TYPE => {
existing_preview_objects = document_asset_objects::table existing_preview_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id)) .filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc()) .order(document_asset_objects::ordinal.asc())
.load(&mut conn) .load(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -568,7 +573,10 @@ fn persist_assets_metadata(
context: &ThumbnailContext, context: &ThumbnailContext,
assets: &[AssetPersistence], assets: &[AssetPersistence],
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let tenant_id = context.document.tenant_id;
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
if let Some(existing_preview) = &context.existing_preview { if let Some(existing_preview) = &context.existing_preview {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id))) diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id)))
@@ -607,6 +615,7 @@ fn persist_assets_metadata(
"generated_at": Utc::now().to_rfc3339(), "generated_at": Utc::now().to_rfc3339(),
}), }),
cardinality: Some(object_count), cardinality: Some(object_count),
tenant_id,
}; };
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
@@ -626,7 +635,8 @@ fn persist_assets_metadata(
diesel::delete( diesel::delete(
document_asset_objects::table document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.asset_id)), .filter(document_asset_objects::asset_id.eq(asset.asset_id))
.filter(document_asset_objects::tenant_id.eq(tenant_id)),
) )
.execute(&mut conn) .execute(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -648,6 +658,7 @@ fn persist_assets_metadata(
ordinal: object.ordinal, ordinal: object.ordinal,
s3_key: object.s3_key.clone(), s3_key: object.s3_key.clone(),
metadata: object_metadata, metadata: object_metadata,
tenant_id,
}; };
diesel::insert_into(document_asset_objects::table) diesel::insert_into(document_asset_objects::table)
@@ -666,11 +677,17 @@ fn persist_document_page_count(
document_version_id: Uuid, document_version_id: Uuid,
page_count: u32, page_count: u32,
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let tenant_id: Uuid = documents::table
.find(document_id)
.select(documents::tenant_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let existing_metadata: Value = document_versions::table let existing_metadata: Value = document_versions::table
.filter(document_versions::id.eq(document_version_id)) .filter(document_versions::id.eq(document_version_id))
.filter(document_versions::document_id.eq(document_id)) .filter(document_versions::document_id.eq(document_id))
.filter(document_versions::tenant_id.eq(tenant_id))
.select(document_versions::metadata) .select(document_versions::metadata)
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -690,7 +707,8 @@ fn persist_document_page_count(
diesel::update( diesel::update(
document_versions::table document_versions::table
.filter(document_versions::id.eq(document_version_id)) .filter(document_versions::id.eq(document_version_id))
.filter(document_versions::document_id.eq(document_id)), .filter(document_versions::document_id.eq(document_id))
.filter(document_versions::tenant_id.eq(tenant_id)),
) )
.set(document_versions::metadata.eq(updated)) .set(document_versions::metadata.eq(updated))
.execute(&mut conn) .execute(&mut conn)
-2
View File
@@ -8,7 +8,6 @@ use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthenticatedUser { struct AuthenticatedUser {
username: String, username: String,
role: String,
} }
#[tokio::test] #[tokio::test]
@@ -27,7 +26,6 @@ async fn login_and_me_roundtrip() -> Result<()> {
let user: AuthenticatedUser = serde_json::from_slice(&body)?; let user: AuthenticatedUser = serde_json::from_slice(&body)?;
assert_eq!(user.username, "alice"); assert_eq!(user.username, "alice");
assert_eq!(user.role, "admin");
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
+20 -2
View File
@@ -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}; use backend::models::{Job, NewUser, NewUserMembership};
use backend::routes; use backend::routes;
use backend::state::AppState; use backend::state::AppState;
use backend::storage::ObjectStorage; use backend::storage::ObjectStorage;
@@ -138,6 +138,7 @@ impl TestApp {
s3_bucket: "test-bucket".to_string(), s3_bucket: "test-bucket".to_string(),
quickwit_endpoint: None, quickwit_endpoint: None,
quickwit_index: None, quickwit_index: None,
default_tenant_slug: "admin".to_string(),
}; };
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
@@ -178,18 +179,35 @@ impl TestApp {
let username = username.to_string(); let username = username.to_string();
let password = password.to_string(); let password = password.to_string();
let role = role.to_string(); let role = role.to_string();
let tenant_id = self
.state
.tenants
.tenant_id_for_slug(&self.state.config.default_tenant_slug)
.context("default tenant not found")?;
self.with_conn(move |conn| { self.with_conn(move |conn| {
let password_hash = hash_password(&password)?; let password_hash = hash_password(&password)?;
let user = NewUser { let user = NewUser {
id: Uuid::new_v4(), id: Uuid::new_v4(),
username, username,
password_hash, password_hash,
role, tenant_id,
}; };
diesel::insert_into(backend::schema::users::table) diesel::insert_into(backend::schema::users::table)
.values(&user) .values(&user)
.execute(conn) .execute(conn)
.context("failed to insert user")?; .context("failed to insert user")?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id,
role,
};
diesel::insert_into(backend::schema::user_memberships::table)
.values(&membership)
.execute(conn)
.context("failed to insert user membership")?;
Ok(user.id) Ok(user.id)
}) })
.await .await