From 175387f0d3413436c8e6640841c90578a4785fce Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 31 Oct 2025 19:13:11 +0100 Subject: [PATCH] rls --- DEVELOPMENT.md | 10 +++ .../202510310100_split_schemas/down.sql | 28 ++++++ .../202510310100_split_schemas/up.sql | 45 ++++++++++ .../202510310200_enable_rls/down.sql | 51 +++++++++++ .../migrations/202510310200_enable_rls/up.sql | 69 +++++++++++++++ backend/postgres-init/01_create_app_roles.sql | 21 +++++ backend/src/db.rs | 31 ++++++- backend/src/routes/auth.rs | 87 ++++++++++++------- backend/src/routes/webdav/mod.rs | 21 +++-- backend/src/state.rs | 9 +- backend/src/tenants.rs | 44 +++++++++- backend/src/workers/analyze.rs | 14 ++- backend/src/workers/common.rs | 7 +- backend/src/workers/index.rs | 25 +++--- backend/src/workers/ocr.rs | 30 +++---- backend/src/workers/thumbnails.rs | 31 ++++--- backend/tests/common/mod.rs | 41 +++++---- docker-compose.yml | 4 +- docker-composer.dev.yml | 2 +- 19 files changed, 463 insertions(+), 107 deletions(-) create mode 100644 backend/migrations/202510310100_split_schemas/down.sql create mode 100644 backend/migrations/202510310100_split_schemas/up.sql create mode 100644 backend/migrations/202510310200_enable_rls/down.sql create mode 100644 backend/migrations/202510310200_enable_rls/up.sql create mode 100644 backend/postgres-init/01_create_app_roles.sql diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 54ce317..6bdddb1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -15,6 +15,16 @@ This creates windows for the compose stack, frontend dev server, backend API, an background worker using the repository-relative paths defined in the tmux file. Detach with `Ctrl+b d` and reattach later with the same command. +The development Postgres container now seeds two database roles: + +- `papercrate_app_login` (password `papercrate_app`) is used by the backend and + is subject to row-level security policies. +- `papercrate` remains the owner role for running Diesel migrations or other + maintenance tasks. + +When connecting manually to inspect RLS behaviour, switch to the application +role with `SET ROLE papercrate_app_login;` before querying tenant tables. + ## Backend Integration Tests Integration tests require a running Postgres instance (and, optionally, Quickwit diff --git a/backend/migrations/202510310100_split_schemas/down.sql b/backend/migrations/202510310100_split_schemas/down.sql new file mode 100644 index 0000000..4c93eda --- /dev/null +++ b/backend/migrations/202510310100_split_schemas/down.sql @@ -0,0 +1,28 @@ +-- Move tables and types back to the public schema +ALTER TABLE tenant.webdav_tokens SET SCHEMA public; +ALTER TABLE tenant.user_memberships SET SCHEMA public; +ALTER TABLE tenant.refresh_tokens SET SCHEMA public; +ALTER TABLE tenant.tags SET SCHEMA public; +ALTER TABLE tenant.document_correspondents SET SCHEMA public; +ALTER TABLE tenant.document_tags SET SCHEMA public; +ALTER TABLE tenant.document_asset_objects SET SCHEMA public; +ALTER TABLE tenant.document_assets SET SCHEMA public; +ALTER TABLE tenant.document_versions SET SCHEMA public; +ALTER TABLE tenant.documents SET SCHEMA public; +ALTER TABLE tenant.folders SET SCHEMA public; +ALTER TABLE tenant.correspondents SET SCHEMA public; + +ALTER FUNCTION shared.touch_jobs_updated_at() SET SCHEMA public; + +ALTER TABLE shared.magic_tokens SET SCHEMA public; +ALTER TABLE shared.jobs SET SCHEMA public; +ALTER TABLE shared.webauthn_challenges SET SCHEMA public; +ALTER TABLE shared.user_passkeys SET SCHEMA public; +ALTER TABLE shared.users SET SCHEMA public; +ALTER TABLE shared.tenants SET SCHEMA public; + +ALTER TYPE shared.magic_token_kind SET SCHEMA public; +ALTER TYPE shared.tenant_status SET SCHEMA public; + +DROP SCHEMA IF EXISTS tenant CASCADE; +DROP SCHEMA IF EXISTS shared CASCADE; diff --git a/backend/migrations/202510310100_split_schemas/up.sql b/backend/migrations/202510310100_split_schemas/up.sql new file mode 100644 index 0000000..5ab9471 --- /dev/null +++ b/backend/migrations/202510310100_split_schemas/up.sql @@ -0,0 +1,45 @@ +CREATE SCHEMA IF NOT EXISTS shared; +CREATE SCHEMA IF NOT EXISTS tenant; + +-- Move global types and tables into the shared schema +ALTER TYPE tenant_status SET SCHEMA shared; +ALTER TYPE magic_token_kind SET SCHEMA shared; + +ALTER TABLE tenants SET SCHEMA shared; +ALTER TABLE users SET SCHEMA shared; +ALTER TABLE user_passkeys SET SCHEMA shared; +ALTER TABLE webauthn_challenges SET SCHEMA shared; +ALTER TABLE jobs SET SCHEMA shared; +ALTER TABLE magic_tokens SET SCHEMA shared; + +ALTER FUNCTION touch_jobs_updated_at() SET SCHEMA shared; + +-- Move tenant-scoped tables into the tenant schema +ALTER TABLE correspondents SET SCHEMA tenant; +ALTER TABLE folders SET SCHEMA tenant; +ALTER TABLE documents SET SCHEMA tenant; +ALTER TABLE document_versions SET SCHEMA tenant; +ALTER TABLE document_assets SET SCHEMA tenant; +ALTER TABLE document_asset_objects SET SCHEMA tenant; +ALTER TABLE document_tags SET SCHEMA tenant; +ALTER TABLE document_correspondents SET SCHEMA tenant; +ALTER TABLE tags SET SCHEMA tenant; +ALTER TABLE refresh_tokens SET SCHEMA tenant; +ALTER TABLE user_memberships SET SCHEMA tenant; +ALTER TABLE webdav_tokens SET SCHEMA tenant; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN + RETURN; + END IF; + + GRANT USAGE ON SCHEMA shared TO papercrate_app; + GRANT USAGE ON SCHEMA tenant TO papercrate_app; + GRANT SELECT ON ALL TABLES IN SCHEMA shared TO papercrate_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tenant TO papercrate_app; + + ALTER DEFAULT PRIVILEGES IN SCHEMA shared GRANT SELECT ON TABLES TO papercrate_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA tenant GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO papercrate_app; +END +$$; diff --git a/backend/migrations/202510310200_enable_rls/down.sql b/backend/migrations/202510310200_enable_rls/down.sql new file mode 100644 index 0000000..be3675d --- /dev/null +++ b/backend/migrations/202510310200_enable_rls/down.sql @@ -0,0 +1,51 @@ +DROP POLICY IF EXISTS tenant_membership_select_policy ON tenant.user_memberships; +ALTER TABLE tenant.user_memberships NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.user_memberships DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.webdav_tokens; +ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.tags; +ALTER TABLE tenant.tags NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.tags DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_refresh_token_policy ON tenant.refresh_tokens; +ALTER TABLE tenant.refresh_tokens NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.refresh_tokens DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.folders; +ALTER TABLE tenant.folders NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.folders DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.documents; +ALTER TABLE tenant.documents NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.documents DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_versions; +ALTER TABLE tenant.document_versions NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_versions DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_tags; +ALTER TABLE tenant.document_tags NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_tags DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_correspondents; +ALTER TABLE tenant.document_correspondents NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_correspondents DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_assets; +ALTER TABLE tenant.document_assets NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_assets DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_asset_objects; +ALTER TABLE tenant.document_asset_objects NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_asset_objects DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.correspondents; +ALTER TABLE tenant.correspondents NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.correspondents DISABLE ROW LEVEL SECURITY; + +DROP FUNCTION IF EXISTS shared.current_refresh_token_hash(); +DROP FUNCTION IF EXISTS shared.current_user_id(); +DROP FUNCTION IF EXISTS shared.current_tenant_id(); diff --git a/backend/migrations/202510310200_enable_rls/up.sql b/backend/migrations/202510310200_enable_rls/up.sql new file mode 100644 index 0000000..2a7550f --- /dev/null +++ b/backend/migrations/202510310200_enable_rls/up.sql @@ -0,0 +1,69 @@ +CREATE OR REPLACE FUNCTION shared.current_tenant_id() RETURNS uuid AS $$ + SELECT CASE + WHEN setting IS NULL OR setting = '' THEN NULL + ELSE setting::uuid + END + FROM (SELECT current_setting('papercrate.tenant_id', true) AS setting) s; +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION shared.current_user_id() RETURNS uuid AS $$ + SELECT CASE + WHEN setting IS NULL OR setting = '' THEN NULL + ELSE setting::uuid + END + FROM (SELECT current_setting('papercrate.user_id', true) AS setting) s; +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '') +$$ LANGUAGE SQL STABLE; + +-- Helper to create tenant isolation policy +CREATE OR REPLACE FUNCTION shared.ensure_tenant_policy(table_reg regclass) RETURNS void AS $$ +BEGIN + EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', table_reg); + EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', table_reg); + EXECUTE format( + 'CREATE POLICY tenant_isolation_policy ON %s USING (tenant_id = shared.current_tenant_id()) WITH CHECK (tenant_id = shared.current_tenant_id())', + table_reg + ); +END; +$$ LANGUAGE plpgsql; + +SELECT shared.ensure_tenant_policy('tenant.correspondents'); +SELECT shared.ensure_tenant_policy('tenant.document_asset_objects'); +SELECT shared.ensure_tenant_policy('tenant.document_assets'); +SELECT shared.ensure_tenant_policy('tenant.document_correspondents'); +SELECT shared.ensure_tenant_policy('tenant.document_tags'); +SELECT shared.ensure_tenant_policy('tenant.document_versions'); +SELECT shared.ensure_tenant_policy('tenant.documents'); +SELECT shared.ensure_tenant_policy('tenant.folders'); +SELECT shared.ensure_tenant_policy('tenant.tags'); +SELECT shared.ensure_tenant_policy('tenant.webdav_tokens'); + +-- user_memberships has a special read policy to allow tenant discovery during login +ALTER TABLE tenant.user_memberships ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.user_memberships FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_membership_select_policy ON tenant.user_memberships + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_user_id() IS NOT NULL + AND user_id = shared.current_user_id() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); + +ALTER TABLE tenant.refresh_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.refresh_tokens FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_refresh_token_policy ON tenant.refresh_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_refresh_token_hash() IS NOT NULL + AND token_hash = shared.current_refresh_token_hash() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); + +DROP FUNCTION shared.ensure_tenant_policy(regclass); diff --git a/backend/postgres-init/01_create_app_roles.sql b/backend/postgres-init/01_create_app_roles.sql new file mode 100644 index 0000000..c6a8202 --- /dev/null +++ b/backend/postgres-init/01_create_app_roles.sql @@ -0,0 +1,21 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN + CREATE ROLE papercrate_app NOLOGIN; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app_login') THEN + CREATE ROLE papercrate_app_login LOGIN PASSWORD 'papercrate_app'; + GRANT papercrate_app TO papercrate_app_login; + END IF; +END +$$; + +-- Ensure the login role inherits and uses a sensible search path by default +ALTER ROLE papercrate_app_login INHERIT; +ALTER ROLE papercrate_app_login SET search_path = 'tenant, shared, public'; + +GRANT CONNECT ON DATABASE papercrate TO papercrate_app; +GRANT CONNECT ON DATABASE papercrate TO papercrate_app_login; +GRANT USAGE ON SCHEMA public TO papercrate_app; +GRANT USAGE ON SCHEMA public TO papercrate_app_login; diff --git a/backend/src/db.rs b/backend/src/db.rs index a1cd981..7798c4c 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,12 +1,40 @@ use std::time::Duration; use diesel::pg::PgConnection; -use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::r2d2::{ConnectionManager, CustomizeConnection, Pool}; +use diesel::RunQueryDsl; pub type PgPool = Pool>; pub const DEFAULT_MAX_POOL_SIZE: u32 = 2; +#[derive(Debug)] +struct SchemaCustomizer; + +impl CustomizeConnection for SchemaCustomizer { + fn on_acquire(&self, conn: &mut PgConnection) -> Result<(), diesel::r2d2::Error> { + diesel::sql_query( + "SELECT set_config('search_path', ( + SELECT string_agg(schema_name, ', ') + FROM ( + SELECT 'tenant' AS schema_name WHERE EXISTS ( + SELECT 1 FROM pg_namespace WHERE nspname = 'tenant' + ) + UNION ALL + SELECT 'shared' AS schema_name WHERE EXISTS ( + SELECT 1 FROM pg_namespace WHERE nspname = 'shared' + ) + UNION ALL + SELECT 'public' AS schema_name + ) AS schemas + ), false)", + ) + .execute(conn) + .map(|_| ()) + .map_err(diesel::r2d2::Error::QueryError) + } +} + pub fn init_pool(database_url: &str) -> anyhow::Result { init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE) } @@ -17,6 +45,7 @@ pub fn init_pool_with_size(database_url: &str, max_size: u32) -> anyhow::Result< let pool = Pool::builder() .max_size(pool_size) .connection_timeout(Duration::from_secs(10)) + .connection_customizer(Box::new(SchemaCustomizer)) .build(manager)?; Ok(pool) } diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 2cd5d8e..ce20cc5 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -26,14 +26,17 @@ use crate::{ }, error::{AppError, AppResult}, models::{ - MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, - User, UserMembership, + MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, TenantStatus, User, }, schema::{ magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl, }, state::AppState, + tenants::{ + apply_refresh_token_hash, apply_tenant_guc, apply_user_guc, clear_refresh_token_hash, + clear_user_guc, + }, }; use crate::schema::refresh_tokens::dsl as refresh_dsl; @@ -337,6 +340,7 @@ pub async fn refresh( let now = Utc::now(); let now_naive = now.naive_utc(); + apply_refresh_token_hash(&mut conn, &hashed)?; let token = match refresh_dsl::refresh_tokens .filter(refresh_dsl::token_hash.eq(&hashed)) .filter(refresh_dsl::revoked_at.is_null()) @@ -348,6 +352,10 @@ pub async fn refresh( Err(err) => return Err(AppError::from(err)), }; + clear_refresh_token_hash(&mut conn)?; + apply_tenant_guc(&mut conn, token.tenant_id)?; + clear_user_guc(&mut conn)?; + diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id))) .set(( refresh_dsl::revoked_at.eq(now_naive), @@ -398,15 +406,17 @@ pub async fn select_tenant( }; let mut conn = state.db_unscoped()?; + apply_user_guc(&mut conn, user_id)?; let membership_exists = memberships_dsl::user_memberships .filter(memberships_dsl::user_id.eq(user_id)) .filter(memberships_dsl::tenant_id.eq(payload.tenant_id)) - .inner_join(tenant_dsl::tenants) - .select(memberships_dsl::id) + .select(memberships_dsl::tenant_id) .first::(&mut conn) .optional()?; + clear_user_guc(&mut conn)?; + if membership_exists.is_none() { return Err(AppError::unauthorized()); } @@ -430,7 +440,7 @@ pub async fn logout( user: AuthenticatedUser, jar: Option>, ) -> AppResult<(HeaderMap, StatusCode)> { - let mut conn = state.db_unscoped()?; + let mut conn = state.db_for_tenant(user.tenant_id)?; let now = Utc::now().naive_utc(); let mut rows_affected = 0; @@ -505,15 +515,26 @@ pub async fn list_tenants( }; let mut conn = state.db_unscoped()?; + apply_user_guc(&mut conn, user_id)?; - let tenants = memberships_dsl::user_memberships - .inner_join(tenant_dsl::tenants) + let tenant_ids: Vec = memberships_dsl::user_memberships .filter(memberships_dsl::user_id.eq(user_id)) - .select((tenant_dsl::id, tenant_dsl::name)) - .load::<(Uuid, String)>(&mut conn)? - .into_iter() - .map(|(id, name)| TenantSnippet { id, name }) - .collect(); + .select(memberships_dsl::tenant_id) + .load(&mut conn)?; + + clear_user_guc(&mut conn)?; + drop(conn); + + let mut tenants = Vec::with_capacity(tenant_ids.len()); + for tenant_id in tenant_ids { + let mut tenant_conn = state.db_for_tenant(tenant_id)?; + let name: String = tenant_dsl::tenants + .find(tenant_id) + .select(tenant_dsl::name) + .first(&mut tenant_conn) + .map_err(AppError::from)?; + tenants.push(TenantSnippet { id: tenant_id, name }); + } Ok(Json(TenantListResponse { tenants })) } @@ -642,26 +663,27 @@ fn complete_login( user: &User, preferred_tenant_id: Option, ) -> AppResult { - let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships - .inner_join(tenant_dsl::tenants) + apply_user_guc(conn, user.id)?; + let tenant_ids: Vec = memberships_dsl::user_memberships .filter(memberships_dsl::user_id.eq(user.id)) + .select(memberships_dsl::tenant_id) .load(conn)?; + clear_user_guc(conn)?; - if memberships.is_empty() { + tracing::debug!(user_id = %user.id, tenants = tenant_ids.len(), "passkey login memberships"); + + if tenant_ids.is_empty() { return Err(AppError::unauthorized()); } if let Some(preferred_id) = preferred_tenant_id { - if let Some((_, tenant)) = memberships - .iter() - .find(|(_, tenant)| tenant.id == preferred_id) - { - return issue_session(state, conn, user, tenant.id); + if tenant_ids.iter().any(|id| *id == preferred_id) { + return issue_session(state, conn, user, preferred_id); } } - if memberships.len() == 1 { - return issue_session(state, conn, user, memberships[0].1.id); + if tenant_ids.len() == 1 { + return issue_session(state, conn, user, tenant_ids[0]); } let selection_token = state @@ -669,13 +691,16 @@ fn complete_login( .generate_tenant_selector_token(user.id) .map_err(AppError::from)?; - let tenants = memberships - .into_iter() - .map(|(_, tenant)| TenantSnippet { - id: tenant.id, - name: tenant.name, - }) - .collect(); + let mut tenants = Vec::with_capacity(tenant_ids.len()); + for tenant_id in tenant_ids { + let mut tenant_conn = state.db_for_tenant(tenant_id)?; + let name: String = tenant_dsl::tenants + .find(tenant_id) + .select(tenant_dsl::name) + .first(&mut tenant_conn) + .map_err(AppError::from)?; + tenants.push(TenantSnippet { id: tenant_id, name }); + } Ok(Json(TenantSelectionResponse { access_token: selection_token, @@ -747,6 +772,10 @@ fn issue_session( user: &User, tenant_id: Uuid, ) -> AppResult { + apply_tenant_guc(conn, tenant_id)?; + clear_user_guc(conn)?; + clear_refresh_token_hash(conn)?; + let now = Utc::now(); let access_token = state .jwt diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs index 5642adf..cea1edc 100644 --- a/backend/src/routes/webdav/mod.rs +++ b/backend/src/routes/webdav/mod.rs @@ -21,10 +21,11 @@ use crate::error::{AppError, AppResult}; use crate::models::{Document, DocumentVersion, Folder, User}; use crate::schema::{ document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, - folders::dsl as folders_dsl, tenants::dsl as tenant_dsl, - user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, + folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, + users::dsl as users_dsl, }; use crate::state::AppState; +use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc}; use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date}; const REALM: &str = "Papercrate WebDAV"; @@ -453,16 +454,19 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result(&mut conn) + .select(memberships_dsl::tenant_id) + .first::(&mut conn) .optional()?; - let (tenant_id, _name) = match tenant_row { - Some(row) => row, + clear_user_guc(&mut conn)?; + + let tenant_id = match membership_exists { + Some(id) => id, None => { tracing::warn!( %username, @@ -473,6 +477,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result>; @@ -75,14 +75,17 @@ impl AppState { debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant"); let mut conn = self.db_unscoped()?; apply_tenant_guc(&mut conn, tenant_id)?; + clear_user_guc(&mut conn)?; Ok(conn) } pub(crate) fn db_unscoped(&self) -> AppResult { - self.pool.get().map_err(|err| { + let mut conn = self.pool.get().map_err(|err| { tracing::error!(error = ?err, "database pool error"); AppError::internal("database pool error") - }) + })?; + clear_tenant_context(&mut conn)?; + Ok(conn) } pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult { diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs index 4fe9fa5..e1d3029 100644 --- a/backend/src/tenants.rs +++ b/backend/src/tenants.rs @@ -128,13 +128,55 @@ impl TenantService { } pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> { - diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, true)") + diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, false)") .bind::(tenant_id.to_string()) .execute(conn) .map(|_| ()) .map_err(AppError::from) } +pub fn apply_user_guc(conn: &mut PgConnection, user_id: Uuid) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_id', $1, false)") + .bind::(user_id.to_string()) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query( + "SELECT \ + set_config('papercrate.tenant_id', '', false), \ + set_config('papercrate.user_id', '', false), \ + set_config('papercrate.refresh_token_hash', '', false)" + ) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn clear_user_guc(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_id', '', false)") + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn apply_refresh_token_hash(conn: &mut PgConnection, hash: &str) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.refresh_token_hash', $1, false)") + .bind::(hash) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn clear_refresh_token_hash(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.refresh_token_hash', '', false)") + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + fn normalize_storage_root(raw: Option<&str>, tenant_id: Uuid) -> String { match raw.map(str::trim) { Some(root) if !root.is_empty() => { diff --git a/backend/src/workers/analyze.rs b/backend/src/workers/analyze.rs index f6c7430..3678644 100644 --- a/backend/src/workers/analyze.rs +++ b/backend/src/workers/analyze.rs @@ -91,7 +91,12 @@ fn analyze_document( let version: DocumentVersion = document_versions::table .find(payload.document_version_id) .first(&mut conn) - .map_err(|err| format!("{err:?}"))?; + .map_err(|err| { + format!( + "failed to load document_version {} for tenant {}: {err:?}", + payload.document_version_id, tenant_id + ) + })?; if version.document_id != payload.document_id { return Err("document/version mismatch".into()); @@ -100,7 +105,12 @@ fn analyze_document( let document: Document = documents::table .find(payload.document_id) .first(&mut conn) - .map_err(|err| format!("{err:?}"))?; + .map_err(|err| { + format!( + "failed to load document {} for tenant {}: {err:?}", + payload.document_id, tenant_id + ) + })?; let tenant_id = document.tenant_id; diff --git a/backend/src/workers/common.rs b/backend/src/workers/common.rs index 0cbdaf5..bccb92e 100644 --- a/backend/src/workers/common.rs +++ b/backend/src/workers/common.rs @@ -15,10 +15,13 @@ pub(crate) struct DocumentVersionContext { pub(crate) fn load_document_version( state: &AppState, + tenant_id: Uuid, document_id: Uuid, version_id: Uuid, ) -> Result { - let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; let version: DocumentVersion = document_versions::table .find(version_id) @@ -34,8 +37,6 @@ pub(crate) fn load_document_version( .first(&mut conn) .map_err(|err| format!("{err:?}"))?; - let tenant_id = document.tenant_id; - Ok(DocumentVersionContext { document, version, diff --git a/backend/src/workers/index.rs b/backend/src/workers/index.rs index 0277eae..d7125b9 100644 --- a/backend/src/workers/index.rs +++ b/backend/src/workers/index.rs @@ -89,8 +89,8 @@ impl JobHandler for IndexDocumentTextJob { let client = Client::new(); let state_clone = state.clone(); - let context = match task::spawn_blocking(move || load_context(state_clone, &payload)).await - { + let tenant_id = job.tenant_id; + let context = match task::spawn_blocking(move || load_context(state_clone, tenant_id, payload)).await { Ok(Ok(ctx)) => ctx, Ok(Err(err)) => { warn!(job_id = %job.id, error = %err, "index job will retry"); @@ -166,12 +166,18 @@ struct IndexContext { text_s3_key: Option, } -fn load_context(state: Arc, payload: &IndexPayload) -> Result { - let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; +fn load_context( + state: Arc, + tenant_id: Uuid, + payload: IndexPayload, +) -> Result { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; let version: DocumentVersion = document_versions::table .find(payload.document_version_id) - .first(&mut base_conn) + .first(&mut conn) .map_err(|err| format!("{err:?}"))?; if version.document_id != payload.document_id { @@ -180,14 +186,7 @@ fn load_context(state: Arc, payload: &IndexPayload) -> Result = document_asset_objects::table diff --git a/backend/src/workers/ocr.rs b/backend/src/workers/ocr.rs index 87b180b..e3196a4 100644 --- a/backend/src/workers/ocr.rs +++ b/backend/src/workers/ocr.rs @@ -24,7 +24,7 @@ use crate::{ Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, NewDocumentAssetObject, }, - schema::{document_asset_objects, document_assets, documents}, + schema::{document_asset_objects, document_assets}, state::AppState, storage::TenantStorage, utils::storage_paths::document_asset_object_prefix, @@ -77,9 +77,12 @@ impl JobHandler for GenerateOcrTextJob { let state_clone = state.clone(); let payload_clone = payload.clone(); - let context = - match task::spawn_blocking(move || load_ocr_context(state_clone, &payload_clone)).await - { + let tenant_id = job.tenant_id; + let context = match task::spawn_blocking(move || { + load_ocr_context(state_clone, tenant_id, payload_clone) + }) + .await + { Ok(Ok(ctx)) => ctx, Ok(Err(err)) => { warn!(job_id = %job.id, error = %err, "ocr job will retry"); @@ -195,7 +198,7 @@ impl JobHandler for GenerateOcrTextJob { .await { Ok(Ok(())) => { - if let Err(err) = enqueue_index_job(&state, &payload) { + if let Err(err) = enqueue_index_job(&state, job.tenant_id, &payload) { warn!(job_id = %job.id, error = %err, "failed to enqueue index job"); } JobExecution::Success @@ -236,9 +239,14 @@ struct OcrGeneration { source: &'static str, } -fn load_ocr_context(state: Arc, payload: &OcrPayload) -> Result { +fn load_ocr_context( + state: Arc, + tenant_id: Uuid, + payload: OcrPayload, +) -> Result { let base = load_document_version( state.as_ref(), + tenant_id, payload.document_id, payload.document_version_id, )?; @@ -481,15 +489,7 @@ fn persist_ocr_metadata( Ok(()) } -fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> { - 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); - +fn enqueue_index_job(state: &AppState, tenant_id: Uuid, payload: &OcrPayload) -> Result<(), String> { let mut conn = state .db_for_tenant(tenant_id) .map_err(|err| format!("{err:?}"))?; diff --git a/backend/src/workers/thumbnails.rs b/backend/src/workers/thumbnails.rs index aab9bc1..111fdb3 100644 --- a/backend/src/workers/thumbnails.rs +++ b/backend/src/workers/thumbnails.rs @@ -18,7 +18,7 @@ use crate::{ Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, NewDocumentAssetObject, }, - schema::{document_asset_objects, document_assets, document_versions, documents}, + schema::{document_asset_objects, document_assets, document_versions}, state::AppState, storage::TenantStorage, utils::storage_paths::document_asset_object_key, @@ -37,7 +37,7 @@ const PREVIEW_HEIGHT: u32 = THUMBNAIL_HEIGHT * 4; const THUMBNAIL_ASSET_TYPE: &str = "thumbnail"; const PREVIEW_ASSET_TYPE: &str = "preview"; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] struct ThumbnailPayload { document_id: Uuid, document_version_id: Uuid, @@ -75,9 +75,13 @@ impl JobHandler for GenerateThumbnailsJob { }; let state_clone = state.clone(); - let initial = - match task::spawn_blocking(move || load_thumbnail_context(state_clone, &payload)).await - { + let tenant_id = job.tenant_id; + let payload_for_context = payload.clone(); + let initial = match task::spawn_blocking(move || { + load_thumbnail_context(state_clone, tenant_id, payload_for_context) + }) + .await + { Ok(Ok(ctx)) => ctx, Ok(Err(err)) => { warn!(job_id = %job.id, error = %err, "thumbnail job will retry"); @@ -123,8 +127,9 @@ impl JobHandler for GenerateThumbnailsJob { let state_clone = state.clone(); let document_id = initial.document.id; let version_id = initial.version.id; + let tenant_id = initial.tenant_id; match task::spawn_blocking(move || { - persist_document_page_count(state_clone, document_id, version_id, page_count) + persist_document_page_count(state_clone, tenant_id, document_id, version_id, page_count) }) .await { @@ -372,6 +377,7 @@ struct ThumbnailContext { existing_preview: Option, existing_preview_objects: Vec, skip: bool, + tenant_id: Uuid, } struct GeneratedImage { @@ -405,10 +411,12 @@ struct AssetPersistence { fn load_thumbnail_context( state: Arc, - payload: &ThumbnailPayload, + tenant_id: Uuid, + payload: ThumbnailPayload, ) -> Result { let base = load_document_version( state.as_ref(), + tenant_id, payload.document_id, payload.document_version_id, )?; @@ -467,6 +475,7 @@ fn load_thumbnail_context( existing_preview, existing_preview_objects, skip, + tenant_id: base.tenant_id, }) } @@ -708,15 +717,13 @@ fn persist_assets_metadata( fn persist_document_page_count( state: Arc, + tenant_id: Uuid, document_id: Uuid, document_version_id: Uuid, page_count: u32, ) -> Result<(), String> { - 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) + let mut conn = state + .db_for_tenant(tenant_id) .map_err(|err| format!("{err:?}"))?; let existing_metadata: Value = document_versions::table diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 25ff4fc..9eea33d 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -38,7 +38,11 @@ use uuid::Uuid; const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); const RESET_DATABASE_SQL: &str = - "DROP SCHEMA IF EXISTS public CASCADE;\nCREATE SCHEMA public;\nGRANT ALL ON SCHEMA public TO public;"; + "DROP SCHEMA IF EXISTS tenant CASCADE;\n\ + DROP SCHEMA IF EXISTS shared CASCADE;\n\ + DROP SCHEMA IF EXISTS public CASCADE;\n\ + CREATE SCHEMA public;\n\ + GRANT ALL ON SCHEMA public TO public;"; static DB_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); @@ -775,23 +779,24 @@ async fn prepare_database(pool: &PgPool) -> Result<()> { fn truncate_all(conn: &mut PgConnection) -> Result<()> { conn.batch_execute( "TRUNCATE TABLE \ - document_asset_objects, \ - document_assets, \ - document_correspondents, \ - correspondents, \ - document_tags, \ - document_versions, \ - documents, \ - folders, \ - jobs, \ - refresh_tokens, \ - tags, \ - webdav_tokens, \ - webauthn_challenges, \ - user_passkeys, \ - user_memberships, \ - users, \ - tenants \ + tenant.document_asset_objects, \ + tenant.document_assets, \ + tenant.document_correspondents, \ + tenant.correspondents, \ + tenant.document_tags, \ + tenant.document_versions, \ + tenant.documents, \ + tenant.folders, \ + shared.jobs, \ + tenant.refresh_tokens, \ + tenant.tags, \ + tenant.webdav_tokens, \ + shared.webauthn_challenges, \ + shared.user_passkeys, \ + tenant.user_memberships, \ + shared.users, \ + shared.magic_tokens, \ + shared.tenants \ RESTART IDENTITY CASCADE;", ) .context("failed to truncate tables")?; diff --git a/docker-compose.yml b/docker-compose.yml index 1ffac0b..9320927 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: "3.9" x-app-env: &app-env - DATABASE_URL: postgres://papercrate:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@postgres:5432/papercrate + DATABASE_URL: postgres://papercrate_app_login:${APP_DATABASE_PASSWORD:-papercrate_app}@postgres:5432/papercrate DATABASE_MAX_POOL_SIZE: ${DATABASE_MAX_POOL_SIZE:-8} SERVER_HOST: 0.0.0.0 SERVER_PORT: 3000 @@ -41,6 +41,7 @@ services: POSTGRES_DB: papercrate volumes: - postgres_data:/var/lib/postgresql/data + - ./backend/postgres-init:/docker-entrypoint-initdb.d healthcheck: test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate"] interval: 10s @@ -170,6 +171,7 @@ services: condition: service_healthy environment: <<: *app-env + DATABASE_URL: postgres://papercrate:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@postgres:5432/papercrate entrypoint: ["/usr/local/bin/diesel"] command: ["migration", "run"] restart: "no" diff --git a/docker-composer.dev.yml b/docker-composer.dev.yml index 9990d19..ece5879 100644 --- a/docker-composer.dev.yml +++ b/docker-composer.dev.yml @@ -9,7 +9,7 @@ services: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - - ./backend/migrations:/docker-entrypoint-initdb.d + - ./backend/postgres-init:/docker-entrypoint-initdb.d healthcheck: test: ["CMD-SHELL", "pg_isready -U papercrate"] interval: 5s