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.
294 lines
11 KiB
PHP
294 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Integration\Shared\Infrastructure\Tenant;
|
|
|
|
use App\Shared\Infrastructure\Security\TenantAwareInterface;
|
|
use App\Shared\Infrastructure\Security\TenantVoter;
|
|
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
|
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\Tenant\TenantMiddleware;
|
|
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
|
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
|
use Symfony\Component\Security\Core\User\UserInterface;
|
|
|
|
/**
|
|
* Cross-tenant isolation tests verifying that:
|
|
* 1. Users from tenant A cannot see data from tenant B
|
|
* 2. Subdomain mismatch results in rejection
|
|
* 3. Unknown subdomains return 404
|
|
* 4. Resource access for other tenants returns 404 (not 403)
|
|
*/
|
|
#[CoversClass(TenantMiddleware::class)]
|
|
#[CoversClass(TenantVoter::class)]
|
|
#[CoversClass(TenantResolver::class)]
|
|
final class CrossTenantIsolationTest extends TestCase
|
|
{
|
|
private const string BASE_DOMAIN = 'classeo.local';
|
|
|
|
private TenantId $tenantIdAlpha;
|
|
private TenantId $tenantIdBeta;
|
|
private TenantConfig $configAlpha;
|
|
private TenantConfig $configBeta;
|
|
private InMemoryTenantRegistry $registry;
|
|
private TenantContext $context;
|
|
private TenantResolver $resolver;
|
|
private TenantMiddleware $middleware;
|
|
private TenantVoter $voter;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->tenantIdAlpha = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
$this->tenantIdBeta = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
|
|
|
$this->configAlpha = new TenantConfig(
|
|
tenantId: $this->tenantIdAlpha,
|
|
subdomain: 'ecole-alpha',
|
|
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
|
);
|
|
$this->configBeta = new TenantConfig(
|
|
tenantId: $this->tenantIdBeta,
|
|
subdomain: 'ecole-beta',
|
|
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_beta',
|
|
);
|
|
|
|
$this->registry = new InMemoryTenantRegistry([$this->configAlpha, $this->configBeta]);
|
|
$this->context = new TenantContext();
|
|
$this->resolver = new TenantResolver($this->registry, self::BASE_DOMAIN);
|
|
$this->middleware = new TenantMiddleware($this->resolver, $this->context);
|
|
$this->voter = new TenantVoter($this->context);
|
|
}
|
|
|
|
#[Test]
|
|
public function userFromTenantACannotAccessResourceFromTenantB(): void
|
|
{
|
|
// User authenticates on tenant Alpha
|
|
$this->context->setCurrentTenant($this->configAlpha);
|
|
|
|
// Create a resource belonging to tenant Beta
|
|
$resourceFromBeta = $this->createTenantAwareResource($this->tenantIdBeta);
|
|
|
|
// Create authenticated user token
|
|
$user = $this->createMock(UserInterface::class);
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($user);
|
|
|
|
// Vote should DENY access
|
|
$result = $this->voter->vote($token, $resourceFromBeta, [TenantVoter::ATTRIBUTE]);
|
|
|
|
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function userFromTenantACanAccessOwnResources(): void
|
|
{
|
|
// User authenticates on tenant Alpha
|
|
$this->context->setCurrentTenant($this->configAlpha);
|
|
|
|
// Create a resource belonging to tenant Alpha
|
|
$resourceFromAlpha = $this->createTenantAwareResource($this->tenantIdAlpha);
|
|
|
|
// Create authenticated user token
|
|
$user = $this->createMock(UserInterface::class);
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($user);
|
|
|
|
// Vote should GRANT access
|
|
$result = $this->voter->vote($token, $resourceFromAlpha, [TenantVoter::ATTRIBUTE]);
|
|
|
|
self::assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function unknownSubdomainReturns404(): void
|
|
{
|
|
$request = Request::create('https://ecole-inexistant.classeo.local/api/dashboard');
|
|
$event = $this->createRequestEvent($request);
|
|
|
|
$this->middleware->onKernelRequest($event);
|
|
|
|
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::assertArrayNotHasKey('subdomain', $content);
|
|
self::assertArrayNotHasKey('tenant', $content);
|
|
}
|
|
|
|
#[Test]
|
|
public function validSubdomainSetsContext(): void
|
|
{
|
|
$request = Request::create('https://ecole-alpha.classeo.local/api/dashboard');
|
|
$event = $this->createRequestEvent($request);
|
|
|
|
$this->middleware->onKernelRequest($event);
|
|
|
|
self::assertFalse($event->hasResponse()); // No error response
|
|
self::assertTrue($this->context->hasTenant());
|
|
self::assertTrue($this->tenantIdAlpha->equals($this->context->getCurrentTenantId()));
|
|
}
|
|
|
|
#[Test]
|
|
public function accessToResourceFromOtherTenantReturns404NotRevealingExistence(): void
|
|
{
|
|
// This test verifies the critical security requirement:
|
|
// When denied access to a resource from another tenant,
|
|
// the response MUST be 404 (not 403) to prevent enumeration attacks.
|
|
|
|
// User is authenticated on tenant Alpha
|
|
$this->context->setCurrentTenant($this->configAlpha);
|
|
|
|
// Resource exists in tenant Beta
|
|
$resourceFromBeta = $this->createTenantAwareResource($this->tenantIdBeta);
|
|
|
|
// Create authenticated user token
|
|
$user = $this->createMock(UserInterface::class);
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($user);
|
|
|
|
// Vote should DENY (which will be converted to 404 by TenantAccessDeniedHandler)
|
|
$result = $this->voter->vote($token, $resourceFromBeta, [TenantVoter::ATTRIBUTE]);
|
|
|
|
// The voter returns DENIED, not ABSTAIN
|
|
// This ensures the access is actively denied rather than just not supported
|
|
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
|
|
|
|
// The AccessDeniedHandler will convert this to 404
|
|
// (tested separately in TenantAccessDeniedHandler tests)
|
|
}
|
|
|
|
#[Test]
|
|
public function tenantContextIsClearedOnRequestTermination(): void
|
|
{
|
|
// Set a tenant
|
|
$this->context->setCurrentTenant($this->configAlpha);
|
|
self::assertTrue($this->context->hasTenant());
|
|
|
|
// Terminate request
|
|
$this->middleware->onKernelTerminate();
|
|
|
|
// Context should be cleared
|
|
self::assertFalse($this->context->hasTenant());
|
|
}
|
|
|
|
#[Test]
|
|
public function subdomainMismatchWithAuthenticatedUserFromDifferentTenant(): void
|
|
{
|
|
// User tries to access ecole-beta.classeo.local
|
|
$request = Request::create('https://ecole-beta.classeo.local/api/dashboard');
|
|
$event = $this->createRequestEvent($request);
|
|
|
|
$this->middleware->onKernelRequest($event);
|
|
|
|
// Context should be set to Beta (the subdomain in the request)
|
|
self::assertTrue($this->context->hasTenant());
|
|
self::assertTrue($this->tenantIdBeta->equals($this->context->getCurrentTenantId()));
|
|
|
|
// If a user's JWT was issued by Alpha but they're accessing Beta,
|
|
// the authentication layer should reject the token (not tested here,
|
|
// that's handled by JWT validation which checks tenant claims)
|
|
}
|
|
|
|
#[Test]
|
|
public function reservedSubdomainsAreRejected(): void
|
|
{
|
|
$reservedSubdomains = ['www', 'api', 'admin', 'static', 'cdn', 'mail'];
|
|
|
|
foreach ($reservedSubdomains as $subdomain) {
|
|
$this->context->clear();
|
|
|
|
$request = Request::create("https://{$subdomain}.classeo.local/api/test");
|
|
$event = $this->createRequestEvent($request);
|
|
|
|
$this->middleware->onKernelRequest($event);
|
|
|
|
self::assertTrue(
|
|
$event->hasResponse(),
|
|
"Expected 404 response for reserved subdomain: {$subdomain}"
|
|
);
|
|
self::assertSame(
|
|
Response::HTTP_NOT_FOUND,
|
|
$event->getResponse()?->getStatusCode(),
|
|
"Expected 404 for reserved subdomain: {$subdomain}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[Test]
|
|
public function testWithTwoTenantsMinimum(): void
|
|
{
|
|
// This test ensures we always test with at least 2 tenants
|
|
// as per the project's critical rules
|
|
|
|
// Verify both tenants are set up
|
|
self::assertTrue($this->registry->exists('ecole-alpha'));
|
|
self::assertTrue($this->registry->exists('ecole-beta'));
|
|
|
|
// Test Alpha isolation
|
|
$this->context->setCurrentTenant($this->configAlpha);
|
|
$resourceAlpha = $this->createTenantAwareResource($this->tenantIdAlpha);
|
|
$resourceBeta = $this->createTenantAwareResource($this->tenantIdBeta);
|
|
|
|
$user = $this->createMock(UserInterface::class);
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($user);
|
|
|
|
self::assertSame(
|
|
VoterInterface::ACCESS_GRANTED,
|
|
$this->voter->vote($token, $resourceAlpha, [TenantVoter::ATTRIBUTE]),
|
|
'Alpha user should access Alpha resource'
|
|
);
|
|
self::assertSame(
|
|
VoterInterface::ACCESS_DENIED,
|
|
$this->voter->vote($token, $resourceBeta, [TenantVoter::ATTRIBUTE]),
|
|
'Alpha user should NOT access Beta resource'
|
|
);
|
|
|
|
// Test Beta isolation
|
|
$this->context->setCurrentTenant($this->configBeta);
|
|
|
|
self::assertSame(
|
|
VoterInterface::ACCESS_DENIED,
|
|
$this->voter->vote($token, $resourceAlpha, [TenantVoter::ATTRIBUTE]),
|
|
'Beta user should NOT access Alpha resource'
|
|
);
|
|
self::assertSame(
|
|
VoterInterface::ACCESS_GRANTED,
|
|
$this->voter->vote($token, $resourceBeta, [TenantVoter::ATTRIBUTE]),
|
|
'Beta user should access Beta resource'
|
|
);
|
|
}
|
|
|
|
private function createRequestEvent(Request $request): RequestEvent
|
|
{
|
|
$kernel = $this->createMock(HttpKernelInterface::class);
|
|
|
|
return new RequestEvent(
|
|
$kernel,
|
|
$request,
|
|
HttpKernelInterface::MAIN_REQUEST
|
|
);
|
|
}
|
|
|
|
private function createTenantAwareResource(TenantId $tenantId): TenantAwareInterface
|
|
{
|
|
$resource = $this->createMock(TenantAwareInterface::class);
|
|
$resource->method('getTenantId')->willReturn($tenantId);
|
|
|
|
return $resource;
|
|
}
|
|
}
|