This commit is contained in:
2025-10-31 19:13:11 +01:00
parent 2d8a4432cc
commit 175387f0d3
19 changed files with 463 additions and 107 deletions
+10
View File
@@ -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. background worker using the repository-relative paths defined in the tmux file.
Detach with `Ctrl+b d` and reattach later with the same command. 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 ## Backend Integration Tests
Integration tests require a running Postgres instance (and, optionally, Quickwit Integration tests require a running Postgres instance (and, optionally, Quickwit
@@ -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;
@@ -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
$$;
@@ -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();
@@ -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);
@@ -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;
+30 -1
View File
@@ -1,12 +1,40 @@
use std::time::Duration; use std::time::Duration;
use diesel::pg::PgConnection; use diesel::pg::PgConnection;
use diesel::r2d2::{ConnectionManager, Pool}; use diesel::r2d2::{ConnectionManager, CustomizeConnection, Pool};
use diesel::RunQueryDsl;
pub type PgPool = Pool<ConnectionManager<PgConnection>>; pub type PgPool = Pool<ConnectionManager<PgConnection>>;
pub const DEFAULT_MAX_POOL_SIZE: u32 = 2; pub const DEFAULT_MAX_POOL_SIZE: u32 = 2;
#[derive(Debug)]
struct SchemaCustomizer;
impl CustomizeConnection<PgConnection, diesel::r2d2::Error> 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<PgPool> { pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE) 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() let pool = Pool::builder()
.max_size(pool_size) .max_size(pool_size)
.connection_timeout(Duration::from_secs(10)) .connection_timeout(Duration::from_secs(10))
.connection_customizer(Box::new(SchemaCustomizer))
.build(manager)?; .build(manager)?;
Ok(pool) Ok(pool)
} }
+58 -29
View File
@@ -26,14 +26,17 @@ use crate::{
}, },
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{ models::{
MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, TenantStatus, User,
User, UserMembership,
}, },
schema::{ schema::{
magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl, 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, user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl,
}, },
state::AppState, 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; use crate::schema::refresh_tokens::dsl as refresh_dsl;
@@ -337,6 +340,7 @@ pub async fn refresh(
let now = Utc::now(); let now = Utc::now();
let now_naive = now.naive_utc(); let now_naive = now.naive_utc();
apply_refresh_token_hash(&mut conn, &hashed)?;
let token = match refresh_dsl::refresh_tokens let token = match refresh_dsl::refresh_tokens
.filter(refresh_dsl::token_hash.eq(&hashed)) .filter(refresh_dsl::token_hash.eq(&hashed))
.filter(refresh_dsl::revoked_at.is_null()) .filter(refresh_dsl::revoked_at.is_null())
@@ -348,6 +352,10 @@ pub async fn refresh(
Err(err) => return Err(AppError::from(err)), 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))) diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id)))
.set(( .set((
refresh_dsl::revoked_at.eq(now_naive), refresh_dsl::revoked_at.eq(now_naive),
@@ -398,15 +406,17 @@ pub async fn select_tenant(
}; };
let mut conn = state.db_unscoped()?; let mut conn = state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let membership_exists = memberships_dsl::user_memberships let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id)) .filter(memberships_dsl::user_id.eq(user_id))
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id)) .filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
.inner_join(tenant_dsl::tenants) .select(memberships_dsl::tenant_id)
.select(memberships_dsl::id)
.first::<Uuid>(&mut conn) .first::<Uuid>(&mut conn)
.optional()?; .optional()?;
clear_user_guc(&mut conn)?;
if membership_exists.is_none() { if membership_exists.is_none() {
return Err(AppError::unauthorized()); return Err(AppError::unauthorized());
} }
@@ -430,7 +440,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_unscoped()?; let mut conn = state.db_for_tenant(user.tenant_id)?;
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let mut rows_affected = 0; let mut rows_affected = 0;
@@ -505,15 +515,26 @@ pub async fn list_tenants(
}; };
let mut conn = state.db_unscoped()?; let mut conn = state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let tenants = memberships_dsl::user_memberships let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user_id)) .filter(memberships_dsl::user_id.eq(user_id))
.select((tenant_dsl::id, tenant_dsl::name)) .select(memberships_dsl::tenant_id)
.load::<(Uuid, String)>(&mut conn)? .load(&mut conn)?;
.into_iter()
.map(|(id, name)| TenantSnippet { id, name }) clear_user_guc(&mut conn)?;
.collect(); 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 })) Ok(Json(TenantListResponse { tenants }))
} }
@@ -642,26 +663,27 @@ fn complete_login(
user: &User, user: &User,
preferred_tenant_id: Option<Uuid>, preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships apply_user_guc(conn, user.id)?;
.inner_join(tenant_dsl::tenants) let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id)) .filter(memberships_dsl::user_id.eq(user.id))
.select(memberships_dsl::tenant_id)
.load(conn)?; .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()); return Err(AppError::unauthorized());
} }
if let Some(preferred_id) = preferred_tenant_id { if let Some(preferred_id) = preferred_tenant_id {
if let Some((_, tenant)) = memberships if tenant_ids.iter().any(|id| *id == preferred_id) {
.iter() return issue_session(state, conn, user, preferred_id);
.find(|(_, tenant)| tenant.id == preferred_id)
{
return issue_session(state, conn, user, tenant.id);
} }
} }
if memberships.len() == 1 { if tenant_ids.len() == 1 {
return issue_session(state, conn, user, memberships[0].1.id); return issue_session(state, conn, user, tenant_ids[0]);
} }
let selection_token = state let selection_token = state
@@ -669,13 +691,16 @@ fn complete_login(
.generate_tenant_selector_token(user.id) .generate_tenant_selector_token(user.id)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let tenants = memberships let mut tenants = Vec::with_capacity(tenant_ids.len());
.into_iter() for tenant_id in tenant_ids {
.map(|(_, tenant)| TenantSnippet { let mut tenant_conn = state.db_for_tenant(tenant_id)?;
id: tenant.id, let name: String = tenant_dsl::tenants
name: tenant.name, .find(tenant_id)
}) .select(tenant_dsl::name)
.collect(); .first(&mut tenant_conn)
.map_err(AppError::from)?;
tenants.push(TenantSnippet { id: tenant_id, name });
}
Ok(Json(TenantSelectionResponse { Ok(Json(TenantSelectionResponse {
access_token: selection_token, access_token: selection_token,
@@ -747,6 +772,10 @@ fn issue_session(
user: &User, user: &User,
tenant_id: Uuid, tenant_id: Uuid,
) -> AppResult<Response> { ) -> AppResult<Response> {
apply_tenant_guc(conn, tenant_id)?;
clear_user_guc(conn)?;
clear_refresh_token_hash(conn)?;
let now = Utc::now(); let now = Utc::now();
let access_token = state let access_token = state
.jwt .jwt
+13 -8
View File
@@ -21,10 +21,11 @@ use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentVersion, Folder, User}; use crate::models::{Document, DocumentVersion, Folder, User};
use crate::schema::{ use crate::schema::{
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
folders::dsl as folders_dsl, tenants::dsl as tenant_dsl, folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl,
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, users::dsl as users_dsl,
}; };
use crate::state::AppState; 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}; use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date};
const REALM: &str = "Papercrate WebDAV"; const REALM: &str = "Papercrate WebDAV";
@@ -453,16 +454,19 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
} }
}; };
let tenant_row = memberships_dsl::user_memberships apply_user_guc(&mut conn, user.id)?;
.inner_join(tenant_dsl::tenants)
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id)) .filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(token.tenant_id)) .filter(memberships_dsl::tenant_id.eq(token.tenant_id))
.select((tenant_dsl::id, tenant_dsl::name)) .select(memberships_dsl::tenant_id)
.first::<(Uuid, String)>(&mut conn) .first::<Uuid>(&mut conn)
.optional()?; .optional()?;
let (tenant_id, _name) = match tenant_row { clear_user_guc(&mut conn)?;
Some(row) => row,
let tenant_id = match membership_exists {
Some(id) => id,
None => { None => {
tracing::warn!( tracing::warn!(
%username, %username,
@@ -473,6 +477,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
} }
}; };
apply_tenant_guc(&mut conn, tenant_id)?;
touch_webdav_token(&mut conn, token.id)?; touch_webdav_token(&mut conn, token.id)?;
tracing::debug!( tracing::debug!(
+6 -3
View File
@@ -12,7 +12,7 @@ use crate::{
db::PgPool, db::PgPool,
error::{AppError, AppResult}, error::{AppError, AppResult},
storage::{ObjectStorage, TenantStorage}, storage::{ObjectStorage, TenantStorage},
tenants::{apply_tenant_guc, TenantService}, tenants::{apply_tenant_guc, clear_tenant_context, clear_user_guc, TenantService},
}; };
pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>; pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
@@ -75,14 +75,17 @@ impl AppState {
debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant"); debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant");
let mut conn = self.db_unscoped()?; let mut conn = self.db_unscoped()?;
apply_tenant_guc(&mut conn, tenant_id)?; apply_tenant_guc(&mut conn, tenant_id)?;
clear_user_guc(&mut conn)?;
Ok(conn) Ok(conn)
} }
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> { pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
self.pool.get().map_err(|err| { let mut conn = self.pool.get().map_err(|err| {
tracing::error!(error = ?err, "database pool error"); tracing::error!(error = ?err, "database pool error");
AppError::internal("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<TenantStorage> { pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
+43 -1
View File
@@ -128,13 +128,55 @@ impl TenantService {
} }
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> { 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::<Text, _>(tenant_id.to_string()) .bind::<Text, _>(tenant_id.to_string())
.execute(conn) .execute(conn)
.map(|_| ()) .map(|_| ())
.map_err(AppError::from) .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::<Text, _>(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::<Text, _>(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 { fn normalize_storage_root(raw: Option<&str>, tenant_id: Uuid) -> String {
match raw.map(str::trim) { match raw.map(str::trim) {
Some(root) if !root.is_empty() => { Some(root) if !root.is_empty() => {
+12 -2
View File
@@ -91,7 +91,12 @@ fn analyze_document(
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 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 { if version.document_id != payload.document_id {
return Err("document/version mismatch".into()); return Err("document/version mismatch".into());
@@ -100,7 +105,12 @@ fn analyze_document(
let document: Document = documents::table let document: Document = documents::table
.find(payload.document_id) .find(payload.document_id)
.first(&mut conn) .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; let tenant_id = document.tenant_id;
+4 -3
View File
@@ -15,10 +15,13 @@ pub(crate) struct DocumentVersionContext {
pub(crate) fn load_document_version( pub(crate) fn load_document_version(
state: &AppState, state: &AppState,
tenant_id: Uuid,
document_id: Uuid, document_id: Uuid,
version_id: Uuid, version_id: Uuid,
) -> Result<DocumentVersionContext, String> { ) -> Result<DocumentVersionContext, String> {
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 let version: DocumentVersion = document_versions::table
.find(version_id) .find(version_id)
@@ -34,8 +37,6 @@ pub(crate) fn load_document_version(
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
Ok(DocumentVersionContext { Ok(DocumentVersionContext {
document, document,
version, version,
+12 -13
View File
@@ -89,8 +89,8 @@ impl JobHandler for IndexDocumentTextJob {
let client = Client::new(); let client = Client::new();
let state_clone = state.clone(); 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(Ok(ctx)) => ctx,
Ok(Err(err)) => { Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "index job will retry"); warn!(job_id = %job.id, error = %err, "index job will retry");
@@ -166,12 +166,18 @@ struct IndexContext {
text_s3_key: Option<String>, text_s3_key: Option<String>,
} }
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> { fn load_context(
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; state: Arc<AppState>,
tenant_id: Uuid,
payload: IndexPayload,
) -> Result<IndexContext, 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)
.first(&mut base_conn) .first(&mut 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 {
@@ -180,14 +186,7 @@ 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 base_conn) .first(&mut 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
+15 -15
View File
@@ -24,7 +24,7 @@ use crate::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
NewDocumentAssetObject, NewDocumentAssetObject,
}, },
schema::{document_asset_objects, document_assets, documents}, schema::{document_asset_objects, document_assets},
state::AppState, state::AppState,
storage::TenantStorage, storage::TenantStorage,
utils::storage_paths::document_asset_object_prefix, utils::storage_paths::document_asset_object_prefix,
@@ -77,9 +77,12 @@ impl JobHandler for GenerateOcrTextJob {
let state_clone = state.clone(); let state_clone = state.clone();
let payload_clone = payload.clone(); let payload_clone = payload.clone();
let context = let tenant_id = job.tenant_id;
match task::spawn_blocking(move || load_ocr_context(state_clone, &payload_clone)).await let context = match task::spawn_blocking(move || {
{ load_ocr_context(state_clone, tenant_id, payload_clone)
})
.await
{
Ok(Ok(ctx)) => ctx, Ok(Ok(ctx)) => ctx,
Ok(Err(err)) => { Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "ocr job will retry"); warn!(job_id = %job.id, error = %err, "ocr job will retry");
@@ -195,7 +198,7 @@ impl JobHandler for GenerateOcrTextJob {
.await .await
{ {
Ok(Ok(())) => { 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"); warn!(job_id = %job.id, error = %err, "failed to enqueue index job");
} }
JobExecution::Success JobExecution::Success
@@ -236,9 +239,14 @@ struct OcrGeneration {
source: &'static str, source: &'static str,
} }
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> { fn load_ocr_context(
state: Arc<AppState>,
tenant_id: Uuid,
payload: OcrPayload,
) -> Result<OcrContext, String> {
let base = load_document_version( let base = load_document_version(
state.as_ref(), state.as_ref(),
tenant_id,
payload.document_id, payload.document_id,
payload.document_version_id, payload.document_version_id,
)?; )?;
@@ -481,15 +489,7 @@ fn persist_ocr_metadata(
Ok(()) Ok(())
} }
fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> { fn enqueue_index_job(state: &AppState, tenant_id: Uuid, 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);
let mut conn = state let mut conn = state
.db_for_tenant(tenant_id) .db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
+19 -12
View File
@@ -18,7 +18,7 @@ use crate::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
NewDocumentAssetObject, NewDocumentAssetObject,
}, },
schema::{document_asset_objects, document_assets, document_versions, documents}, schema::{document_asset_objects, document_assets, document_versions},
state::AppState, state::AppState,
storage::TenantStorage, storage::TenantStorage,
utils::storage_paths::document_asset_object_key, 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 THUMBNAIL_ASSET_TYPE: &str = "thumbnail";
const PREVIEW_ASSET_TYPE: &str = "preview"; const PREVIEW_ASSET_TYPE: &str = "preview";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Clone)]
struct ThumbnailPayload { struct ThumbnailPayload {
document_id: Uuid, document_id: Uuid,
document_version_id: Uuid, document_version_id: Uuid,
@@ -75,9 +75,13 @@ impl JobHandler for GenerateThumbnailsJob {
}; };
let state_clone = state.clone(); let state_clone = state.clone();
let initial = let tenant_id = job.tenant_id;
match task::spawn_blocking(move || load_thumbnail_context(state_clone, &payload)).await 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(Ok(ctx)) => ctx,
Ok(Err(err)) => { Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "thumbnail job will retry"); 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 state_clone = state.clone();
let document_id = initial.document.id; let document_id = initial.document.id;
let version_id = initial.version.id; let version_id = initial.version.id;
let tenant_id = initial.tenant_id;
match task::spawn_blocking(move || { 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 .await
{ {
@@ -372,6 +377,7 @@ struct ThumbnailContext {
existing_preview: Option<DocumentAsset>, existing_preview: Option<DocumentAsset>,
existing_preview_objects: Vec<DocumentAssetObject>, existing_preview_objects: Vec<DocumentAssetObject>,
skip: bool, skip: bool,
tenant_id: Uuid,
} }
struct GeneratedImage { struct GeneratedImage {
@@ -405,10 +411,12 @@ struct AssetPersistence {
fn load_thumbnail_context( fn load_thumbnail_context(
state: Arc<AppState>, state: Arc<AppState>,
payload: &ThumbnailPayload, tenant_id: Uuid,
payload: ThumbnailPayload,
) -> Result<ThumbnailContext, String> { ) -> Result<ThumbnailContext, String> {
let base = load_document_version( let base = load_document_version(
state.as_ref(), state.as_ref(),
tenant_id,
payload.document_id, payload.document_id,
payload.document_version_id, payload.document_version_id,
)?; )?;
@@ -467,6 +475,7 @@ fn load_thumbnail_context(
existing_preview, existing_preview,
existing_preview_objects, existing_preview_objects,
skip, skip,
tenant_id: base.tenant_id,
}) })
} }
@@ -708,15 +717,13 @@ fn persist_assets_metadata(
fn persist_document_page_count( fn persist_document_page_count(
state: Arc<AppState>, state: Arc<AppState>,
tenant_id: Uuid,
document_id: Uuid, document_id: Uuid,
document_version_id: Uuid, document_version_id: Uuid,
page_count: u32, page_count: u32,
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; let mut conn = state
let tenant_id: Uuid = documents::table .db_for_tenant(tenant_id)
.find(document_id)
.select(documents::tenant_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing_metadata: Value = document_versions::table let existing_metadata: Value = document_versions::table
+23 -18
View File
@@ -38,7 +38,11 @@ use uuid::Uuid;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
const RESET_DATABASE_SQL: &str = 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<Mutex<()>> = Lazy::new(|| Mutex::new(())); static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
@@ -775,23 +779,24 @@ async fn prepare_database(pool: &PgPool) -> Result<()> {
fn truncate_all(conn: &mut PgConnection) -> Result<()> { fn truncate_all(conn: &mut PgConnection) -> Result<()> {
conn.batch_execute( conn.batch_execute(
"TRUNCATE TABLE \ "TRUNCATE TABLE \
document_asset_objects, \ tenant.document_asset_objects, \
document_assets, \ tenant.document_assets, \
document_correspondents, \ tenant.document_correspondents, \
correspondents, \ tenant.correspondents, \
document_tags, \ tenant.document_tags, \
document_versions, \ tenant.document_versions, \
documents, \ tenant.documents, \
folders, \ tenant.folders, \
jobs, \ shared.jobs, \
refresh_tokens, \ tenant.refresh_tokens, \
tags, \ tenant.tags, \
webdav_tokens, \ tenant.webdav_tokens, \
webauthn_challenges, \ shared.webauthn_challenges, \
user_passkeys, \ shared.user_passkeys, \
user_memberships, \ tenant.user_memberships, \
users, \ shared.users, \
tenants \ shared.magic_tokens, \
shared.tenants \
RESTART IDENTITY CASCADE;", RESTART IDENTITY CASCADE;",
) )
.context("failed to truncate tables")?; .context("failed to truncate tables")?;
+3 -1
View File
@@ -1,7 +1,7 @@
version: "3.9" version: "3.9"
x-app-env: &app-env 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} DATABASE_MAX_POOL_SIZE: ${DATABASE_MAX_POOL_SIZE:-8}
SERVER_HOST: 0.0.0.0 SERVER_HOST: 0.0.0.0
SERVER_PORT: 3000 SERVER_PORT: 3000
@@ -41,6 +41,7 @@ services:
POSTGRES_DB: papercrate POSTGRES_DB: papercrate
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./backend/postgres-init:/docker-entrypoint-initdb.d
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate"] test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate"]
interval: 10s interval: 10s
@@ -170,6 +171,7 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
<<: *app-env <<: *app-env
DATABASE_URL: postgres://papercrate:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@postgres:5432/papercrate
entrypoint: ["/usr/local/bin/diesel"] entrypoint: ["/usr/local/bin/diesel"]
command: ["migration", "run"] command: ["migration", "run"]
restart: "no" restart: "no"
+1 -1
View File
@@ -9,7 +9,7 @@ services:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./backend/migrations:/docker-entrypoint-initdb.d - ./backend/postgres-init:/docker-entrypoint-initdb.d
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U papercrate"] test: ["CMD-SHELL", "pg_isready -U papercrate"]
interval: 5s interval: 5s