rls
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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
@@ -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() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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:?}"))?;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user