diff --git a/src/main/resources/db/migration-postgresql/V42__enable_postgresql_rls.sql b/src/main/resources/db/migration-postgresql/V42__enable_postgresql_rls.sql new file mode 100644 index 00000000..e55ac5cd --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V42__enable_postgresql_rls.sql @@ -0,0 +1,45 @@ +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +ALTER TABLE public.company ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.company_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.user_account ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.refresh_token ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.user_agreement_consent ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.password_reset_token ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.worker ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.worker_document ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.stored_file ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.task ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.task_checklist_item ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.task_transition_history ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.document_request_draft ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.document_request_draft_type ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.approval_request ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.external_submission ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.task_evidence ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.audit_event ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.workflow_case ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.document_ocr_run ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.notification ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.worker_link ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.worker_response ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.worker_response_upload ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.worker_document_upload_idempotency ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.worker_import_job ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.worker_import_row ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.worker_import_commit_idempotency ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.ai_run ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ai_attempt ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ai_question ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ai_candidate ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ai_candidate_decision_batch ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ai_candidate_decision ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ai_candidate_decision_task ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.event_publication ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.event_consumption ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.outbox_manual_retry ENABLE ROW LEVEL SECURITY; diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 54d5bbae..d9e1620a 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -21,6 +21,47 @@ @EnabledIfEnvironmentVariable(named = "POSTGRES_TEST_ENABLED", matches = "true") class PostgreSqlMigrationTests { + private static final Set RLS_TABLES = Set.of( + "company", + "company_settings", + "user_account", + "refresh_token", + "user_agreement_consent", + "password_reset_token", + "worker", + "worker_document", + "stored_file", + "task", + "task_checklist_item", + "task_transition_history", + "document_request_draft", + "document_request_draft_type", + "approval_request", + "external_submission", + "task_evidence", + "audit_event", + "workflow_case", + "document_ocr_run", + "notification", + "worker_link", + "worker_response", + "worker_response_upload", + "worker_document_upload_idempotency", + "worker_import_job", + "worker_import_row", + "worker_import_commit_idempotency", + "ai_run", + "ai_attempt", + "ai_question", + "ai_candidate", + "ai_candidate_decision_batch", + "ai_candidate_decision", + "ai_candidate_decision_task", + "event_publication", + "event_consumption", + "outbox_manual_retry" + ); + private static final String COMPANY_A = "10000000-0000-0000-0000-000000000001"; private static final String COMPANY_B = "20000000-0000-0000-0000-000000000002"; private static final String USER_A = "11000000-0000-0000-0000-000000000001"; @@ -109,7 +150,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "worker_import_job", "worker_import_row", "worker_import_commit_idempotency", - "document_ocr_run" + "document_ocr_run", + "notification" ); assertThat(columnSpecs(connection, "company")) @@ -528,7 +570,11 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_document_ocr_run_tenant_isolation", "pl_notification_tenant_isolation" ); - assertThat(rlsEnabledTables(connection)).isEmpty(); + assertThat(policyTableNames(connection)) + .containsExactlyInAnyOrderElementsOf(RLS_TABLES); + assertThat(rlsEnabledTables(connection)) + .containsExactlyInAnyOrderElementsOf(RLS_TABLES); + assertThat(rlsForcedTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) .containsExactlyInAnyOrder( "bootstrap_company_id_by_normalized_email", @@ -1061,6 +1107,17 @@ private Set policyNames(Connection connection) throws SQLException { ); } + private Set policyTableNames(Connection connection) throws SQLException { + return queryStrings( + connection, + """ + SELECT DISTINCT tablename + FROM pg_catalog.pg_policies + WHERE schemaname = 'public' + """ + ); + } + private Set rlsEnabledTables(Connection connection) throws SQLException { return queryStrings( connection, @@ -1074,6 +1131,19 @@ private Set rlsEnabledTables(Connection connection) throws SQLException ); } + private Set rlsForcedTables(Connection connection) throws SQLException { + return queryStrings( + connection, + """ + SELECT relname + FROM pg_catalog.pg_class + WHERE relnamespace = 'public'::regnamespace + AND relkind = 'r' + AND relforcerowsecurity + """ + ); + } + private Set securityDefinerFunctionNames(Connection connection) throws SQLException { return queryStrings( connection, diff --git a/src/test/java/com/fowoco/server/common/database/PostgreSqlRuntimeTimeoutBehaviorIntegrationTest.java b/src/test/java/com/fowoco/server/common/database/PostgreSqlRuntimeTimeoutBehaviorIntegrationTest.java index aa1208f7..4a70d616 100644 --- a/src/test/java/com/fowoco/server/common/database/PostgreSqlRuntimeTimeoutBehaviorIntegrationTest.java +++ b/src/test/java/com/fowoco/server/common/database/PostgreSqlRuntimeTimeoutBehaviorIntegrationTest.java @@ -39,11 +39,14 @@ void resetFixture() { void statementTimeoutRollsBackTransactionAndPoolServesNextQuery() { Throwable failure = catchThrowable(() -> transactionTemplate.executeWithoutResult( status -> { - runtimeJdbc.update( + tenantDatabaseContext.setCompanyIdForCurrentTransaction( + FIXTURE_COMPANY_ID + ); + assertThat(runtimeJdbc.update( "UPDATE company SET name = ? WHERE company_id = ?", "must roll back", FIXTURE_COMPANY_ID - ); + )).isEqualTo(1); runtimeJdbc.execute("SELECT pg_catalog.pg_sleep(1.0)"); } )); @@ -72,14 +75,17 @@ void lockTimeoutDoesNotAffectLockOwnerAndPoolRecovers() throws Exception { for (int attempt = 0; attempt < 3; attempt++) { Throwable failure = catchThrowable(() -> - transactionTemplate.executeWithoutResult(status -> - runtimeJdbc.update( - "UPDATE company SET name = ? " - + "WHERE company_id = ?", - "blocked update", - FIXTURE_COMPANY_ID - ) - ) + transactionTemplate.executeWithoutResult(status -> { + tenantDatabaseContext.setCompanyIdForCurrentTransaction( + FIXTURE_COMPANY_ID + ); + runtimeJdbc.update( + "UPDATE company SET name = ? " + + "WHERE company_id = ?", + "blocked update", + FIXTURE_COMPANY_ID + ); + }) ); PostgreSqlTimeoutClassification classification = classifier.classify(failure); @@ -95,11 +101,14 @@ void lockTimeoutDoesNotAffectLockOwnerAndPoolRecovers() throws Exception { } } - transactionTemplate.executeWithoutResult(status -> runtimeJdbc.update( - "UPDATE company SET name = ? WHERE company_id = ?", - "after lock release", - FIXTURE_COMPANY_ID - )); + transactionTemplate.executeWithoutResult(status -> { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(FIXTURE_COMPANY_ID); + assertThat(runtimeJdbc.update( + "UPDATE company SET name = ? WHERE company_id = ?", + "after lock release", + FIXTURE_COMPANY_ID + )).isEqualTo(1); + }); assertThat(migrationJdbc.queryForObject( "SELECT name FROM company WHERE company_id = ?", String.class, diff --git a/src/test/java/com/fowoco/server/common/database/PostgreSqlRuntimeTimeoutIntegrationSupport.java b/src/test/java/com/fowoco/server/common/database/PostgreSqlRuntimeTimeoutIntegrationSupport.java index 4c966891..c936c10d 100644 --- a/src/test/java/com/fowoco/server/common/database/PostgreSqlRuntimeTimeoutIntegrationSupport.java +++ b/src/test/java/com/fowoco/server/common/database/PostgreSqlRuntimeTimeoutIntegrationSupport.java @@ -2,6 +2,7 @@ import com.fowoco.server.ServerApplication; import com.fowoco.server.common.security.PostgreSqlRlsTestLock; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.zaxxer.hikari.HikariDataSource; import java.sql.Connection; import java.sql.DriverManager; @@ -41,6 +42,7 @@ abstract class PostgreSqlRuntimeTimeoutIntegrationSupport { protected JdbcTemplate runtimeJdbc; protected HikariDataSource runtimeDataSource; protected TransactionTemplate transactionTemplate; + protected TenantDatabaseContext tenantDatabaseContext; private PostgreSqlRlsTestLock rlsTestLock; protected abstract String statementTimeout(); @@ -122,6 +124,9 @@ ON CONFLICT (company_id) DO UPDATE SET transactionTemplate = new TransactionTemplate( applicationContext.getBean(PlatformTransactionManager.class) ); + tenantDatabaseContext = applicationContext.getBean( + TenantDatabaseContext.class + ); } catch (Throwable setupFailure) { try { tearDownRuntimeTimeoutFixture();