From 407d40b2e815d68869d5e603bcc472efad1fd483 Mon Sep 17 00:00:00 2001 From: DanielS Date: Tue, 18 Aug 2026 23:56:51 +0200 Subject: [PATCH] feat(db): add postgres setup and multi-tenant config --- .env.example | 7 ++ Makefile | 13 +++ docker-compose.yml | 57 +++++++++++++ docker/postgres/init/01-init-security.sql | 29 +++++++ ...emaBasedMultiTenantConnectionProvider.java | 62 ++++++++++++++ .../caspos/config/tenant/TenantContext.java | 25 ++++++ .../tenant/TenantIdentifierResolver.java | 19 +++++ src/main/resources/application.yml | 26 ++++++ .../master/V1__init_master_schema.sql | 10 +++ .../tenant/V1__init_tenant_schema.sql | 66 +++++++++++++++ .../tenant/MultiTenantIsolationTest.java | 82 +++++++++++++++++++ start-db.sh | 18 ++++ stop-db.sh | 6 ++ 13 files changed, 420 insertions(+) create mode 100644 .env.example create mode 100644 Makefile create mode 100644 docker-compose.yml create mode 100644 docker/postgres/init/01-init-security.sql create mode 100644 src/main/java/com/caspos/config/tenant/SchemaBasedMultiTenantConnectionProvider.java create mode 100644 src/main/java/com/caspos/config/tenant/TenantContext.java create mode 100644 src/main/java/com/caspos/config/tenant/TenantIdentifierResolver.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration/master/V1__init_master_schema.sql create mode 100644 src/main/resources/db/migration/tenant/V1__init_tenant_schema.sql create mode 100644 src/test/java/com/caspos/config/tenant/MultiTenantIsolationTest.java create mode 100644 start-db.sh create mode 100644 stop-db.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5861bce --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +POSTGRES_DB=smartseat_db +POSTGRES_USER=postgres_admin +POSTGRES_PASSWORD=change_me_super_secret_admin_password +POSTGRES_PORT=5432 + +POSTGRES_APP_PASSWORD=change_me_app_user_password +POSTGRES_MIGRATION_PASSWORD=change_me_migration_user_password diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e3d0e37 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: up down logs status + +up: + bash start-db.sh + +down: + bash stop-db.sh + +logs: + docker compose logs -f postgres + +status: + docker ps --filter name=caspos-postgres diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0839c4a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +version: '3.8' + +services: + # 1. PostgreSQL 16 Datenbank (Hybrid Multi-Tenant Storage) + postgres: + image: postgres:16-alpine + container_name: caspos-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-caspos_reservations} + POSTGRES_USER: ${POSTGRES_USER:-caspos_admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secretpassword} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/postgres/init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-caspos_admin} -d ${POSTGRES_DB:-caspos_reservations}"] + interval: 5s + timeout: 5s + retries: 5 + + # 2. Spring Boot 3 Java Backend (API) + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: caspos-backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-caspos_reservations} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-caspos_admin} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-secretpassword} + SPRING_PROFILES_ACTIVE: docker + ports: + - "8080:8080" + + # 3. TypeScript / React Frontend (Tischplan & UI) + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: caspos-frontend + restart: unless-stopped + depends_on: + - backend + environment: + NEXT_PUBLIC_API_URL: http://localhost:8080/api/v1 + ports: + - "3000:3000" + +volumes: + postgres_data: diff --git a/docker/postgres/init/01-init-security.sql b/docker/postgres/init/01-init-security.sql new file mode 100644 index 0000000..1b6df75 --- /dev/null +++ b/docker/postgres/init/01-init-security.sql @@ -0,0 +1,29 @@ +-- Extensions aktivieren +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Entziehe allgemeine Rechte auf PUBLIC Schema +REVOKE ALL ON SCHEMA public FROM PUBLIC; + +-- Rollen anlegen +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'app_user') THEN + CREATE ROLE app_user WITH LOGIN PASSWORD 'POSTGRES_APP_PASSWORD_PLACEHOLDER'; + END IF; + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'migration_user') THEN + CREATE ROLE migration_user WITH LOGIN PASSWORD 'POSTGRES_MIGRATION_PASSWORD_PLACEHOLDER'; + END IF; +END +$$; + +-- Schema-Rechte +GRANT USAGE, CREATE ON SCHEMA public TO migration_user; +GRANT USAGE ON SCHEMA public TO app_user; + +-- Standard-Berechtigungen für zukünftige Tabellen/Sequenzen +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO app_user; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO migration_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO migration_user; diff --git a/src/main/java/com/caspos/config/tenant/SchemaBasedMultiTenantConnectionProvider.java b/src/main/java/com/caspos/config/tenant/SchemaBasedMultiTenantConnectionProvider.java new file mode 100644 index 0000000..b5de9b5 --- /dev/null +++ b/src/main/java/com/caspos/config/tenant/SchemaBasedMultiTenantConnectionProvider.java @@ -0,0 +1,62 @@ +package com.caspos.config.tenant; + +import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; + +@Component +public class SchemaBasedMultiTenantConnectionProvider implements MultiTenantConnectionProvider { + + private final DataSource dataSource; + + public SchemaBasedMultiTenantConnectionProvider(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public Connection getAnyConnection() throws SQLException { + return dataSource.getConnection(); + } + + @Override + public void releaseAnyConnection(Connection connection) throws SQLException { + connection.close(); + } + + @Override + public Connection getConnection(String tenantIdentifier) throws SQLException { + final Connection connection = getAnyConnection(); + connection.setSchema(tenantIdentifier); + return connection; + } + + @Override + public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException { + try { + connection.setSchema("public"); + } finally { + releaseAnyConnection(connection); + } + } + + @Override + public boolean supportsAggressiveRelease() { + return true; + } + + @Override + public boolean isUnwrappableAs(Class unwrapType) { + return MultiTenantConnectionProvider.class.isAssignableFrom(unwrapType); + } + + @Override + public T unwrap(Class unwrapType) { + if (isUnwrappableAs(unwrapType)) { + return unwrapType.cast(this); + } + throw new IllegalArgumentException("Can't unwrap to " + unwrapType.getName()); + } +} diff --git a/src/main/java/com/caspos/config/tenant/TenantContext.java b/src/main/java/com/caspos/config/tenant/TenantContext.java new file mode 100644 index 0000000..53c0af1 --- /dev/null +++ b/src/main/java/com/caspos/config/tenant/TenantContext.java @@ -0,0 +1,25 @@ +package com.caspos.config.tenant; + +public final class TenantContext { + + private static final String DEFAULT_TENANT = "public"; + private static final ThreadLocal CURRENT_TENANT = ThreadLocal.withInitial(() -> DEFAULT_TENANT); + + private TenantContext() {} + + public static String getCurrentTenant() { + return CURRENT_TENANT.get(); + } + + public static void setCurrentTenant(String tenantSchema) { + if (tenantSchema == null || tenantSchema.isBlank()) { + CURRENT_TENANT.set(DEFAULT_TENANT); + } else { + CURRENT_TENANT.set(tenantSchema); + } + } + + public static void clear() { + CURRENT_TENANT.set(DEFAULT_TENANT); + } +} diff --git a/src/main/java/com/caspos/config/tenant/TenantIdentifierResolver.java b/src/main/java/com/caspos/config/tenant/TenantIdentifierResolver.java new file mode 100644 index 0000000..45598a7 --- /dev/null +++ b/src/main/java/com/caspos/config/tenant/TenantIdentifierResolver.java @@ -0,0 +1,19 @@ +package com.caspos.config.tenant; + +import org.hibernate.context.spi.CurrentTenantIdentifierResolver; +import org.springframework.stereotype.Component; + +@Component +public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver { + + @Override + public String resolveCurrentTenantIdentifier() { + String tenant = TenantContext.getCurrentTenant(); + return (tenant != null && !tenant.isBlank()) ? tenant : "public"; + } + + @Override + public boolean validateExistingCurrentSessions() { + return true; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..abd3841 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,26 @@ +spring: + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:smartseat_db} + username: ${POSTGRES_USER:postgres_admin} + password: ${POSTGRES_PASSWORD:change_me_super_secret_admin_password} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + idle-timeout: 300000 + connection-timeout: 20000 + max-lifetime: 1200000 + leak-detection-threshold: 5000 + pool-name: CasposHikariCP + + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: validate + properties: + hibernate: + multiTenancy: SCHEMA + multi_tenant_connection_provider: com.caspos.config.tenant.SchemaBasedMultiTenantConnectionProvider + tenant_identifier_resolver: com.caspos.config.tenant.TenantIdentifierResolver + format_sql: true + show_sql: false diff --git a/src/main/resources/db/migration/master/V1__init_master_schema.sql b/src/main/resources/db/migration/master/V1__init_master_schema.sql new file mode 100644 index 0000000..5bcb773 --- /dev/null +++ b/src/main/resources/db/migration/master/V1__init_master_schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(150) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + db_schema VARCHAR(100) UNIQUE NOT NULL, + api_key VARCHAR(64) UNIQUE NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); diff --git a/src/main/resources/db/migration/tenant/V1__init_tenant_schema.sql b/src/main/resources/db/migration/tenant/V1__init_tenant_schema.sql new file mode 100644 index 0000000..a29a9ad --- /dev/null +++ b/src/main/resources/db/migration/tenant/V1__init_tenant_schema.sql @@ -0,0 +1,66 @@ +-- Dining Areas +CREATE TABLE IF NOT EXISTS dining_areas ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + is_active BOOLEAN DEFAULT true, + display_order INT DEFAULT 0 +); + +-- Restaurant Tables +CREATE TABLE IF NOT EXISTS restaurant_tables ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dining_area_id UUID NOT NULL REFERENCES dining_areas(id) ON DELETE CASCADE, + table_number VARCHAR(20) NOT NULL, + min_capacity INT NOT NULL DEFAULT 1, + max_capacity INT NOT NULL, + is_combinable BOOLEAN DEFAULT true, + pos_x DOUBLE PRECISION, + pos_y DOUBLE PRECISION, + width DOUBLE PRECISION, + height DOUBLE PRECISION, + shape VARCHAR(20) CHECK (shape IN ('RECTANGLE', 'ROUND')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_restaurant_tables_number_area ON restaurant_tables(table_number, dining_area_id); + +-- Guests +CREATE TABLE IF NOT EXISTS guests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + first_name VARCHAR(100), + last_name VARCHAR(100), + phone VARCHAR(50), + email VARCHAR(255), + notes TEXT, + no_show_count INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Reservations +CREATE TABLE IF NOT EXISTS reservations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + guest_id UUID REFERENCES guests(id), + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + guest_count INT NOT NULL, + status VARCHAR(30) NOT NULL CHECK (status IN ('PENDING', 'CONFIRMED', 'ARRIVED', 'FINISHED', 'NO_SHOW', 'CANCELLED')), + special_requests TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_reservations_availability ON reservations(start_time, end_time, status); + +-- Reservation Table Assignments +CREATE TABLE IF NOT EXISTS reservation_table_assignments ( + reservation_id UUID NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + table_id UUID NOT NULL REFERENCES restaurant_tables(id) ON DELETE CASCADE, + PRIMARY KEY (reservation_id, table_id) +); + +-- Staff Users +CREATE TABLE IF NOT EXISTS staff_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + display_name VARCHAR(100) NOT NULL, + role VARCHAR(30) NOT NULL, + pin_hash VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT true +); diff --git a/src/test/java/com/caspos/config/tenant/MultiTenantIsolationTest.java b/src/test/java/com/caspos/config/tenant/MultiTenantIsolationTest.java new file mode 100644 index 0000000..0c75864 --- /dev/null +++ b/src/test/java/com/caspos/config/tenant/MultiTenantIsolationTest.java @@ -0,0 +1,82 @@ +package com.caspos.config.tenant; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Testcontainers +class MultiTenantIsolationTest { + + @Container + private static final PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("test_db") + .withUsername("test_user") + .withPassword("test_pass"); + + @BeforeEach + void setUp() throws Exception { + try (Connection conn = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()); + Statement stmt = conn.createStatement()) { + + stmt.execute("CREATE SCHEMA IF NOT EXISTS tenant_a"); + stmt.execute("CREATE SCHEMA IF NOT EXISTS tenant_b"); + + stmt.execute("CREATE TABLE tenant_a.dining_areas (id UUID PRIMARY KEY, name VARCHAR(100))"); + stmt.execute("CREATE TABLE tenant_b.dining_areas (id UUID PRIMARY KEY, name VARCHAR(100))"); + } + } + + @AfterEach + void tearDown() { + TenantContext.clear(); + } + + @Test + void testTenantDataIsolation() throws Exception { + try (Connection conn = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) { + + // Insert in Tenant A + TenantContext.setCurrentTenant("tenant_a"); + conn.setSchema(TenantContext.getCurrentTenant()); + try (Statement stmt = conn.createStatement()) { + stmt.execute("INSERT INTO dining_areas (id, name) VALUES (gen_random_uuid(), 'Main Hall A')"); + } + + // Insert in Tenant B + TenantContext.setCurrentTenant("tenant_b"); + conn.setSchema(TenantContext.getCurrentTenant()); + try (Statement stmt = conn.createStatement()) { + stmt.execute("INSERT INTO dining_areas (id, name) VALUES (gen_random_uuid(), 'Terrace B')"); + stmt.execute("INSERT INTO dining_areas (id, name) VALUES (gen_random_uuid(), 'Vip B')"); + } + + // Verify Tenant A Count + TenantContext.setCurrentTenant("tenant_a"); + conn.setSchema(TenantContext.getCurrentTenant()); + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM dining_areas")) { + rs.next(); + assertEquals(1, rs.getInt(1)); + } + + // Verify Tenant B Count + TenantContext.setCurrentTenant("tenant_b"); + conn.setSchema(TenantContext.getCurrentTenant()); + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM dining_areas")) { + rs.next(); + assertEquals(2, rs.getInt(1)); + } + } + } +} diff --git a/start-db.sh b/start-db.sh new file mode 100644 index 0000000..9ab5166 --- /dev/null +++ b/start-db.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -e + +if [ ! -f .env ]; then + echo "Keine .env vorhanden. Kopiere .env.example..." + cp .env.example .env +fi + +echo "Starte PostgreSQL Container..." +docker compose up -d + +echo "Warte auf Datenbank Healthcheck..." +until [ "`docker inspect --format='{{json .State.Health.Status}}' caspos-postgres`" == "\"healthy\"" ]; do + echo "PostgreSQL startet noch..." + sleep 2 +done + +echo "PostgreSQL ist einsatzbereit!" diff --git a/stop-db.sh b/stop-db.sh new file mode 100644 index 0000000..a254fc4 --- /dev/null +++ b/stop-db.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +echo "Stoppe PostgreSQL Container..." +docker compose down +echo "PostgreSQL gestoppt."