diff --git a/src/main/resources/db/migration-postgresql/V58__enable_postgresql_rls.sql b/src/main/resources/db/migration-postgresql/V58__enable_postgresql_rls.sql new file mode 100644 index 00000000..05caa7ba --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V58__enable_postgresql_rls.sql @@ -0,0 +1,47 @@ +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.stay_verification_case ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.worker_archive 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 0eea7033..852eb40a 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -21,6 +21,49 @@ @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", + "stay_verification_case", + "worker_archive", + "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"; @@ -110,6 +153,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { "worker_import_row", "worker_import_commit_idempotency", "document_ocr_run", + "notification", "stay_verification_case", "worker_archive" ); @@ -561,7 +605,11 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_stay_verification_tenant_isolation", "pl_worker_archive_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", @@ -1144,6 +1192,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, @@ -1157,6 +1216,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(); diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java index 34a0a29f..aa16e53d 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java @@ -86,6 +86,10 @@ class PostgreSqlRlsIsolationTest { UUID.fromString("ad000000-0000-0000-0000-000000000001"); private static final UUID OCR_RUN_B = UUID.fromString("bd000000-0000-0000-0000-000000000002"); + private static final UUID STAY_VERIFICATION_A = + UUID.fromString("ae000000-0000-0000-0000-000000000001"); + private static final UUID STAY_VERIFICATION_B = + UUID.fromString("be000000-0000-0000-0000-000000000002"); private static final List RLS_TABLES = List.of( "company", "user_account", @@ -102,7 +106,9 @@ class PostgreSqlRlsIsolationTest { "outbox_manual_retry", "user_agreement_consent", "password_reset_token", - "document_ocr_run" + "document_ocr_run", + "stay_verification_case", + "worker_archive" ); @Test @@ -196,6 +202,8 @@ private void prepareFixture( + "public.worker_document_upload_idempotency, " + "public.outbox_manual_retry, " + "public.document_ocr_run, " + + "public.stay_verification_case, " + + "public.worker_archive, " + "public.user_agreement_consent, " + "public.password_reset_token TO " + quotedRole @@ -225,6 +233,31 @@ INSERT INTO user_account ( ('%s', '%s', 'rls-b@example.com', 'rls-b@example.com', 'test-password-hash-b', 'ADMIN', 'ACTIVE') """.formatted(USER_A, COMPANY_A, USER_B, COMPANY_B)); + statement.execute(""" + INSERT INTO stay_verification_case ( + stay_verification_id, company_id, worker_id, + source_stay_expiry_date, verification_status, + created_at, updated_at + ) VALUES + ('%s', '%s', '%s', DATE '2026-08-01', 'UNKNOWN', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('%s', '%s', '%s', DATE '2026-08-02', 'UNKNOWN', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.formatted( + STAY_VERIFICATION_A, COMPANY_A, WORKER_A, + STAY_VERIFICATION_B, COMPANY_B, WORKER_B + )); + statement.execute(""" + INSERT INTO worker_archive ( + worker_id, company_id, archived_at, archived_by, + archive_reason, worker_version + ) VALUES + ('%s', '%s', CURRENT_TIMESTAMP, '%s', 'RLS archive A', 1), + ('%s', '%s', CURRENT_TIMESTAMP, '%s', 'RLS archive B', 1) + """.formatted( + WORKER_A, COMPANY_A, USER_A, + WORKER_B, COMPANY_B, USER_B + )); statement.execute(""" INSERT INTO user_agreement_consent ( consent_id, company_id, user_id, agreement_type, @@ -448,6 +481,8 @@ private void assertMissingAndInvalidContextFailClosed(Connection connection) assertThat(tableCount(connection, "user_agreement_consent")).isZero(); assertThat(tableCount(connection, "password_reset_token")).isZero(); assertThat(tableCount(connection, "document_ocr_run")).isZero(); + assertThat(tableCount(connection, "stay_verification_case")).isZero(); + assertThat(tableCount(connection, "worker_archive")).isZero(); setTenantContext(connection, ""); assertThat(workerCount(connection)).isZero(); @@ -524,6 +559,15 @@ private void assertTenantCrudIsolation(Connection connection) throws SQLExceptio connection, "SELECT ocr_run_id FROM public.document_ocr_run ORDER BY ocr_run_id" )).containsExactly(OCR_RUN_A); + assertThat(uuidValues( + connection, + "SELECT stay_verification_id FROM public.stay_verification_case " + + "ORDER BY stay_verification_id" + )).containsExactly(STAY_VERIFICATION_A); + assertThat(uuidValues( + connection, + "SELECT worker_id FROM public.worker_archive ORDER BY worker_id" + )).containsExactly(WORKER_A); assertThat(executeUpdate( connection, @@ -698,6 +742,33 @@ INSERT INTO outbox_manual_retry ( ) """.formatted(COMPANY_B, EVENT_B, USER_B) ); + assertSqlState( + connection, + "42501", + """ + INSERT INTO stay_verification_case ( + stay_verification_id, company_id, worker_id, + source_stay_expiry_date, verification_status, + created_at, updated_at + ) VALUES ( + 'be000000-0000-0000-0000-000000000099', '%s', '%s', + DATE '2026-08-03', 'UNKNOWN', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """.formatted(COMPANY_B, WORKER_B) + ); + assertSqlState( + connection, + "42501", + """ + INSERT INTO worker_archive ( + worker_id, company_id, archived_at, archived_by, + archive_reason, worker_version + ) VALUES ( + '%s', '%s', + CURRENT_TIMESTAMP, '%s', 'Forbidden archive', 1 + ) + """.formatted(WORKER_B, COMPANY_B, USER_B) + ); assertThat(executeUpdate( connection, @@ -714,6 +785,16 @@ INSERT INTO outbox_manual_retry ( "DELETE FROM document_ocr_run WHERE ocr_run_id = ?", OCR_RUN_B )).isZero(); + assertThat(executeUpdate( + connection, + "DELETE FROM stay_verification_case WHERE stay_verification_id = ?", + STAY_VERIFICATION_B + )).isZero(); + assertThat(executeUpdate( + connection, + "DELETE FROM worker_archive WHERE worker_id = ?", + WORKER_B + )).isZero(); assertThat(executeUpdate( connection, "DELETE FROM workflow_case WHERE case_id = ?", @@ -760,6 +841,8 @@ private void assertCommittedContextDoesNotLeak(Connection connection) throws SQL assertThat(tableCount(connection, "workflow_case")).isZero(); assertThat(tableCount(connection, "outbox_manual_retry")).isZero(); assertThat(tableCount(connection, "document_ocr_run")).isZero(); + assertThat(tableCount(connection, "stay_verification_case")).isZero(); + assertThat(tableCount(connection, "worker_archive")).isZero(); setTenantContext(connection, COMPANY_B.toString()); assertThat(workerIds(connection)).containsExactly(WORKER_B); assertThat(uuidValues( @@ -774,6 +857,15 @@ private void assertCommittedContextDoesNotLeak(Connection connection) throws SQL connection, "SELECT ocr_run_id FROM public.document_ocr_run ORDER BY ocr_run_id" )).containsExactly(OCR_RUN_B); + assertThat(uuidValues( + connection, + "SELECT stay_verification_id FROM public.stay_verification_case " + + "ORDER BY stay_verification_id" + )).containsExactly(STAY_VERIFICATION_B); + assertThat(uuidValues( + connection, + "SELECT worker_id FROM public.worker_archive ORDER BY worker_id" + )).containsExactly(WORKER_B); connection.rollback(); } @@ -816,6 +908,15 @@ private SQLException runCleanupStep(SQLException failure, SqlCleanupStep step) { } private void deleteFixtureRows(Statement statement) throws SQLException { + statement.execute(""" + DELETE FROM stay_verification_case + WHERE stay_verification_id IN ('%s', '%s') + OR stay_verification_id = 'be000000-0000-0000-0000-000000000099' + """.formatted(STAY_VERIFICATION_A, STAY_VERIFICATION_B)); + statement.execute(""" + DELETE FROM worker_archive + WHERE worker_id IN ('%s', '%s') + """.formatted(WORKER_A, WORKER_B)); statement.execute(""" DELETE FROM document_ocr_run WHERE ocr_run_id IN ('%s', '%s')