diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..3f2bb56 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,83 @@ +name: CI + +on: + pull_request: + push: + branches: [ main, develop ] + +jobs: + run: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '8.3' + - '8.4' + coverage: ['none'] + doctrine-orm-versions: + - '^2.17' + - '^3.0' + symfony-versions: + - '6.4.*' + - '7.0.*' + include: + - description: 'Log Code Coverage' + php: '8.3' + symfony-versions: '^7.0' + doctrine-orm-versions: '^3.0' + coverage: xdebug + + name: PHP ${{ matrix.php }} Symfony ${{ matrix.symfony-versions }} Doctrine ${{ matrix.doctrine-orm-versions }} ${{ matrix.description }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: ~/.composer/cache/files + key: ${{ matrix.php }}-${{ matrix.symfony-versions }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + + - name: Add PHPUnit matcher + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Set composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache composer + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer + + - name: Update Symfony version + if: matrix.symfony-versions != '' + run: | + composer require symfony/framework-bundle:${{ matrix.symfony-versions }} --no-update --no-scripts + composer require doctrine/orm:${{ matrix.doctrine-orm-versions }} --no-update --no-scripts + composer require --dev symfony/yaml:${{ matrix.symfony-versions }} --no-update --no-scripts + + - name: Install dependencies + run: composer install + + - name: Run PHPUnit tests + run: vendor/bin/phpunit + if: matrix.coverage == 'none' + + - name: PHPUnit tests and Log Code coverage + run: vendor/bin/phpunit --coverage-clover=coverage.xml + if: matrix.coverage == 'xdebug' + + - name: Upload coverage reports to Codecov + if: matrix.coverage == 'xdebug' + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml new file mode 100644 index 0000000..248d5d0 --- /dev/null +++ b/.github/workflows/security.yaml @@ -0,0 +1,24 @@ +on: + pull_request: + push: + branches: [ main, develop ] + +jobs: + security-checker: + name: Security checker + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Download local-php-security-checker + run: curl -s -L -o local-php-security-checker https://github.com/fabpot/local-php-security-checker/releases/download/v1.0.0/local-php-security-checker_1.0.0_linux_amd64 + + - name: Run local-php-security-checker + run: chmod +x local-php-security-checker && ./local-php-security-checker diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml new file mode 100644 index 0000000..8796ae1 --- /dev/null +++ b/.github/workflows/static-analysis.yaml @@ -0,0 +1,55 @@ +name: Code style and static analysis + +on: + pull_request: + push: + branches: [ main, develop ] + +jobs: + php-cs-fixer: + name: PHP-CS-Fixer + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Run script + run: vendor/bin/phpcs + + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Run script + run: vendor/bin/phpstan analyse + + composer-validate: + name: Composer validate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Run script + run: composer composer-validate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1473148 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.idea +/vendor/ +/composer.lock +/.phpcs-cache + +###> phpunit/phpunit ### +/phpunit.xml +.phpunit.result.cache +###< phpunit/phpunit ### diff --git a/README.md b/README.md index 948c599..502c9da 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,71 @@ -# postgres-schema-bundle -Postgres schema bundle +# Postgres Schema Bundle +The **Postgres Schema Bundle** provides seamless multi-tenant schema support for PostgreSQL within Symfony applications. It automatically switches PostgreSQL `search_path` based on the current request context and ensures proper schema resolution across Doctrine and Messenger.## Installation + +## Features + +- Automatically sets PostgreSQL `search_path` from request headers. +- Validates that the schema exists in the database. +- Works only if the configured database driver is PostgreSQL. +- Integrates with [Schema Context Bundle](https://github.com/macpaw/schema-context-bundle). +- Compatible with Symfony Messenger and Doctrine ORM. + +## Installation +Use Composer to install the bundle: +``` +composer require macpaw/postgres-schema-bundle +``` + +### Applications that don't use Symfony Flex +Enable the bundle by adding it to the list of registered bundles in ```config/bundles.php``` + +``` +// config/bundles.php + ['all' => true], + Macpaw\SchemaContextBundle\PostgresSchemaBundle::class => ['all' => true], + // ... + ]; +``` + +## Configuration + +You must tell Doctrine to use the SchemaConnection class as its DBAL connection class: + +```yaml +# config/packages/doctrine.yaml +doctrine: + dbal: + connections: + default: + wrapper_class: Macpaw\PostgresSchemaBundle\Doctrine\SchemaConnection +``` +Make sure you configure the context bundle properly: + +See https://github.com/MacPaw/schema-context-bundle/blob/develop/README.md + +```yaml +schema_context: + app_name: '%env(APP_NAME)%' # Application name + header_name: 'X-Tenant' # Request header to extract schema name + default_schema: 'public' # Default schema to fallback to + allowed_app_names: ['develop', 'staging', 'test'] # App names where schema context is allowed to change +``` + +## How it Works +* A request comes in with a header like X-Tenant-Id: tenant123. +* The SchemaRequestListener sets this schema in the context. +* When Doctrine connects to PostgreSQL, it sets the search_path to the specified schema. +* If the schema does not exist or DB is not PostgreSQL, an exception is thrown. + +## Testing +To run tests: +```bash +vendor/bin/phpunit +``` +## Contributing +Feel free to open issues and submit pull requests. + +## License +This bundle is released under the MIT license. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0b6878f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting Security Issues +If you believe you have found a security vulnerability in any MacPaw-owned repository, please report it to us through coordinated disclosure. + +Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests. + +Instead, please send an email to security[@]macpaw.com. + +Please include as much of the information listed below as you can to help us better understand and resolve the issue: + +- The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Policy +See MacPaw's [Vulnerability Disclosure Policy](https://macpaw.com/vulnerability-disclosure-policy) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..68e4f78 --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "macpaw/postgres-schema-bundle", + "description": "A Symfony bundle to add schema wrapper for postgres", + "type": "symfony-bundle", + "license": "MIT", + "autoload": { + "psr-4": { + "Macpaw\\PostgresSchemaBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Macpaw\\PostgresSchemaBundle\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.3", + "doctrine/orm": "^2.17 || ^3.0", + "symfony/doctrine-bridge": "^6.4 || ^7.0", + "doctrine/dbal": "^3.4", + "macpaw/schema-context-bundle": "^1.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "3.7.*" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "scripts": { + "composer-validate": [ + "composer validate" + ], + "cs": [ + "vendor/bin/phpcs" + ], + "cs-fix": [ + "vendor/bin/phpcbf" + ], + "phpstan": [ + "vendor/bin/phpstan analyse" + ], + "phpunit": [ + "vendor/bin/phpunit" + ] + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..d89bc03 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + src/ + tests/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..776ccd8 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: max + paths: + - src diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..6344d8d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + ./tests + + + + + + + + + + ./src + + + diff --git a/src/Doctrine/SchemaConnection.php b/src/Doctrine/SchemaConnection.php new file mode 100644 index 0000000..e7982ac --- /dev/null +++ b/src/Doctrine/SchemaConnection.php @@ -0,0 +1,56 @@ +getSchema(); + + if (!$schema) { + return $connection; + } + + $this->ensurePostgreSql(); + $this->applySearchPath($schema); + + return $connection; + } + + private function ensurePostgreSql(): void + { + $platform = $this->getDatabasePlatform(); + + if (!$platform instanceof PostgreSQLPlatform) { + throw new UnsupportedPlatformException(get_class($platform)); + } + } + + private function applySearchPath(string $schema): void + { + if ($this->_conn !== null) { + $this->_conn->exec('SET search_path TO ' . $schema); + } + } +} diff --git a/src/Exception/UnsupportedPlatformException.php b/src/Exception/UnsupportedPlatformException.php new file mode 100644 index 0000000..9fb006e --- /dev/null +++ b/src/Exception/UnsupportedPlatformException.php @@ -0,0 +1,15 @@ +createMock(DriverConnection::class); + + $driverConnection->expects($this->once()) + ->method('exec') + ->with('SET search_path TO test_schema'); + + $driver = $this->createMock(Driver::class); + + $driver->method('connect')->willReturn($driverConnection); + + $platform = new PostgreSQLPlatform(); + $connection = $this->getMockBuilder(SchemaConnection::class) + ->setConstructorArgs([[], $driver, new Configuration(), new EventManager()]) + ->onlyMethods(['getDatabasePlatform', 'fetchOne']) + ->getMock(); + + $connection->method('getDatabasePlatform')->willReturn($platform); + $connection->method('fetchOne')->willReturn(true); + + $resolver = new SchemaResolver(); + + $resolver->setSchema('test_schema'); + + SchemaConnection::setSchemaResolver($resolver); + + $result = $connection->connect(); + + self::assertTrue($result); + } + + public function testConnectSkipsWhenNoSchema(): void + { + $driverConnection = $this->createMock(DriverConnection::class); + + $driver = $this->createMock(Driver::class); + + $driver->method('connect')->willReturn($driverConnection); + + $connection = new SchemaConnection([], $driver, new Configuration()); + $resolver = new SchemaResolver(); + + SchemaConnection::setSchemaResolver($resolver); + + self::assertTrue($connection->connect()); + } + + public function testThrowsForUnsupportedPlatform(): void + { + $this->expectException(UnsupportedPlatformException::class); + + $driverConnection = $this->createMock(DriverConnection::class); + $driver = $this->createMock(Driver::class); + + $driver->method('connect')->willReturn($driverConnection); + + $platform = new MySQLPlatform(); + $connection = $this->getMockBuilder(SchemaConnection::class) + ->setConstructorArgs([[], $driver, new Configuration(), new EventManager()]) + ->onlyMethods(['getDatabasePlatform']) + ->getMock(); + + $connection->method('getDatabasePlatform')->willReturn($platform); + + $resolver = new SchemaResolver(); + + $resolver->setSchema('test_schema'); + + SchemaConnection::setSchemaResolver($resolver); + + $connection->connect(); + } +}