diff --git a/backend/migrations/202510310201_enable_webdav_token_rls/down.sql b/backend/migrations/202510310201_enable_webdav_token_rls/down.sql new file mode 100644 index 0000000..56e8426 --- /dev/null +++ b/backend/migrations/202510310201_enable_webdav_token_rls/down.sql @@ -0,0 +1,5 @@ +DROP POLICY IF EXISTS tenant_webdav_token_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 FUNCTION IF EXISTS shared.current_webdav_token_prefix(); diff --git a/backend/migrations/202510310201_enable_webdav_token_rls/up.sql b/backend/migrations/202510310201_enable_webdav_token_rls/up.sql new file mode 100644 index 0000000..290ff75 --- /dev/null +++ b/backend/migrations/202510310201_enable_webdav_token_rls/up.sql @@ -0,0 +1,15 @@ +CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_webdav_token_prefix() IS NOT NULL + AND token_prefix = shared.current_webdav_token_prefix() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); diff --git a/backend/src/auth/webdav_tokens.rs b/backend/src/auth/webdav_tokens.rs index 389a631..0ea62f7 100644 --- a/backend/src/auth/webdav_tokens.rs +++ b/backend/src/auth/webdav_tokens.rs @@ -13,6 +13,7 @@ use crate::{ models::{NewWebdavToken, WebdavToken}, schema::webdav_tokens, state::PgPooledConnection, + tenants::{apply_webdav_token_prefix, clear_webdav_token_prefix}, }; const TOKEN_PREFIX_LENGTH: usize = 12; @@ -84,6 +85,7 @@ pub fn find_active_token_by_secret( } let prefix = &secret[..TOKEN_PREFIX_LENGTH]; + apply_webdav_token_prefix(conn, prefix)?; let mut query = webdav_tokens::table .filter(webdav_tokens::user_id.eq(user_id)) .filter(webdav_tokens::token_prefix.eq(prefix)) @@ -101,7 +103,10 @@ pub fn find_active_token_by_secret( query = query.filter(webdav_tokens::tenant_id.eq(tenant_id)); } - let candidates = query.load::(conn)?; + let load_result = query.load::(conn); + let clear_result = clear_webdav_token_prefix(conn); + clear_result?; + let candidates = load_result?; for token in candidates { if verify_token_secret(secret, &token.token_hash)? { diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs index e1d3029..4393177 100644 --- a/backend/src/tenants.rs +++ b/backend/src/tenants.rs @@ -148,7 +148,8 @@ pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> { "SELECT \ set_config('papercrate.tenant_id', '', false), \ set_config('papercrate.user_id', '', false), \ - set_config('papercrate.refresh_token_hash', '', false)" + set_config('papercrate.refresh_token_hash', '', false), \ + set_config('papercrate.webdav_token_prefix', '', false)" ) .execute(conn) .map(|_| ()) @@ -177,6 +178,21 @@ pub fn clear_refresh_token_hash(conn: &mut PgConnection) -> AppResult<()> { .map_err(AppError::from) } +pub fn apply_webdav_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', $1, false)") + .bind::(prefix) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn clear_webdav_token_prefix(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', '', 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/tenants.rs b/backend/src/workers/tenants.rs index 0be799a..5eaade8 100644 --- a/backend/src/workers/tenants.rs +++ b/backend/src/workers/tenants.rs @@ -57,6 +57,19 @@ impl JobHandler for ProvisionTenantJob { } }; + drop(conn); + + let mut conn = match state.db_for_tenant(tenant.id) { + Ok(conn) => conn, + Err(err) => { + warn!(job_id = %job.id, error = ?err, "failed to scope connection for tenant provisioning"); + return JobExecution::Retry { + delay: std::time::Duration::from_secs(30), + error: "tenant connection unavailable".into(), + }; + } + }; + if tenant.status == TenantStatus::Active { warn!( job_id = %job.id, diff --git a/k8s/papercrate/templates/backend-secret.yaml b/k8s/papercrate/templates/backend-secret.yaml index 6e4a37b..eb549b6 100644 --- a/k8s/papercrate/templates/backend-secret.yaml +++ b/k8s/papercrate/templates/backend-secret.yaml @@ -3,8 +3,10 @@ {{- $config := dict "databaseUrl" .Values.backend.env.databaseUrl -}} {{- if not (index $config "databaseUrl") -}} {{- if .Values.postgres.enabled -}} - {{- $dbUser := required "postgres.auth.username is required when postgres.enabled" .Values.postgres.auth.username -}} - {{- $dbPass := required "postgres.auth.password is required when postgres.enabled" .Values.postgres.auth.password -}} + {{- $appUser := default .Values.postgres.auth.username .Values.postgres.auth.appUsername -}} + {{- $appPass := default .Values.postgres.auth.password .Values.postgres.auth.appPassword -}} + {{- $dbUser := required "postgres auth appUsername/password (or username/password) must be provided when postgres.enabled" $appUser -}} + {{- $dbPass := required "postgres auth appUsername/password (or username/password) must be provided when postgres.enabled" $appPass -}} {{- $dbName := default "papercrate" .Values.backend.env.databaseName -}} {{- $dbHost := printf "%s-postgres" (include "papercrate.fullname" .) -}} {{- $_ := set $config "databaseUrl" (printf "postgres://%s:%s@%s:5432/%s" $dbUser $dbPass $dbHost $dbName) -}} diff --git a/k8s/papercrate/templates/postgres-configmap.yaml b/k8s/papercrate/templates/postgres-configmap.yaml index ccf1c0b..8f41047 100644 --- a/k8s/papercrate/templates/postgres-configmap.yaml +++ b/k8s/papercrate/templates/postgres-configmap.yaml @@ -17,6 +17,44 @@ data: -c "CREATE DATABASE \"{{ $name }}\""; fi {{- end }} + {{- $appRole := default "" .Values.postgres.auth.appRole }} + {{- $appUser := default "" .Values.postgres.auth.appUsername }} + {{- $appPassword := default "" .Values.postgres.auth.appPassword }} + APP_ROLE="{{ $appRole }}" + APP_USER="{{ $appUser }}" + APP_PASSWORD="{{ $appPassword }}" + APP_DATABASE="{{ default "papercrate" .Values.backend.env.databaseName }}" + if [ -n "$APP_ROLE" ]; then + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <<-EOSQL + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$APP_ROLE') THEN + CREATE ROLE "$APP_ROLE" NOLOGIN; + END IF; + END + $$; +EOSQL + fi + if [ -n "$APP_USER" ] && [ -n "$APP_PASSWORD" ]; then + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <<-EOSQL + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$APP_USER') THEN + CREATE ROLE "$APP_USER" LOGIN PASSWORD '$APP_PASSWORD'; + ELSE + ALTER ROLE "$APP_USER" WITH LOGIN PASSWORD '$APP_PASSWORD'; + END IF; + IF '$APP_ROLE' <> '' THEN + GRANT "$APP_ROLE" TO "$APP_USER"; + END IF; + END + $$; + GRANT CONNECT ON DATABASE "$APP_DATABASE" TO "$APP_USER"; + GRANT USAGE ON SCHEMA public TO "$APP_USER"; + ALTER ROLE "$APP_USER" SET search_path = 'tenant, shared, public'; + ALTER ROLE "$APP_USER" INHERIT; +EOSQL + fi if [ -n "$PGDATA" ] && [ -f "$PGDATA/postgresql.conf" ]; then if grep -q "^max_connections" "$PGDATA/postgresql.conf"; then sed -i "s/^max_connections.*/max_connections = {{ .Values.postgres.maxConnections | default 200 }}/" "$PGDATA/postgresql.conf" diff --git a/k8s/papercrate/templates/quickwit-deployment.yaml b/k8s/papercrate/templates/quickwit-deployment.yaml index a6e0a6d..3e35116 100644 --- a/k8s/papercrate/templates/quickwit-deployment.yaml +++ b/k8s/papercrate/templates/quickwit-deployment.yaml @@ -35,10 +35,7 @@ spec: {{- fail "quickwit.env.storageUri must be set, or provide global.s3.bucket" -}} {{- end -}} {{- if and (not (index $qw "metastoreUri")) .Values.postgres.enabled -}} - {{- $dbUser := required "postgres.auth.username is required when postgres.enabled" .Values.postgres.auth.username -}} - {{- $dbPass := required "postgres.auth.password is required when postgres.enabled" .Values.postgres.auth.password -}} - {{- $dbHost := printf "%s-postgres" (include "papercrate.fullname" .) -}} - {{- $_ := set $qw "metastoreUri" (printf "postgres://%s:%s@%s:5432/quickwit" $dbUser $dbPass $dbHost) -}} + {{- $_ := fail "quickwit.env.metastoreSecret must be provided when using chart-managed Postgres" -}} {{- end -}} {{- if not (index $qw "metastoreUri") -}} {{- fail "quickwit.env.metastoreUri must be set when not using bundled Postgres" -}} @@ -55,8 +52,16 @@ spec: env: - name: QW_STORAGE_URI value: {{ index $qw "storageUri" | quote }} + {{- if .Values.quickwit.env.metastoreSecret }} + - name: QW_METASTORE_URI + valueFrom: + secretKeyRef: + name: {{ .Values.quickwit.env.metastoreSecret }} + key: metastoreUri + {{- else }} - name: QW_METASTORE_URI value: {{ index $qw "metastoreUri" | quote }} + {{- end }} {{- with .Values.global.s3.endpoint }} - name: AWS_ENDPOINT_URL value: {{ . | quote }} diff --git a/k8s/papercrate/values.yaml b/k8s/papercrate/values.yaml index 7999233..3d4f27a 100644 --- a/k8s/papercrate/values.yaml +++ b/k8s/papercrate/values.yaml @@ -108,6 +108,9 @@ postgres: auth: username: papercrate password: papercrate_dev + appRole: papercrate_app + appUsername: papercrate_app_login + appPassword: papercrate_app databases: papercrate: {} quickwit: {}