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:
2026-01-30 23:34:10 +01:00
parent 6da5996340
commit 1fd256346a
71 changed files with 14390 additions and 37 deletions

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Security;
use App\Shared\Infrastructure\Security\TenantAccessDeniedHandler;
use App\Shared\Infrastructure\Security\TenantAwareInterface;
use App\Shared\Infrastructure\Tenant\TenantId;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use RuntimeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Throwable;
/**
* Unit tests for TenantAccessDeniedHandler.
*
* CRITICAL: This handler converts 403 to 404 for cross-tenant access
* to prevent enumeration attacks (revealing resource existence).
*/
#[CoversClass(TenantAccessDeniedHandler::class)]
final class TenantAccessDeniedHandlerTest extends TestCase
{
private TenantAccessDeniedHandler $handler;
protected function setUp(): void
{
$this->handler = new TenantAccessDeniedHandler();
}
#[Test]
public function itConvertsAccessDeniedExceptionForTenantAwareResourceTo404(): void
{
// Create a TenantAware resource
$resource = $this->createMock(TenantAwareInterface::class);
$resource->method('getTenantId')
->willReturn(TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'));
// Create AccessDeniedException with TenantAware subject
$exception = new AccessDeniedException('Access Denied', null);
// Note: Symfony's AccessDeniedException doesn't have a public setSubject method,
// but it stores the subject internally. For this test, we'll use reflection.
$reflection = new ReflectionClass($exception);
$property = $reflection->getProperty('subject');
$property->setAccessible(true);
$property->setValue($exception, $resource);
$event = $this->createExceptionEvent($exception);
$this->handler->onKernelException($event);
// CRITICAL: Must return 404, not 403
self::assertTrue($event->hasResponse());
self::assertSame(Response::HTTP_NOT_FOUND, $event->getResponse()?->getStatusCode());
// Verify error message is generic (no information leakage)
$content = json_decode((string) $event->getResponse()->getContent(), true);
self::assertSame('Resource not found', $content['message']);
self::assertSame(Response::HTTP_NOT_FOUND, $content['status']);
self::assertSame('https://classeo.fr/errors/resource-not-found', $content['type']);
// Must NOT reveal tenant information
self::assertArrayNotHasKey('tenant', $content);
self::assertArrayNotHasKey('resource', $content);
}
#[Test]
public function itDoesNotModifyAccessDeniedExceptionForNonTenantAwareResources(): void
{
// Regular access denied (not tenant-related)
$exception = new AccessDeniedException('Access Denied');
$event = $this->createExceptionEvent($exception);
$this->handler->onKernelException($event);
// Should NOT set a response - let other handlers deal with it
self::assertFalse($event->hasResponse());
}
#[Test]
public function itIgnoresOtherExceptions(): void
{
// Non-AccessDeniedException
$exception = new RuntimeException('Some error');
$event = $this->createExceptionEvent($exception);
$this->handler->onKernelException($event);
// Should NOT set a response
self::assertFalse($event->hasResponse());
}
#[Test]
public function itReturns404NotRevealingResourceExistence(): void
{
// This test specifically validates the security requirement:
// 403 reveals resource exists, 404 hides it
$resource = $this->createMock(TenantAwareInterface::class);
$resource->method('getTenantId')
->willReturn(TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901'));
$exception = new AccessDeniedException('You cannot access this resource', null);
$reflection = new ReflectionClass($exception);
$property = $reflection->getProperty('subject');
$property->setAccessible(true);
$property->setValue($exception, $resource);
$event = $this->createExceptionEvent($exception);
$this->handler->onKernelException($event);
$response = $event->getResponse();
self::assertNotNull($response);
// MUST be 404 (not 403) to prevent enumeration
self::assertSame(404, $response->getStatusCode());
self::assertNotSame(403, $response->getStatusCode(), 'SECURITY: Must be 404, not 403');
}
private function createExceptionEvent(Throwable $exception): ExceptionEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create('/api/resource/123');
return new ExceptionEvent(
$kernel,
$request,
HttpKernelInterface::MAIN_REQUEST,
$exception
);
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Security;
use App\Shared\Infrastructure\Security\TenantAwareInterface;
use App\Shared\Infrastructure\Security\TenantVoter;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[CoversClass(TenantVoter::class)]
final class TenantVoterTest extends TestCase
{
private TenantContext $tenantContext;
protected function setUp(): void
{
$this->tenantContext = new TenantContext();
}
#[Test]
public function itAbstainsForNonTenantAwareSubjects(): void
{
$voter = new TenantVoter($this->tenantContext);
$token = $this->createMock(TokenInterface::class);
$subject = new stdClass();
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
}
#[Test]
public function itAbstainsForNonTenantAccessAttributes(): void
{
$tenantIdString = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
$this->setCurrentTenant($tenantIdString, 'ecole-alpha');
$voter = new TenantVoter($this->tenantContext);
$user = $this->createMock(UserInterface::class);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($user);
$subject = $this->createTenantAwareSubject($tenantIdString);
// Voter should abstain for other attributes to not bypass other voters
foreach (['VIEW', 'EDIT', 'DELETE', 'ROLE_ADMIN'] as $attribute) {
$result = $voter->vote($token, $subject, [$attribute]);
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $result, "Should abstain for: {$attribute}");
}
}
#[Test]
public function itDeniesAccessWhenUserNotAuthenticated(): void
{
$this->setCurrentTenant('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'ecole-alpha');
$voter = new TenantVoter($this->tenantContext);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn(null);
$subject = $this->createTenantAwareSubject('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
}
#[Test]
public function itGrantsAccessWhenSubjectBelongsToCurrentTenant(): void
{
$tenantIdString = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
$this->setCurrentTenant($tenantIdString, 'ecole-alpha');
$voter = new TenantVoter($this->tenantContext);
$user = $this->createMock(UserInterface::class);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($user);
$subject = $this->createTenantAwareSubject($tenantIdString);
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
self::assertSame(VoterInterface::ACCESS_GRANTED, $result);
}
#[Test]
public function itDeniesAccessWhenSubjectBelongsToDifferentTenant(): void
{
// Current tenant is alpha
$this->setCurrentTenant('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'ecole-alpha');
$voter = new TenantVoter($this->tenantContext);
$user = $this->createMock(UserInterface::class);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($user);
// Subject belongs to beta tenant
$subject = $this->createTenantAwareSubject('b2c3d4e5-f6a7-8901-bcde-f12345678901');
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
// Should be DENIED (will be converted to 404 by access denied handler)
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
}
#[Test]
public function itDeniesAccessWhenNoTenantContextSet(): void
{
// Don't set any tenant context
$voter = new TenantVoter($this->tenantContext);
$user = $this->createMock(UserInterface::class);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($user);
$subject = $this->createTenantAwareSubject('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
}
private function setCurrentTenant(string $tenantIdString, string $subdomain): void
{
$tenantId = TenantId::fromString($tenantIdString);
$config = new TenantConfig(
tenantId: $tenantId,
subdomain: $subdomain,
databaseUrl: "postgresql://user:pass@localhost:5432/classeo_{$subdomain}",
);
$this->tenantContext->setCurrentTenant($config);
}
private function createTenantAwareSubject(string $tenantIdString): TenantAwareInterface
{
$tenantId = TenantId::fromString($tenantIdString);
$subject = $this->createMock(TenantAwareInterface::class);
$subject->method('getTenantId')->willReturn($tenantId);
return $subject;
}
}