Skip to content

Commit

Permalink
feat: add DBAL middleware to ensure proper role is used for all queries
Browse files Browse the repository at this point in the history
Initially, we attempted to use the `wrapperClass` option to handle setting the
database role. However, we encountered issues because DBAL checks for the exact
`Connection` class type and not the interface, making it difficult to extend the
`Connection` class as needed.

We also considered overwriting the PgSQL-specific connection class (PDO variant)
to set the role upon connection. Unfortunately, this was not an option because
the class is declared as `final`, preventing us from extending it.

The next potential solution was to use an `EventSubscriber` to set the role
after the connection was established (using `postConnect`). However, this
approach is already deprecated in our version of DBAL and completely removed in
the next major release, rendering it unsuitable for us (maintainability).

Ultimately, we implemented the `SET ROLE` functionality using DBAL's middleware.
By wrapping the driver, and manually creating the `Connection` we can perform
the `SET ROLE` query before the connection is used by the application.

Runtime checks exist to ensure that the role for each database is defined.
However, validation of the actual value is done by PostgreSQL itself (it will
complain if the role does not exist).

Co-Authored-By: Rink <[email protected]>
  • Loading branch information
tomudding and rinkp committed Nov 2, 2024
1 parent f2c4154 commit f80419f
Show file tree
Hide file tree
Showing 6 changed files with 142 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ DOCTRINE_DEFAULT_HOST=postgresql
DOCTRINE_DEFAULT_PORT=5432
DOCTRINE_DEFAULT_USER=gewisdb
DOCTRINE_DEFAULT_PASSWORD=gewisdb
DOCTRINE_DEFAULT_ROLE=gewisdb
DOCTRINE_DEFAULT_DATABASE=gewisdb
DOCTRINE_REPORT_HOST=postgresql
DOCTRINE_REPORT_PORT=5432
DOCTRINE_REPORT_USER=gewisdb
DOCTRINE_REPORT_PASSWORD=gewisdb
DOCTRINE_REPORT_ROLE=gewisdb
DOCTRINE_REPORT_DATABASE=gewisdb_report

# Laminas settings
Expand Down
7 changes: 7 additions & 0 deletions config/autoload/doctrine.local.development.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

declare(strict_types=1);

use Application\Extensions\Doctrine\Middleware\SetRoleMiddleware;
use Doctrine\DBAL\Driver\PDO\PgSQL\Driver as PgSQLDriver;
use Doctrine\Persistence\Mapping\Driver\MappingDriverChain;

