feat: Infrastructure multi-tenant avec isolation par sous-domaine
Une application SaaS éducative nécessite une séparation stricte des données entre établissements scolaires. L'architecture multi-tenant par sous-domaine (ecole-alpha.classeo.local) permet cette isolation tout en utilisant une base de code unique. Le choix d'une résolution basée sur les sous-domaines plutôt que sur des headers ou tokens facilite le routage au niveau infrastructure (reverse proxy) et offre une UX plus naturelle où chaque école accède à "son" URL dédiée.
This commit is contained in:
0
backend/src/Controller/.gitignore
vendored
Normal file
0
backend/src/Controller/.gitignore
vendored
Normal file
0
backend/src/Entity/.gitignore
vendored
Normal file
0
backend/src/Entity/.gitignore
vendored
Normal file
0
backend/src/Repository/.gitignore
vendored
Normal file
0
backend/src/Repository/.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Security;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
/**
|
||||
* Converts AccessDeniedException for TenantAware resources to 404 responses.
|
||||
*
|
||||
* CRITICAL SECURITY: This prevents information leakage about resource existence.
|
||||
* When a user is denied access to a resource in another tenant, we return 404
|
||||
* instead of 403 to avoid revealing that the resource exists.
|
||||
*/
|
||||
final readonly class TenantAccessDeniedHandler implements EventSubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::EXCEPTION => ['onKernelException', 2], // Higher priority than default
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
$exception = $event->getThrowable();
|
||||
|
||||
if (!$exception instanceof AccessDeniedException) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a TenantAware resource denial
|
||||
// The subject is stored in the exception's attributes
|
||||
$subject = $exception->getSubject();
|
||||
|
||||
if ($subject instanceof TenantAwareInterface) {
|
||||
// Convert to 404 to hide resource existence
|
||||
$response = new JsonResponse(
|
||||
[
|
||||
'status' => Response::HTTP_NOT_FOUND,
|
||||
'message' => 'Resource not found',
|
||||
'type' => 'https://classeo.fr/errors/resource-not-found',
|
||||
],
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
|
||||
$event->setResponse($response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Security;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Interface for domain objects that belong to a specific tenant.
|
||||
* Used by TenantVoter to enforce cross-tenant access control.
|
||||
*/
|
||||
interface TenantAwareInterface
|
||||
{
|
||||
public function getTenantId(): TenantId;
|
||||
}
|
||||
73
backend/src/Shared/Infrastructure/Security/TenantVoter.php
Normal file
73
backend/src/Shared/Infrastructure/Security/TenantVoter.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Security;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotSetException;
|
||||
use Override;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Voter that enforces tenant isolation at the authorization level.
|
||||
*
|
||||
* CRITICAL SECURITY: This voter is the second line of defense (after TenantMiddleware).
|
||||
* It prevents authenticated users from accessing resources belonging to other tenants.
|
||||
*
|
||||
* IMPORTANT: This voter ONLY handles the TENANT_ACCESS attribute to avoid interfering
|
||||
* with other authorization checks (ROLE_*, EDIT, DELETE, etc.). Use isGranted('TENANT_ACCESS', $entity)
|
||||
* explicitly when you need to verify tenant ownership.
|
||||
*
|
||||
* Returns ACCESS_DENIED (which is converted to 404 by AccessDeniedHandler) to avoid
|
||||
* revealing the existence of resources in other tenants.
|
||||
*
|
||||
* @extends Voter<string, TenantAwareInterface>
|
||||
*/
|
||||
final class TenantVoter extends Voter
|
||||
{
|
||||
public const string ATTRIBUTE = 'TENANT_ACCESS';
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantContext $tenantContext,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
// Only vote on TENANT_ACCESS attribute to avoid bypassing other voters
|
||||
return $attribute === self::ATTRIBUTE && $subject instanceof TenantAwareInterface;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
// User must be authenticated
|
||||
if (!$user instanceof UserInterface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Subject must be tenant-aware (should always be true due to supports())
|
||||
if (!$subject instanceof TenantAwareInterface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tenant context must be set
|
||||
try {
|
||||
$currentTenantId = $this->tenantContext->getCurrentTenantId();
|
||||
} catch (TenantNotSetException) {
|
||||
// No tenant context - deny access
|
||||
return false;
|
||||
}
|
||||
|
||||
// CRITICAL: Verify resource belongs to current tenant
|
||||
// If resource belongs to different tenant, return false (-> 404)
|
||||
return $subject->getTenantId()->equals($currentTenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant\Command;
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use Override;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'tenant:database:create',
|
||||
description: 'Creates a new database for a tenant'
|
||||
)]
|
||||
final class CreateTenantDatabaseCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $masterDatabaseUrl,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('database_name', InputArgument::REQUIRED, 'The name of the database to create (e.g., classeo_tenant_alpha)')
|
||||
->addArgument('database_user', InputArgument::OPTIONAL, 'The database user to grant access to', 'classeo')
|
||||
;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
/** @var string $databaseName */
|
||||
$databaseName = $input->getArgument('database_name');
|
||||
/** @var string $databaseUser */
|
||||
$databaseUser = $input->getArgument('database_user');
|
||||
|
||||
// Validate database name format
|
||||
if (!preg_match('/^classeo_tenant_[a-z0-9_]+$/', $databaseName)) {
|
||||
$io->error('Database name must follow pattern: classeo_tenant_<name>');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title("Creating database: {$databaseName}");
|
||||
|
||||
try {
|
||||
// Connect to master database (postgres) to create new database
|
||||
$connection = DriverManager::getConnection(['url' => $this->masterDatabaseUrl]);
|
||||
|
||||
// Check if database already exists
|
||||
$existsQuery = $connection->executeQuery(
|
||||
'SELECT 1 FROM pg_database WHERE datname = :name',
|
||||
['name' => $databaseName]
|
||||
);
|
||||
|
||||
if ($existsQuery->fetchOne() !== false) {
|
||||
$io->warning("Database '{$databaseName}' already exists.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Create database
|
||||
// Note: Database names cannot be parameterized in SQL, so we use a validated name
|
||||
$connection->executeStatement(sprintf(
|
||||
'CREATE DATABASE %s WITH OWNER = %s ENCODING = \'UTF8\' LC_COLLATE = \'en_US.utf8\' LC_CTYPE = \'en_US.utf8\'',
|
||||
$this->quoteIdentifier($databaseName),
|
||||
$this->quoteIdentifier($databaseUser)
|
||||
));
|
||||
|
||||
$io->success("Database '{$databaseName}' created successfully.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$io->error([
|
||||
'Failed to create database',
|
||||
$e->getMessage(),
|
||||
]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function quoteIdentifier(string $identifier): string
|
||||
{
|
||||
// Simple identifier quoting for PostgreSQL
|
||||
return '"' . str_replace('"', '""', $identifier) . '"';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant\Command;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
|
||||
use function count;
|
||||
|
||||
use Override;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'tenant:migrate',
|
||||
description: 'Run migrations for a specific tenant or all tenants'
|
||||
)]
|
||||
final class TenantMigrateCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantRegistry $registry,
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('subdomain', InputArgument::OPTIONAL, 'The subdomain of the tenant to migrate (or "all" for all tenants)')
|
||||
->addOption('all', 'a', InputOption::VALUE_NONE, 'Run migrations for all tenants')
|
||||
;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
/** @var string|null $subdomain */
|
||||
$subdomain = $input->getArgument('subdomain');
|
||||
$all = $input->getOption('all');
|
||||
|
||||
if ($all || $subdomain === 'all') {
|
||||
return $this->migrateAllTenants($io);
|
||||
}
|
||||
|
||||
if ($subdomain === null) {
|
||||
$io->error('Please provide a subdomain or use --all to migrate all tenants.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return $this->migrateTenant($subdomain, $io);
|
||||
}
|
||||
|
||||
private function migrateTenant(string $subdomain, SymfonyStyle $io): int
|
||||
{
|
||||
try {
|
||||
$config = $this->registry->getBySubdomain($subdomain);
|
||||
|
||||
return $this->runMigrationForConfig($config, $io);
|
||||
} catch (Throwable $e) {
|
||||
$io->error([
|
||||
"Failed to migrate tenant '{$subdomain}'",
|
||||
$e->getMessage(),
|
||||
]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function migrateAllTenants(SymfonyStyle $io): int
|
||||
{
|
||||
$io->title('Running migrations for all tenants');
|
||||
|
||||
$configs = $this->registry->getAllConfigs();
|
||||
|
||||
if ($configs === []) {
|
||||
$io->warning('No tenants found in registry.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->info(sprintf('Found %d tenant(s) to migrate.', count($configs)));
|
||||
|
||||
$failed = 0;
|
||||
foreach ($configs as $config) {
|
||||
$result = $this->runMigrationForConfig($config, $io);
|
||||
if ($result !== Command::SUCCESS) {
|
||||
++$failed;
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed > 0) {
|
||||
$io->error(sprintf('%d tenant(s) failed to migrate.', $failed));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success('All tenants migrated successfully.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function runMigrationForConfig(TenantConfig $config, SymfonyStyle $io): int
|
||||
{
|
||||
$io->section("Migrating tenant: {$config->subdomain}");
|
||||
|
||||
// Spawn a new process with DATABASE_URL set BEFORE the kernel boots.
|
||||
// This ensures Doctrine uses the tenant's database connection.
|
||||
$process = new Process(
|
||||
command: ['php', 'bin/console', 'doctrine:migrations:migrate', '--no-interaction'],
|
||||
cwd: $this->projectDir,
|
||||
env: [
|
||||
...getenv(),
|
||||
'DATABASE_URL' => $config->databaseUrl,
|
||||
],
|
||||
timeout: 300,
|
||||
);
|
||||
|
||||
$process->run(static function (string $type, string $buffer) use ($io): void {
|
||||
$io->write($buffer);
|
||||
});
|
||||
|
||||
if ($process->isSuccessful()) {
|
||||
$io->success("Tenant '{$config->subdomain}' migrated successfully.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->error("Migration failed for tenant '{$config->subdomain}'");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* In-memory implementation of TenantRegistry.
|
||||
* Useful for tests and development environments.
|
||||
*/
|
||||
final class InMemoryTenantRegistry implements TenantRegistry
|
||||
{
|
||||
/** @var array<string, TenantConfig> Indexed by tenant ID */
|
||||
private array $byId = [];
|
||||
|
||||
/** @var array<string, TenantConfig> Indexed by subdomain */
|
||||
private array $bySubdomain = [];
|
||||
|
||||
/**
|
||||
* @param TenantConfig[] $configs
|
||||
*/
|
||||
public function __construct(array $configs)
|
||||
{
|
||||
foreach ($configs as $config) {
|
||||
$this->byId[(string) $config->tenantId] = $config;
|
||||
$this->bySubdomain[$config->subdomain] = $config;
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getConfig(TenantId $tenantId): TenantConfig
|
||||
{
|
||||
$key = (string) $tenantId;
|
||||
|
||||
if (!isset($this->byId[$key])) {
|
||||
throw TenantNotFoundException::withId($tenantId);
|
||||
}
|
||||
|
||||
return $this->byId[$key];
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getBySubdomain(string $subdomain): TenantConfig
|
||||
{
|
||||
if (!isset($this->bySubdomain[$subdomain])) {
|
||||
throw TenantNotFoundException::withSubdomain($subdomain);
|
||||
}
|
||||
|
||||
return $this->bySubdomain[$subdomain];
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function exists(string $subdomain): bool
|
||||
{
|
||||
return isset($this->bySubdomain[$subdomain]);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getAllConfigs(): array
|
||||
{
|
||||
return array_values($this->byId);
|
||||
}
|
||||
}
|
||||
15
backend/src/Shared/Infrastructure/Tenant/TenantConfig.php
Normal file
15
backend/src/Shared/Infrastructure/Tenant/TenantConfig.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
final readonly class TenantConfig
|
||||
{
|
||||
public function __construct(
|
||||
public TenantId $tenantId,
|
||||
public string $subdomain,
|
||||
public string $databaseUrl,
|
||||
) {
|
||||
}
|
||||
}
|
||||
43
backend/src/Shared/Infrastructure/Tenant/TenantContext.php
Normal file
43
backend/src/Shared/Infrastructure/Tenant/TenantContext.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
final class TenantContext
|
||||
{
|
||||
private ?TenantConfig $currentTenant = null;
|
||||
|
||||
public function setCurrentTenant(TenantConfig $config): void
|
||||
{
|
||||
$this->currentTenant = $config;
|
||||
}
|
||||
|
||||
public function getCurrentTenantId(): TenantId
|
||||
{
|
||||
if ($this->currentTenant === null) {
|
||||
throw new TenantNotSetException('No tenant is set in the current context.');
|
||||
}
|
||||
|
||||
return $this->currentTenant->tenantId;
|
||||
}
|
||||
|
||||
public function getCurrentTenantConfig(): TenantConfig
|
||||
{
|
||||
if ($this->currentTenant === null) {
|
||||
throw new TenantNotSetException('No tenant is set in the current context.');
|
||||
}
|
||||
|
||||
return $this->currentTenant;
|
||||
}
|
||||
|
||||
public function hasTenant(): bool
|
||||
{
|
||||
return $this->currentTenant !== null;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->currentTenant = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use Doctrine\ORM\Configuration;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final class TenantEntityManagerFactory
|
||||
{
|
||||
private const MAX_MANAGERS = 50;
|
||||
private const IDLE_TIMEOUT_SECONDS = 300; // 5 minutes
|
||||
|
||||
/** @var array<string, array{manager: EntityManagerInterface, lastUsed: DateTimeImmutable}> */
|
||||
private array $managers = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantRegistry $registry,
|
||||
private readonly Clock $clock,
|
||||
private readonly Configuration $ormConfiguration,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getForTenant(TenantId $tenantId): EntityManagerInterface
|
||||
{
|
||||
$key = (string) $tenantId;
|
||||
|
||||
// Evict idle connections first
|
||||
$this->evictIdleConnections();
|
||||
|
||||
// Evict LRU if pool is full and we need a new manager
|
||||
if (!isset($this->managers[$key]) && count($this->managers) >= self::MAX_MANAGERS) {
|
||||
$this->evictLeastRecentlyUsed();
|
||||
}
|
||||
|
||||
if (!isset($this->managers[$key])) {
|
||||
$this->managers[$key] = [
|
||||
'manager' => $this->createManagerForTenant($tenantId),
|
||||
'lastUsed' => $this->clock->now(),
|
||||
];
|
||||
} else {
|
||||
// Health check before returning cached manager (AC4 requirement)
|
||||
$manager = $this->managers[$key]['manager'];
|
||||
if (!$manager->isOpen() || !$manager->getConnection()->isConnected()) {
|
||||
// Connection is dead, recreate the manager
|
||||
$this->closeAndRemove($key);
|
||||
$this->managers[$key] = [
|
||||
'manager' => $this->createManagerForTenant($tenantId),
|
||||
'lastUsed' => $this->clock->now(),
|
||||
];
|
||||
} else {
|
||||
$this->managers[$key]['lastUsed'] = $this->clock->now();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->managers[$key]['manager'];
|
||||
}
|
||||
|
||||
public function getPoolSize(): int
|
||||
{
|
||||
return count($this->managers);
|
||||
}
|
||||
|
||||
private function evictLeastRecentlyUsed(): void
|
||||
{
|
||||
if (empty($this->managers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$oldestKey = null;
|
||||
$oldestTime = null;
|
||||
|
||||
foreach ($this->managers as $key => $data) {
|
||||
if ($oldestTime === null || $data['lastUsed'] < $oldestTime) {
|
||||
$oldestKey = $key;
|
||||
$oldestTime = $data['lastUsed'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($oldestKey !== null) {
|
||||
$this->closeAndRemove($oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
private function evictIdleConnections(): void
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
$keysToRemove = [];
|
||||
|
||||
foreach ($this->managers as $key => $data) {
|
||||
$idleSeconds = $now->getTimestamp() - $data['lastUsed']->getTimestamp();
|
||||
if ($idleSeconds > self::IDLE_TIMEOUT_SECONDS) {
|
||||
$keysToRemove[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($keysToRemove as $key) {
|
||||
$this->closeAndRemove($key);
|
||||
}
|
||||
}
|
||||
|
||||
private function closeAndRemove(string $key): void
|
||||
{
|
||||
if (isset($this->managers[$key])) {
|
||||
$manager = $this->managers[$key]['manager'];
|
||||
if ($manager->isOpen()) {
|
||||
$manager->close();
|
||||
}
|
||||
unset($this->managers[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
private function createManagerForTenant(TenantId $tenantId): EntityManagerInterface
|
||||
{
|
||||
$config = $this->registry->getConfig($tenantId);
|
||||
|
||||
// Parse database URL and create connection parameters
|
||||
$connectionParams = $this->parseConnectionParams($config->databaseUrl);
|
||||
|
||||
// Health check before creation
|
||||
/** @phpstan-ignore argument.type (Doctrine accepts both URL and explicit params) */
|
||||
$connection = DriverManager::getConnection($connectionParams);
|
||||
$this->healthCheck($connection);
|
||||
|
||||
return new EntityManager($connection, $this->ormConfiguration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function parseConnectionParams(string $databaseUrl): array
|
||||
{
|
||||
// Handle SQLite in-memory specially
|
||||
if (str_starts_with($databaseUrl, 'sqlite:///:memory:')) {
|
||||
return [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'memory' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// For other databases, use URL parameter
|
||||
return ['url' => $databaseUrl];
|
||||
}
|
||||
|
||||
private function healthCheck(\Doctrine\DBAL\Connection $connection): void
|
||||
{
|
||||
// Verify the database is accessible by executing a simple query
|
||||
// This implicitly connects and validates the connection
|
||||
$connection->executeQuery('SELECT 1');
|
||||
}
|
||||
}
|
||||
11
backend/src/Shared/Infrastructure/Tenant/TenantId.php
Normal file
11
backend/src/Shared/Infrastructure/Tenant/TenantId.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class TenantId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
final readonly class TenantMiddleware implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private TenantResolver $resolver,
|
||||
private TenantContext $context,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 256], // High priority - run early
|
||||
KernelEvents::TERMINATE => 'onKernelTerminate',
|
||||
];
|
||||
}
|
||||
|
||||
private const array PUBLIC_PATHS = [
|
||||
'/api/docs',
|
||||
'/api/docs.json',
|
||||
'/api/docs.jsonld',
|
||||
'/api/contexts',
|
||||
'/_profiler',
|
||||
'/_wdt',
|
||||
'/_error',
|
||||
];
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
$path = $request->getPathInfo();
|
||||
|
||||
// Skip tenant resolution for public paths (docs, profiler, etc.)
|
||||
foreach (self::PUBLIC_PATHS as $publicPath) {
|
||||
if (str_starts_with($path, $publicPath)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$host = $request->getHost();
|
||||
|
||||
try {
|
||||
$config = $this->resolver->resolve($host);
|
||||
$this->context->setCurrentTenant($config);
|
||||
|
||||
// Store tenant config in request for easy access
|
||||
$request->attributes->set('_tenant', $config);
|
||||
} catch (TenantNotFoundException) {
|
||||
// Return 404 with generic message - DO NOT reveal tenant existence
|
||||
$response = new JsonResponse(
|
||||
[
|
||||
'status' => Response::HTTP_NOT_FOUND,
|
||||
'message' => 'Resource not found',
|
||||
'type' => 'https://classeo.fr/errors/resource-not-found',
|
||||
],
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
|
||||
$event->setResponse($response);
|
||||
}
|
||||
}
|
||||
|
||||
public function onKernelTerminate(): void
|
||||
{
|
||||
$this->context->clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class TenantNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function withSubdomain(string $subdomain): self
|
||||
{
|
||||
return new self(sprintf('Tenant with subdomain "%s" not found.', $subdomain));
|
||||
}
|
||||
|
||||
public static function withId(TenantId $tenantId): self
|
||||
{
|
||||
return new self(sprintf('Tenant with ID "%s" not found.', $tenantId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
use LogicException;
|
||||
|
||||
final class TenantNotSetException extends LogicException
|
||||
{
|
||||
}
|
||||
34
backend/src/Shared/Infrastructure/Tenant/TenantRegistry.php
Normal file
34
backend/src/Shared/Infrastructure/Tenant/TenantRegistry.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
interface TenantRegistry
|
||||
{
|
||||
/**
|
||||
* Retrieves the tenant configuration for the given tenant ID.
|
||||
*
|
||||
* @throws TenantNotFoundException if tenant does not exist
|
||||
*/
|
||||
public function getConfig(TenantId $tenantId): TenantConfig;
|
||||
|
||||
/**
|
||||
* Retrieves the tenant configuration for the given subdomain.
|
||||
*
|
||||
* @throws TenantNotFoundException if tenant does not exist
|
||||
*/
|
||||
public function getBySubdomain(string $subdomain): TenantConfig;
|
||||
|
||||
/**
|
||||
* Checks if a tenant with the given subdomain exists.
|
||||
*/
|
||||
public function exists(string $subdomain): bool;
|
||||
|
||||
/**
|
||||
* Retrieves all tenant configurations.
|
||||
*
|
||||
* @return TenantConfig[]
|
||||
*/
|
||||
public function getAllConfigs(): array;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
use function is_array;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
/**
|
||||
* Factory to create TenantRegistry from configuration arrays.
|
||||
* Used for dev/test (YAML config) and prod (env var JSON).
|
||||
*/
|
||||
final readonly class TenantRegistryFactory
|
||||
{
|
||||
/**
|
||||
* Creates registry from YAML configuration array (dev/test).
|
||||
*
|
||||
* @param array<array{tenantId: string, subdomain: string, databaseUrl: string}> $configs
|
||||
*/
|
||||
public function createFromConfig(array $configs): TenantRegistry
|
||||
{
|
||||
return new InMemoryTenantRegistry($this->parseConfigs($configs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates registry from JSON environment variable (prod).
|
||||
*
|
||||
* Expected format: [{"tenantId":"uuid","subdomain":"ecole","databaseUrl":"postgres://..."}]
|
||||
*/
|
||||
public function createFromEnv(string $configsJson): TenantRegistry
|
||||
{
|
||||
if ($configsJson === '') {
|
||||
return new InMemoryTenantRegistry([]);
|
||||
}
|
||||
|
||||
$decoded = json_decode($configsJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
throw new InvalidArgumentException('TENANT_CONFIGS must be a JSON array');
|
||||
}
|
||||
|
||||
/** @var array<array{tenantId: string, subdomain: string, databaseUrl: string}> $configs */
|
||||
$configs = $decoded;
|
||||
|
||||
return new InMemoryTenantRegistry($this->parseConfigs($configs));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array{tenantId: string, subdomain: string, databaseUrl: string}> $configs
|
||||
*
|
||||
* @return TenantConfig[]
|
||||
*/
|
||||
private function parseConfigs(array $configs): array
|
||||
{
|
||||
$tenantConfigs = [];
|
||||
|
||||
foreach ($configs as $config) {
|
||||
$tenantConfigs[] = new TenantConfig(
|
||||
tenantId: TenantId::fromString($config['tenantId']),
|
||||
subdomain: $config['subdomain'],
|
||||
databaseUrl: $config['databaseUrl'],
|
||||
);
|
||||
}
|
||||
|
||||
return $tenantConfigs;
|
||||
}
|
||||
}
|
||||
66
backend/src/Shared/Infrastructure/Tenant/TenantResolver.php
Normal file
66
backend/src/Shared/Infrastructure/Tenant/TenantResolver.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
use function in_array;
|
||||
use function strlen;
|
||||
|
||||
readonly class TenantResolver
|
||||
{
|
||||
private const array RESERVED_SUBDOMAINS = ['www', 'api', 'admin', 'static', 'cdn', 'mail'];
|
||||
|
||||
public function __construct(
|
||||
private TenantRegistry $registry,
|
||||
private string $baseDomain,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a tenant from a host header value.
|
||||
*
|
||||
* @throws TenantNotFoundException if tenant cannot be resolved
|
||||
*/
|
||||
public function resolve(string $host): TenantConfig
|
||||
{
|
||||
$subdomain = $this->extractSubdomain($host);
|
||||
|
||||
if ($subdomain === null) {
|
||||
throw TenantNotFoundException::withSubdomain('');
|
||||
}
|
||||
|
||||
return $this->registry->getBySubdomain($subdomain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the subdomain from a host header.
|
||||
* Returns null if no tenant subdomain is present (main domain or reserved subdomain).
|
||||
*/
|
||||
public function extractSubdomain(string $host): ?string
|
||||
{
|
||||
// Remove port if present
|
||||
$host = explode(':', $host)[0];
|
||||
|
||||
// Check if host ends with base domain
|
||||
$baseDomain = '.' . $this->baseDomain;
|
||||
if (!str_ends_with($host, $baseDomain)) {
|
||||
// Host doesn't match our domain - could be the base domain itself
|
||||
if ($host === $this->baseDomain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract subdomain
|
||||
$subdomain = substr($host, 0, -strlen($baseDomain));
|
||||
|
||||
// Empty subdomain or reserved
|
||||
if ($subdomain === '' || in_array($subdomain, self::RESERVED_SUBDOMAINS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $subdomain;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user