Expand Down Expand Up @@ -82,6 +83,9 @@ return [
// to use the default chained driver. The retrieved service name will
// be `doctrine.driver.$thisSetting`
'driver' => 'orm_default',
'middlewares' => [
SetRoleMiddleware::class,
],

// Generate proxies automatically (turn off for production)
'generate_proxies' => true,
Expand Down Expand Up @@ -128,6 +132,9 @@ return [
// to use the default chained driver. The retrieved service name will
// be `doctrine.driver.$thisSetting`
'driver' => 'orm_report',
'middlewares' => [
SetRoleMiddleware::class,
],

// Generate proxies automatically (turn off for production)
'generate_proxies' => true,
Expand Down
7 changes: 7 additions & 0 deletions config/autoload/doctrine.local.production.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

declare(strict_types=1);

use Application\Extensions\Doctrine\Middleware\SetRoleMiddleware;
use Doctrine\DBAL\Driver\PDO\PgSQL\Driver as PgSQLDriver;
use Doctrine\Persistence\Mapping\Driver\MappingDriverChain;

Expand Down Expand Up @@ -82,6 +83,9 @@ return [
// to use the default chained driver. The retrieved service name will
// be `doctrine.driver.$thisSetting`
'driver' => 'orm_default',
'middlewares' => [
SetRoleMiddleware::class,
],

// Generate proxies automatically (turn off for production)
'generate_proxies' => false,
Expand Down Expand Up @@ -128,6 +132,9 @@ return [
// to use the default chained driver. The retrieved service name will
// be `doctrine.driver.$thisSetting`
'driver' => 'orm_report',
'middlewares' => [
SetRoleMiddleware::class,
],

// Generate proxies automatically (turn off for production)
'generate_proxies' => false,
Expand Down
47 changes: 47 additions & 0 deletions module/Application/src/Extensions/Doctrine/Middleware/Driver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Application\Extensions\Doctrine\Middleware;

use Doctrine\DBAL\Driver as DriverInterface;
use Doctrine\DBAL\Driver\Connection as ConnectionInterface;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use SensitiveParameter;

use function implode;

class Driver extends AbstractDriverMiddleware
{
/**
* @param array<non-empty-string, non-empty-string> $roles
*/
public function __construct(
DriverInterface $driver,
private readonly array $roles,
private readonly bool $isPgSQL,
) {
parent::__construct($driver);
}

/**
* {@inheritDoc}
*/
public function connect(
#[SensitiveParameter]
array $params,
): ConnectionInterface {
$connection = parent::connect($params);

if (
$this->isPgSQL
&& isset($params['host'], $params['port'], $params['dbname'])
) {
$role = $this->roles[implode(':', [$params['host'], $params['port'], $params['dbname']])];

$connection->exec('SET ROLE ' . $connection->quote($role));
}

return $connection;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Application\Extensions\Doctrine\Middleware;

use Doctrine\DBAL\Driver as DriverInterface;
use Doctrine\DBAL\Driver\Middleware as MiddlewareInterface;
use RuntimeException;

use function getenv;
use function implode;

class SetRoleMiddleware implements MiddlewareInterface
{
public function wrap(DriverInterface $driver): DriverInterface
{
$isPgSQL = $driver instanceof DriverInterface\PDO\PgSQL\Driver;
if (
!$isPgSQL
&& !$driver instanceof DriverInterface\PDO\SQLite\Driver
) {
throw new RuntimeException('Expected DBAL Driver to be PDO PgSQL/Sqlite, but got ' . $driver::class);
}

$roleDefaultHost = getenv('DOCTRINE_DEFAULT_HOST');
$roleDefaultPort = getenv('DOCTRINE_DEFAULT_PORT');
$roleDefaultDB = getenv('DOCTRINE_DEFAULT_DATABASE');
$roleDefaultRole = getenv('DOCTRINE_DEFAULT_ROLE');

$roleReportHost = getenv('DOCTRINE_REPORT_HOST');
$roleReportPort = getenv('DOCTRINE_REPORT_PORT');
$roleReportDB = getenv('DOCTRINE_REPORT_DATABASE');
$roleReportRole = getenv('DOCTRINE_REPORT_ROLE');

if (
false === $roleDefaultHost
|| false === $roleDefaultPort
|| false === $roleDefaultDB
|| false === $roleDefaultRole
) {
throw new RuntimeException('Required `DOCTRINE_DEFAULT_*` environment variables not set...');
}

if (
false === $roleReportHost
|| false === $roleReportPort
|| false === $roleReportDB
|| false === $roleReportRole
) {
throw new RuntimeException('Required `DOCTRINE_REPORT_*` environment variables not set...');
}

$roles = [
implode(
':',
[
$roleDefaultHost,
$roleDefaultPort,
$roleDefaultDB,
],
) => $roleDefaultRole,
implode(
':',
[
$roleReportHost,
$roleReportPort,
$roleReportDB,
],
) => $roleReportRole,
];

return new Driver($driver, $roles, $isPgSQL);
}
}
4 changes: 4 additions & 0 deletions module/Application/src/Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Application;

use Application\Extensions\Doctrine\Middleware\SetRoleMiddleware;
use Application\Mapper\ConfigItem as ConfigItemMapper;
use Application\Mapper\Factory\ConfigItemFactory as ConfigItemMapperFactory;
use Application\Service\Config as ConfigService;
Expand Down Expand Up @@ -123,6 +124,9 @@ public function getConfig(): array
public function getServiceConfig(): array
{
return [
'invokables' => [
SetRoleMiddleware::class => SetRoleMiddleware::class,
],
'factories' => [
ConfigItemMapper::class => ConfigItemMapperFactory::class,
ConfigService::class => ConfigServiceFactory::class,
Expand Down

0 comments on commit f80419f

Please sign in to comment.