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:
24
Makefile
24
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help up down build logs ps test lint phpstan cs-fix frontend-lint frontend-test e2e clean
|
||||
.PHONY: help up down restart rebuild logs ps test lint phpstan arch cs-fix warmup frontend-lint frontend-test e2e clean
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -7,6 +7,8 @@ help:
|
||||
@echo "Docker:"
|
||||
@echo " make up - Lancer tous les services"
|
||||
@echo " make down - Arreter tous les services"
|
||||
@echo " make restart - Redemarrer tous les services"
|
||||
@echo " make rebuild - Reconstruire et relancer les services"
|
||||
@echo " make build - Reconstruire les images"
|
||||
@echo " make logs - Voir les logs (Ctrl+C pour quitter)"
|
||||
@echo " make ps - Statut des services"
|
||||
@@ -14,8 +16,10 @@ help:
|
||||
@echo ""
|
||||
@echo "Backend:"
|
||||
@echo " make phpstan - Analyse statique PHPStan"
|
||||
@echo " make arch - Tests d'architecture (PHPat)"
|
||||
@echo " make cs-fix - Correction code style PHP"
|
||||
@echo " make test-php - Tests PHPUnit"
|
||||
@echo " make warmup - Warmup du cache Symfony"
|
||||
@echo ""
|
||||
@echo "Frontend:"
|
||||
@echo " make lint - ESLint frontend"
|
||||
@@ -36,6 +40,15 @@ up:
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
restart:
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
|
||||
rebuild:
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
build:
|
||||
docker compose build --no-cache
|
||||
|
||||
@@ -55,6 +68,9 @@ clean:
|
||||
phpstan:
|
||||
docker compose exec php composer phpstan
|
||||
|
||||
arch:
|
||||
docker compose exec php composer arch
|
||||
|
||||
cs-fix:
|
||||
docker compose exec php composer cs-fix
|
||||
|
||||
@@ -64,6 +80,9 @@ cs-check:
|
||||
test-php:
|
||||
docker compose exec php composer test
|
||||
|
||||
warmup:
|
||||
docker compose exec php php bin/console cache:warmup
|
||||
|
||||
# =============================================================================
|
||||
# Frontend
|
||||
# =============================================================================
|
||||
@@ -97,3 +116,6 @@ check-bc:
|
||||
|
||||
check-naming:
|
||||
./scripts/check-naming.sh
|
||||
|
||||
check-tenants:
|
||||
./scripts/check-tenants.sh
|
||||
|
||||
26
README.md
26
README.md
@@ -9,6 +9,14 @@ Application de gestion scolaire moderne - Backend Symfony 8 + Frontend SvelteKit
|
||||
- Docker Desktop 24+ avec Docker Compose 2.20+
|
||||
- Git
|
||||
|
||||
### Configuration /etc/hosts (multi-tenant)
|
||||
|
||||
Classeo utilise une architecture multi-tenant basée sur les sous-domaines. Ajoutez cette ligne à `/etc/hosts` :
|
||||
|
||||
```bash
|
||||
sudo sh -c 'echo "127.0.0.1 classeo.local ecole-alpha.classeo.local ecole-beta.classeo.local" >> /etc/hosts'
|
||||
```
|
||||
|
||||
### Lancement
|
||||
|
||||
```bash
|
||||
@@ -21,15 +29,27 @@ docker compose up -d
|
||||
|
||||
# Verifier le statut
|
||||
docker compose ps
|
||||
|
||||
# Verifier que les tenants répondent
|
||||
make check-tenants
|
||||
```
|
||||
|
||||
### URLs
|
||||
|
||||
#### Multi-tenant (recommandé)
|
||||
|
||||
| Service | URL | Description |
|
||||
|---------|-----|-------------|
|
||||
| Frontend Alpha | http://ecole-alpha.classeo.local:5174 | Tenant ecole-alpha |
|
||||
| Frontend Beta | http://ecole-beta.classeo.local:5174 | Tenant ecole-beta |
|
||||
| API Alpha | http://ecole-alpha.classeo.local:18000/api | API tenant ecole-alpha |
|
||||
| API Beta | http://ecole-beta.classeo.local:18000/api | API tenant ecole-beta |
|
||||
| API Docs | http://ecole-alpha.classeo.local:18000/api/docs | Documentation OpenAPI |
|
||||
|
||||
#### Services partagés
|
||||
|
||||
| Service | URL | Description |
|
||||
|---------|-----|-------------|
|
||||
| Frontend | http://localhost:5174 | Application SvelteKit |
|
||||
| Backend API | http://localhost:18000/api | API REST (API Platform) |
|
||||
| API Docs | http://localhost:18000/api/docs | Documentation OpenAPI |
|
||||
| RabbitMQ | http://localhost:15672 | Admin (guest/guest) |
|
||||
| Meilisearch | http://localhost:7700 | Dashboard recherche |
|
||||
| Mailpit | http://localhost:8025 | Emails de test |
|
||||
|
||||
17
backend/.editorconfig
Normal file
17
backend/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[{compose.yaml,compose.*.yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
13
backend/.env
13
backend/.env
@@ -17,7 +17,7 @@
|
||||
APP_ENV=dev
|
||||
APP_SECRET=change_me_in_production_12345678
|
||||
TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
TRUSTED_HOSTS='^(localhost|php|127\.0\.0\.1)$'
|
||||
TRUSTED_HOSTS=^(localhost|php|127\.0\.0\.1|(.+\.)?classeo\.local)$
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> doctrine/doctrine-bundle ###
|
||||
@@ -52,3 +52,14 @@ MEILISEARCH_API_KEY=masterKey
|
||||
###> symfony/mailer ###
|
||||
MAILER_DSN=smtp://mailpit:1025
|
||||
###< symfony/mailer ###
|
||||
|
||||
###> symfony/routing ###
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
DEFAULT_URI=http://localhost
|
||||
###< symfony/routing ###
|
||||
|
||||
###> multi-tenant ###
|
||||
# Base domain for tenant resolution (e.g., classeo.fr, classeo.local)
|
||||
TENANT_BASE_DOMAIN=classeo.local
|
||||
###< multi-tenant ###
|
||||
|
||||
4
backend/.env.dev
Normal file
4
backend/.env.dev
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
APP_SECRET=88a37335e55d5adf4169161f49f4b596
|
||||
###< symfony/framework-bundle ###
|
||||
3
backend/.env.test
Normal file
3
backend/.env.test
Normal file
@@ -0,0 +1,3 @@
|
||||
# define your env variables for the test env here
|
||||
KERNEL_CLASS='App\Kernel'
|
||||
APP_SECRET='$ecretf0rt3st'
|
||||
13
backend/.gitignore
vendored
13
backend/.gitignore
vendored
@@ -10,7 +10,6 @@
|
||||
/public/bundles/
|
||||
|
||||
# Fichiers auto-générés par Symfony
|
||||
/config/bundles.php
|
||||
/config/preload.php
|
||||
/config/reference.php
|
||||
|
||||
@@ -45,7 +44,6 @@ phpstan.neon.dist
|
||||
# Composer
|
||||
# =============================================================================
|
||||
composer.phar
|
||||
composer.lock
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
/.env.local
|
||||
@@ -70,3 +68,14 @@ composer.lock
|
||||
/phpunit.xml
|
||||
/.phpunit.cache/
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
# =============================================================================
|
||||
# Symfony Flex Docker (redondant avec compose.yaml racine)
|
||||
# =============================================================================
|
||||
/compose.yaml
|
||||
/compose.override.yaml
|
||||
|
||||
# =============================================================================
|
||||
# System
|
||||
# =============================================================================
|
||||
core
|
||||
|
||||
@@ -14,6 +14,8 @@ $finder = (new PhpCsFixer\Finder())
|
||||
// Exclusions spécifiques
|
||||
->notPath('src/Shared/Domain/AggregateRoot.php')
|
||||
->notPath('src/Shared/Domain/EntityId.php')
|
||||
// Classes that need to be mocked in tests (cannot be final)
|
||||
->notPath('src/Shared/Infrastructure/Tenant/TenantResolver.php')
|
||||
;
|
||||
|
||||
return (new PhpCsFixer\Config())
|
||||
@@ -25,7 +27,7 @@ return (new PhpCsFixer\Config())
|
||||
'array_syntax' => ['syntax' => 'short'],
|
||||
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
||||
'no_unused_imports' => true,
|
||||
'not_operator_with_successor_space' => true,
|
||||
'not_operator_with_successor_space' => false,
|
||||
'trailing_comma_in_multiline' => true,
|
||||
'phpdoc_order' => true,
|
||||
'phpdoc_separation' => true,
|
||||
@@ -52,6 +52,17 @@ RUN echo "opcache.enable=1" >> "$PHP_INI_DIR/conf.d/opcache.ini" \
|
||||
# =============================================================================
|
||||
FROM base AS dev
|
||||
|
||||
# Install gosu for proper user switching
|
||||
ENV GOSU_VERSION=1.17
|
||||
RUN set -eux; \
|
||||
apk add --no-cache --virtual .gosu-deps dpkg gnupg; \
|
||||
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||
chmod +x /usr/local/bin/gosu; \
|
||||
gosu --version; \
|
||||
gosu nobody true; \
|
||||
apk del --no-network .gosu-deps
|
||||
|
||||
# Enable opcache revalidation for dev (zz- prefix loads last alphabetically)
|
||||
RUN echo "opcache.validate_timestamps=1" >> "$PHP_INI_DIR/conf.d/zz-opcache-dev.ini"
|
||||
|
||||
@@ -65,16 +76,40 @@ RUN echo "xdebug.mode=develop,debug,coverage" >> "$PHP_INI_DIR/conf.d/xdebug.ini
|
||||
ENV SERVER_NAME=:8000
|
||||
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
|
||||
|
||||
# Create entrypoint script for dev (installs deps if needed)
|
||||
RUN echo '#!/bin/sh' > /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'set -e' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'if [ ! -f /app/vendor/autoload.php ]; then' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo ' echo "Installing Composer dependencies..."' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo ' composer install --prefer-dist --no-progress --no-interaction' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'fi' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'mkdir -p var/cache var/log && chmod -R 777 var' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'exec "$@"' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
# Entrypoint: detect host UID/GID and run as matching user
|
||||
# Uses gosu with UID:GID directly (no need to create user in Dockerfile)
|
||||
COPY --chmod=755 <<'EOF' /usr/local/bin/docker-entrypoint.sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Detect UID/GID from mounted /app directory
|
||||
HOST_UID=$(stat -c %u /app)
|
||||
HOST_GID=$(stat -c %g /app)
|
||||
|
||||
# If root owns /app, run as root (CI environment or volume not mounted)
|
||||
if [ "$HOST_UID" = "0" ]; then
|
||||
# Install dependencies if not present
|
||||
if [ ! -f /app/vendor/autoload.php ]; then
|
||||
echo "Installing Composer dependencies..."
|
||||
composer install --prefer-dist --no-progress --no-interaction
|
||||
fi
|
||||
mkdir -p /app/var/cache /app/var/log
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Ensure directories exist with correct ownership
|
||||
mkdir -p /app/var/cache /app/var/log /data /config
|
||||
chown -R "$HOST_UID:$HOST_GID" /app/var /data /config 2>/dev/null || true
|
||||
|
||||
# Install Composer dependencies if not present (as host user)
|
||||
if [ ! -f /app/vendor/autoload.php ]; then
|
||||
echo "Installing Composer dependencies..."
|
||||
gosu "$HOST_UID:$HOST_GID" composer install --prefer-dist --no-progress --no-interaction
|
||||
fi
|
||||
|
||||
# Run command as host user via gosu (using UID:GID directly)
|
||||
exec gosu "$HOST_UID:$HOST_GID" "$@"
|
||||
EOF
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
29
backend/Makefile
Normal file
29
backend/Makefile
Normal file
@@ -0,0 +1,29 @@
|
||||
.PHONY: help test arch lint warmup cache-clear
|
||||
|
||||
# Default PHP command (can be overridden)
|
||||
PHP ?= docker run --rm -v "$$(pwd):/app" -w /app php:8.5-cli php
|
||||
|
||||
help: ## Display this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
test: ## Run PHPUnit tests
|
||||
$(PHP) ./vendor/bin/phpunit --testdox
|
||||
|
||||
arch: ## Run architecture tests (PHPat via PHPStan)
|
||||
$(PHP) ./vendor/bin/phpstan analyse tests/Architecture --configuration=phpstan.neon --level=1
|
||||
|
||||
lint: ## Run PHPStan static analysis
|
||||
$(PHP) ./vendor/bin/phpstan analyse --configuration=phpstan.neon
|
||||
|
||||
warmup: ## Warmup Symfony cache (avoids timeout in web requests)
|
||||
$(PHP) ./bin/console cache:warmup --env=dev
|
||||
$(PHP) ./bin/console cache:warmup --env=prod
|
||||
|
||||
cache-clear: ## Clear Symfony cache
|
||||
$(PHP) ./bin/console cache:clear --env=dev
|
||||
$(PHP) ./bin/console cache:clear --env=prod
|
||||
|
||||
cs-fix: ## Run PHP-CS-Fixer
|
||||
$(PHP) ./vendor/bin/php-cs-fixer fix
|
||||
|
||||
ci: lint arch test ## Run all CI checks (lint, arch, test)
|
||||
4
backend/bin/phpunit
Executable file
4
backend/bin/phpunit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||
@@ -38,6 +38,8 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.65",
|
||||
"phpat/phpat": "*",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan-doctrine": "^2.0",
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
@@ -48,8 +50,7 @@
|
||||
"symfony/maker-bundle": "^1.62",
|
||||
"symfony/phpunit-bridge": "^8.0",
|
||||
"symfony/stopwatch": "^8.0",
|
||||
"symfony/web-profiler-bundle": "^8.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.65"
|
||||
"symfony/web-profiler-bundle": "^8.0"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
@@ -57,7 +58,8 @@
|
||||
"symfony/flex": true,
|
||||
"symfony/runtime": true
|
||||
},
|
||||
"sort-packages": true
|
||||
"sort-packages": true,
|
||||
"process-timeout": 600
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -84,6 +86,7 @@
|
||||
"scripts": {
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"cache:warmup": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
},
|
||||
"post-install-cmd": [
|
||||
@@ -94,8 +97,10 @@
|
||||
],
|
||||
"test": "phpunit",
|
||||
"phpstan": "phpstan analyse --memory-limit=512M",
|
||||
"arch": "phpstan analyse tests/Architecture --configuration=phpstan.neon --level=1",
|
||||
"cs-fix": "php-cs-fixer fix",
|
||||
"cs-check": "php-cs-fixer fix --dry-run --diff"
|
||||
"cs-check": "php-cs-fixer fix --dry-run --diff",
|
||||
"warmup": "bin/console cache:warmup"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
|
||||
10540
backend/composer.lock
generated
Normal file
10540
backend/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
backend/config/bundles.php
Normal file
18
backend/config/bundles.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
|
||||
Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
];
|
||||
5
backend/config/packages/debug.yaml
Normal file
5
backend/config/packages/debug.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
when@dev:
|
||||
debug:
|
||||
# Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser.
|
||||
# See the "server:dump" command to start a new server.
|
||||
dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%"
|
||||
18
backend/config/packages/dev/tenant.yaml
Normal file
18
backend/config/packages/dev/tenant.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# Tenants de développement
|
||||
# Ces tenants sont automatiquement chargés en environnement dev
|
||||
|
||||
parameters:
|
||||
tenant.dev_configs:
|
||||
- tenantId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
subdomain: 'ecole-alpha'
|
||||
databaseUrl: '%env(DATABASE_URL)%'
|
||||
- tenantId: 'b2c3d4e5-f6a7-8901-bcde-f12345678901'
|
||||
subdomain: 'ecole-beta'
|
||||
databaseUrl: '%env(DATABASE_URL)%'
|
||||
|
||||
services:
|
||||
App\Shared\Infrastructure\Tenant\TenantRegistry:
|
||||
class: App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry
|
||||
factory: ['@App\Shared\Infrastructure\Tenant\TenantRegistryFactory', 'createFromConfig']
|
||||
arguments:
|
||||
$configs: '%tenant.dev_configs%'
|
||||
19
backend/config/packages/prod/tenant.yaml
Normal file
19
backend/config/packages/prod/tenant.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
# Configuration des tenants en production
|
||||
#
|
||||
# En production, les tenants peuvent être configurés de deux façons :
|
||||
# 1. Via la variable d'environnement TENANT_CONFIGS (JSON)
|
||||
# 2. Via une implémentation DatabaseTenantRegistry (à implémenter)
|
||||
#
|
||||
# Pour l'instant, on utilise InMemoryTenantRegistry avec configuration env.
|
||||
# Si aucun tenant n'est configuré, toutes les requêtes retourneront 404.
|
||||
|
||||
parameters:
|
||||
# Format JSON attendu: [{"tenantId":"uuid","subdomain":"ecole","databaseUrl":"postgres://..."}]
|
||||
tenant.prod_configs_json: '%env(default::TENANT_CONFIGS)%'
|
||||
|
||||
services:
|
||||
App\Shared\Infrastructure\Tenant\TenantRegistry:
|
||||
class: App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry
|
||||
factory: ['@App\Shared\Infrastructure\Tenant\TenantRegistryFactory', 'createFromEnv']
|
||||
arguments:
|
||||
$configsJson: '%tenant.prod_configs_json%'
|
||||
3
backend/config/packages/property_info.yaml
Normal file
3
backend/config/packages/property_info.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
10
backend/config/packages/routing.yaml
Normal file
10
backend/config/packages/routing.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
framework:
|
||||
router:
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
default_uri: '%env(DEFAULT_URI)%'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
router:
|
||||
strict_requirements: null
|
||||
9
backend/config/packages/tenant.yaml
Normal file
9
backend/config/packages/tenant.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
# Tenant infrastructure event subscribers
|
||||
App\Shared\Infrastructure\Tenant\TenantMiddleware:
|
||||
tags:
|
||||
- { name: kernel.event_subscriber }
|
||||
|
||||
App\Shared\Infrastructure\Security\TenantAccessDeniedHandler:
|
||||
tags:
|
||||
- { name: kernel.event_subscriber }
|
||||
18
backend/config/packages/test/tenant.yaml
Normal file
18
backend/config/packages/test/tenant.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# Tenants pour les tests
|
||||
# Utilise les mêmes tenants que dev pour les tests d'intégration
|
||||
|
||||
parameters:
|
||||
tenant.test_configs:
|
||||
- tenantId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
subdomain: 'ecole-alpha'
|
||||
databaseUrl: '%env(DATABASE_URL)%'
|
||||
- tenantId: 'b2c3d4e5-f6a7-8901-bcde-f12345678901'
|
||||
subdomain: 'ecole-beta'
|
||||
databaseUrl: '%env(DATABASE_URL)%'
|
||||
|
||||
services:
|
||||
App\Shared\Infrastructure\Tenant\TenantRegistry:
|
||||
class: App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry
|
||||
factory: ['@App\Shared\Infrastructure\Tenant\TenantRegistryFactory', 'createFromConfig']
|
||||
arguments:
|
||||
$configs: '%tenant.test_configs%'
|
||||
13
backend/config/packages/web_profiler.yaml
Normal file
13
backend/config/packages/web_profiler.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
when@dev:
|
||||
web_profiler:
|
||||
toolbar: true
|
||||
|
||||
framework:
|
||||
profiler:
|
||||
collect_serializer_data: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
profiler:
|
||||
collect: false
|
||||
collect_serializer_data: true
|
||||
4
backend/config/routes/framework.yaml
Normal file
4
backend/config/routes/framework.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
when@dev:
|
||||
_errors:
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||
prefix: /_error
|
||||
3
backend/config/routes/security.yaml
Normal file
3
backend/config/routes/security.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
_security_logout:
|
||||
resource: security.route_loader.logout
|
||||
type: service
|
||||
8
backend/config/routes/web_profiler.yaml
Normal file
8
backend/config/routes/web_profiler.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
when@dev:
|
||||
web_profiler_wdt:
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
|
||||
prefix: /_wdt
|
||||
|
||||
web_profiler_profiler:
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
|
||||
prefix: /_profiler
|
||||
@@ -4,6 +4,7 @@
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
tenant.base_domain: '%env(TENANT_BASE_DOMAIN)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in this file
|
||||
@@ -25,3 +26,20 @@ services:
|
||||
# Domain services need to be registered explicitly to avoid framework coupling
|
||||
# Example: App\Administration\Application\Command\:
|
||||
# resource: '../src/Administration/Application/Command/'
|
||||
|
||||
# Tenant services
|
||||
App\Shared\Infrastructure\Tenant\TenantResolver:
|
||||
arguments:
|
||||
$baseDomain: '%tenant.base_domain%'
|
||||
|
||||
# TenantRegistry est configuré par environnement :
|
||||
# - dev: config/packages/dev/tenant.yaml (tenants de test)
|
||||
# - prod: à configurer via admin ou env vars
|
||||
|
||||
App\Shared\Infrastructure\Tenant\Command\CreateTenantDatabaseCommand:
|
||||
arguments:
|
||||
$masterDatabaseUrl: '%env(DATABASE_URL)%'
|
||||
|
||||
App\Shared\Infrastructure\Tenant\Command\TenantMigrateCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
@@ -2,6 +2,7 @@ parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
- tests/Architecture
|
||||
excludePaths:
|
||||
- src/Kernel.php
|
||||
treatPhpDocTypesAsCertain: false
|
||||
@@ -10,3 +11,4 @@ parameters:
|
||||
includes:
|
||||
- vendor/phpstan/phpstan-doctrine/extension.neon
|
||||
- vendor/phpstan/phpstan-symfony/extension.neon
|
||||
- vendor/phpat/phpat/extension.neon
|
||||
|
||||
49
backend/phpunit.dist.xml
Normal file
49
backend/phpunit.dist.xml
Normal file
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
colors="true"
|
||||
failOnDeprecation="true"
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
>
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<server name="APP_ENV" value="test" force="true" />
|
||||
<server name="SHELL_VERBOSITY" value="-1" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Project Test Suite">
|
||||
<directory>tests</directory>
|
||||
<exclude>tests/Architecture</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source ignoreSuppressionOfDeprecations="true"
|
||||
ignoreIndirectDeprecations="true"
|
||||
restrictNotices="true"
|
||||
restrictWarnings="true"
|
||||
>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
|
||||
<deprecationTrigger>
|
||||
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||
<function>trigger_deprecation</function>
|
||||
</deprecationTrigger>
|
||||
</source>
|
||||
|
||||
<extensions>
|
||||
<bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension">
|
||||
<parameter name="clock-mock-namespaces" value="App" />
|
||||
<parameter name="dns-mock-namespaces" value="App" />
|
||||
</bootstrap>
|
||||
</extensions>
|
||||
</phpunit>
|
||||
0
backend/src/ApiResource/.gitignore
vendored
Normal file
0
backend/src/ApiResource/.gitignore
vendored
Normal file
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;
|
||||
}
|
||||
}
|
||||
296
backend/symfony.lock
Normal file
296
backend/symfony.lock
Normal file
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"api-platform/core": {
|
||||
"version": "4.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "4.0",
|
||||
"ref": "cb9e6b8ceb9b62f32d41fc8ad72a25d5bd674c6d"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/api_platform.yaml",
|
||||
"config/routes/api_platform.yaml",
|
||||
"src/ApiResource/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/deprecations": {
|
||||
"version": "1.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
|
||||
}
|
||||
},
|
||||
"doctrine/doctrine-bundle": {
|
||||
"version": "3.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.0",
|
||||
"ref": "18ee08e513ba0303fd09a01fc1c934870af06ffa"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine.yaml",
|
||||
"src/Entity/.gitignore",
|
||||
"src/Repository/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-fixtures-bundle": {
|
||||
"version": "4.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.0",
|
||||
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
|
||||
},
|
||||
"files": [
|
||||
"src/DataFixtures/AppFixtures.php"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-migrations-bundle": {
|
||||
"version": "3.7",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.1",
|
||||
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine_migrations.yaml",
|
||||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"friendsofphp/php-cs-fixer": {
|
||||
"version": "3.93",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.0",
|
||||
"ref": "be2103eb4a20942e28a6dd87736669b757132435"
|
||||
},
|
||||
"files": [
|
||||
".php-cs-fixer.dist.php"
|
||||
]
|
||||
},
|
||||
"lexik/jwt-authentication-bundle": {
|
||||
"version": "3.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.5",
|
||||
"ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/lexik_jwt_authentication.yaml"
|
||||
]
|
||||
},
|
||||
"phpstan/phpstan": {
|
||||
"version": "2.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
|
||||
}
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "11.5",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "11.1",
|
||||
"ref": "1117deb12541f35793eec9fff7494d7aa12283fc"
|
||||
},
|
||||
"files": [
|
||||
".env.test",
|
||||
"phpunit.dist.xml",
|
||||
"tests/bootstrap.php",
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||
},
|
||||
"files": [
|
||||
"bin/console"
|
||||
]
|
||||
},
|
||||
"symfony/debug-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "5aa8aa48234c8eb6dbdd7b3cd5d791485d2cec4b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/debug.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/flex": {
|
||||
"version": "2.10",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.4",
|
||||
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||
},
|
||||
"files": [
|
||||
".env",
|
||||
".env.dev"
|
||||
]
|
||||
},
|
||||
"symfony/framework-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "09f6e081c763a206802674ce0cb34a022f0ffc6d"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/cache.yaml",
|
||||
"config/packages/framework.yaml",
|
||||
"config/preload.php",
|
||||
"config/routes/framework.yaml",
|
||||
"config/services.yaml",
|
||||
"public/index.php",
|
||||
"src/Controller/.gitignore",
|
||||
"src/Kernel.php",
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/maker-bundle": {
|
||||
"version": "1.65",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
||||
}
|
||||
},
|
||||
"symfony/messenger": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.0",
|
||||
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/messenger.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/monolog-bundle": {
|
||||
"version": "4.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.7",
|
||||
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/monolog.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/phpunit-bridge": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "dc13fec96bd527bd399c3c01f0aab915c67fd544"
|
||||
}
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/property_info.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/routing.yaml",
|
||||
"config/routes.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/security-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/security.yaml",
|
||||
"config/routes/security.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/twig.yaml",
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||
}
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/validator.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/web-profiler-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "a363460c1b0b4a4d0242f2ce1a843ca0f6ac9026"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/web_profiler.yaml",
|
||||
"config/routes/web_profiler.yaml"
|
||||
]
|
||||
}
|
||||
}
|
||||
16
backend/templates/base.html.twig
Normal file
16
backend/templates/base.html.twig
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Welcome!{% endblock %}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
|
||||
{% block stylesheets %}
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
126
backend/tests/Architecture/BoundedContextIsolationTest.php
Normal file
126
backend/tests/Architecture/BoundedContextIsolationTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use PHPat\Selector\Selector;
|
||||
use PHPat\Test\Builder\BuildStep;
|
||||
use PHPat\Test\PHPat;
|
||||
|
||||
/**
|
||||
* Tests ensuring Bounded Contexts are properly isolated.
|
||||
*
|
||||
* Bounded Contexts must communicate through events (via Shared),
|
||||
* not through direct dependencies.
|
||||
*/
|
||||
final class BoundedContextIsolationTest
|
||||
{
|
||||
public function test_scolarite_should_not_depend_on_vie_scolaire(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_scolarite_should_not_depend_on_communication(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_scolarite_should_not_depend_on_administration(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_vie_scolaire_should_not_depend_on_scolarite(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_vie_scolaire_should_not_depend_on_communication(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_vie_scolaire_should_not_depend_on_administration(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_communication_should_not_depend_on_scolarite(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_communication_should_not_depend_on_vie_scolaire(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_communication_should_not_depend_on_administration(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_administration_should_not_depend_on_scolarite(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_administration_should_not_depend_on_vie_scolaire(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_administration_should_not_depend_on_communication(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
}
|
||||
129
backend/tests/Architecture/DomainPurityTest.php
Normal file
129
backend/tests/Architecture/DomainPurityTest.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use PHPat\Selector\Selector;
|
||||
use PHPat\Test\Builder\BuildStep;
|
||||
use PHPat\Test\PHPat;
|
||||
|
||||
/**
|
||||
* Tests ensuring Domain layer purity across all Bounded Contexts.
|
||||
*
|
||||
* The Domain layer must be pure PHP without framework dependencies.
|
||||
* This is critical for DDD architecture and testability.
|
||||
*/
|
||||
final class DomainPurityTest
|
||||
{
|
||||
public function test_scolarite_domain_should_not_depend_on_infrastructure(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Scolarite\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(
|
||||
Selector::inNamespace('App\Scolarite\Infrastructure'),
|
||||
Selector::inNamespace('App\Shared\Infrastructure'),
|
||||
Selector::inNamespace('Symfony'),
|
||||
Selector::inNamespace('Doctrine'),
|
||||
Selector::inNamespace('ApiPlatform'),
|
||||
)
|
||||
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
|
||||
}
|
||||
|
||||
public function test_vie_scolaire_domain_should_not_depend_on_infrastructure(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(
|
||||
Selector::inNamespace('App\VieScolaire\Infrastructure'),
|
||||
Selector::inNamespace('App\Shared\Infrastructure'),
|
||||
Selector::inNamespace('Symfony'),
|
||||
Selector::inNamespace('Doctrine'),
|
||||
Selector::inNamespace('ApiPlatform'),
|
||||
)
|
||||
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
|
||||
}
|
||||
|
||||
public function test_communication_domain_should_not_depend_on_infrastructure(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Communication\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(
|
||||
Selector::inNamespace('App\Communication\Infrastructure'),
|
||||
Selector::inNamespace('App\Shared\Infrastructure'),
|
||||
Selector::inNamespace('Symfony'),
|
||||
Selector::inNamespace('Doctrine'),
|
||||
Selector::inNamespace('ApiPlatform'),
|
||||
)
|
||||
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
|
||||
}
|
||||
|
||||
public function test_administration_domain_should_not_depend_on_infrastructure(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Administration\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(
|
||||
Selector::inNamespace('App\Administration\Infrastructure'),
|
||||
Selector::inNamespace('App\Shared\Infrastructure'),
|
||||
Selector::inNamespace('Symfony'),
|
||||
Selector::inNamespace('Doctrine'),
|
||||
Selector::inNamespace('ApiPlatform'),
|
||||
)
|
||||
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
|
||||
}
|
||||
|
||||
public function test_scolarite_domain_should_not_depend_on_application(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Scolarite\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Scolarite\Application'))
|
||||
->because('Domain must not know about Application layer');
|
||||
}
|
||||
|
||||
public function test_vie_scolaire_domain_should_not_depend_on_application(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\VieScolaire\Application'))
|
||||
->because('Domain must not know about Application layer');
|
||||
}
|
||||
|
||||
public function test_communication_domain_should_not_depend_on_application(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Communication\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Communication\Application'))
|
||||
->because('Domain must not know about Application layer');
|
||||
}
|
||||
|
||||
public function test_administration_domain_should_not_depend_on_application(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Administration\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Administration\Application'))
|
||||
->because('Domain must not know about Application layer');
|
||||
}
|
||||
|
||||
public function test_shared_domain_should_be_pure(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Shared\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(
|
||||
Selector::inNamespace('Symfony'),
|
||||
Selector::inNamespace('Doctrine'),
|
||||
Selector::inNamespace('ApiPlatform'),
|
||||
Selector::inNamespace('App\Shared\Infrastructure'),
|
||||
Selector::inNamespace('App\Shared\Application'),
|
||||
)
|
||||
->because('Shared Domain must be pure PHP');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantEntityManagerFactory;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Configuration;
|
||||
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(TenantEntityManagerFactory::class)]
|
||||
final class TenantDatabaseCreationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCreatesConnectionForTenant(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$config]);
|
||||
$clock = $this->createMock(Clock::class);
|
||||
$clock->method('now')->willReturn(new DateTimeImmutable());
|
||||
|
||||
$ormConfig = new Configuration();
|
||||
$ormConfig->setProxyDir(sys_get_temp_dir());
|
||||
$ormConfig->setProxyNamespace('DoctrineProxies');
|
||||
$ormConfig->setAutoGenerateProxyClasses(true);
|
||||
$ormConfig->setMetadataDriverImpl(new AttributeDriver([]));
|
||||
$ormConfig->enableNativeLazyObjects(true);
|
||||
|
||||
$factory = new TenantEntityManagerFactory($registry, $clock, $ormConfig);
|
||||
|
||||
$em = $factory->getForTenant($tenantId);
|
||||
|
||||
// Verify connection is working
|
||||
$connection = $em->getConnection();
|
||||
self::assertTrue($connection->isConnected() || $connection->connect());
|
||||
|
||||
// Verify we can execute queries
|
||||
$result = $connection->executeQuery('SELECT 1 as test');
|
||||
self::assertEquals(1, $result->fetchOne());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itMaintainsIsolationBetweenTenants(): void
|
||||
{
|
||||
$tenantIdAlpha = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$tenantIdBeta = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
|
||||
$configAlpha = new TenantConfig(
|
||||
tenantId: $tenantIdAlpha,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
$configBeta = new TenantConfig(
|
||||
tenantId: $tenantIdBeta,
|
||||
subdomain: 'ecole-beta',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$configAlpha, $configBeta]);
|
||||
$clock = $this->createMock(Clock::class);
|
||||
$clock->method('now')->willReturn(new DateTimeImmutable());
|
||||
|
||||
$ormConfig = new Configuration();
|
||||
$ormConfig->setProxyDir(sys_get_temp_dir());
|
||||
$ormConfig->setProxyNamespace('DoctrineProxies');
|
||||
$ormConfig->setAutoGenerateProxyClasses(true);
|
||||
$ormConfig->setMetadataDriverImpl(new AttributeDriver([]));
|
||||
$ormConfig->enableNativeLazyObjects(true);
|
||||
|
||||
$factory = new TenantEntityManagerFactory($registry, $clock, $ormConfig);
|
||||
|
||||
$emAlpha = $factory->getForTenant($tenantIdAlpha);
|
||||
$emBeta = $factory->getForTenant($tenantIdBeta);
|
||||
|
||||
// Create table in Alpha
|
||||
$emAlpha->getConnection()->executeStatement(
|
||||
'CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)'
|
||||
);
|
||||
$emAlpha->getConnection()->executeStatement(
|
||||
"INSERT INTO test_data (id, value) VALUES (1, 'alpha_data')"
|
||||
);
|
||||
|
||||
// Create different table in Beta
|
||||
$emBeta->getConnection()->executeStatement(
|
||||
'CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)'
|
||||
);
|
||||
$emBeta->getConnection()->executeStatement(
|
||||
"INSERT INTO test_data (id, value) VALUES (1, 'beta_data')"
|
||||
);
|
||||
|
||||
// Verify isolation - each tenant sees only their data
|
||||
$alphaValue = $emAlpha->getConnection()
|
||||
->executeQuery('SELECT value FROM test_data WHERE id = 1')
|
||||
->fetchOne();
|
||||
$betaValue = $emBeta->getConnection()
|
||||
->executeQuery('SELECT value FROM test_data WHERE id = 1')
|
||||
->fetchOne();
|
||||
|
||||
self::assertSame('alpha_data', $alphaValue);
|
||||
self::assertSame('beta_data', $betaValue);
|
||||
self::assertNotSame($alphaValue, $betaValue);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(InMemoryTenantRegistry::class)]
|
||||
final class InMemoryTenantRegistryTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itReturnsConfigByTenantId(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$config]);
|
||||
|
||||
$result = $registry->getConfig($tenantId);
|
||||
|
||||
self::assertSame($config, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsConfigBySubdomain(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$config]);
|
||||
|
||||
$result = $registry->getBySubdomain('ecole-alpha');
|
||||
|
||||
self::assertSame($config, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionForUnknownTenantId(): void
|
||||
{
|
||||
$registry = new InMemoryTenantRegistry([]);
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
|
||||
$registry->getConfig(TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionForUnknownSubdomain(): void
|
||||
{
|
||||
$registry = new InMemoryTenantRegistry([]);
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
|
||||
$registry->getBySubdomain('ecole-inexistant');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itChecksIfTenantExists(): void
|
||||
{
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$config]);
|
||||
|
||||
self::assertTrue($registry->exists('ecole-alpha'));
|
||||
self::assertFalse($registry->exists('ecole-inexistant'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSupportsMultipleTenants(): void
|
||||
{
|
||||
$configAlpha = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
$configBeta = new TenantConfig(
|
||||
tenantId: TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901'),
|
||||
subdomain: 'ecole-beta',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_beta',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$configAlpha, $configBeta]);
|
||||
|
||||
self::assertSame($configAlpha, $registry->getBySubdomain('ecole-alpha'));
|
||||
self::assertSame($configBeta, $registry->getBySubdomain('ecole-beta'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
|
||||
#[CoversClass(TenantConfig::class)]
|
||||
final class TenantConfigTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCanBeCreatedWithRequiredProperties(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$subdomain = 'ecole-alpha';
|
||||
$databaseUrl = 'postgresql://user:pass@localhost:5432/classeo_alpha';
|
||||
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: $subdomain,
|
||||
databaseUrl: $databaseUrl,
|
||||
);
|
||||
|
||||
self::assertTrue($tenantId->equals($config->tenantId));
|
||||
self::assertSame($subdomain, $config->subdomain);
|
||||
self::assertSame($databaseUrl, $config->databaseUrl);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIsImmutable(): void
|
||||
{
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$reflection = new ReflectionClass($config);
|
||||
|
||||
self::assertTrue($reflection->isReadonly());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotSetException;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(TenantContext::class)]
|
||||
final class TenantContextTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCanSetAndRetrieveCurrentTenant(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$context = new TenantContext();
|
||||
$context->setCurrentTenant($config);
|
||||
|
||||
self::assertTrue($tenantId->equals($context->getCurrentTenantId()));
|
||||
self::assertSame($config, $context->getCurrentTenantConfig());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionWhenNoTenantIsSet(): void
|
||||
{
|
||||
$context = new TenantContext();
|
||||
|
||||
$this->expectException(TenantNotSetException::class);
|
||||
|
||||
$context->getCurrentTenantId();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionWhenGettingConfigWithNoTenantSet(): void
|
||||
{
|
||||
$context = new TenantContext();
|
||||
|
||||
$this->expectException(TenantNotSetException::class);
|
||||
|
||||
$context->getCurrentTenantConfig();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCanCheckIfTenantIsSet(): void
|
||||
{
|
||||
$context = new TenantContext();
|
||||
|
||||
self::assertFalse($context->hasTenant());
|
||||
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
$context->setCurrentTenant($config);
|
||||
|
||||
self::assertTrue($context->hasTenant());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCanClearTenant(): void
|
||||
{
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$context = new TenantContext();
|
||||
$context->setCurrentTenant($config);
|
||||
|
||||
self::assertTrue($context->hasTenant());
|
||||
|
||||
$context->clear();
|
||||
|
||||
self::assertFalse($context->hasTenant());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantEntityManagerFactory;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Configuration;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for TenantEntityManagerFactory.
|
||||
*
|
||||
* Note: These tests use SQLite in-memory databases which requires proper
|
||||
* Doctrine ORM configuration. For full integration testing with PostgreSQL,
|
||||
* see the Integration tests.
|
||||
*/
|
||||
#[CoversClass(TenantEntityManagerFactory::class)]
|
||||
final class TenantEntityManagerFactoryTest extends TestCase
|
||||
{
|
||||
private TenantRegistry $registry;
|
||||
private Clock $clock;
|
||||
private Configuration $ormConfiguration;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->registry = $this->createMock(TenantRegistry::class);
|
||||
$this->clock = $this->createMock(Clock::class);
|
||||
$this->ormConfiguration = $this->createOrmConfiguration();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEntityManagerForTenant(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$this->registry->method('getConfig')->with($tenantId)->willReturn($config);
|
||||
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
||||
|
||||
$factory = new TenantEntityManagerFactory(
|
||||
$this->registry,
|
||||
$this->clock,
|
||||
$this->ormConfiguration,
|
||||
);
|
||||
|
||||
$entityManager = $factory->getForTenant($tenantId);
|
||||
|
||||
self::assertInstanceOf(EntityManagerInterface::class, $entityManager);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsSameEntityManagerForSameTenant(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$this->registry->method('getConfig')->with($tenantId)->willReturn($config);
|
||||
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
||||
|
||||
$factory = new TenantEntityManagerFactory(
|
||||
$this->registry,
|
||||
$this->clock,
|
||||
$this->ormConfiguration,
|
||||
);
|
||||
|
||||
$em1 = $factory->getForTenant($tenantId);
|
||||
$em2 = $factory->getForTenant($tenantId);
|
||||
|
||||
self::assertSame($em1, $em2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsDifferentEntityManagersForDifferentTenants(): void
|
||||
{
|
||||
$tenantIdAlpha = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$tenantIdBeta = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
|
||||
$configAlpha = new TenantConfig(
|
||||
tenantId: $tenantIdAlpha,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
$configBeta = new TenantConfig(
|
||||
tenantId: $tenantIdBeta,
|
||||
subdomain: 'ecole-beta',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$this->registry->method('getConfig')->willReturnMap([
|
||||
[$tenantIdAlpha, $configAlpha],
|
||||
[$tenantIdBeta, $configBeta],
|
||||
]);
|
||||
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
||||
|
||||
$factory = new TenantEntityManagerFactory(
|
||||
$this->registry,
|
||||
$this->clock,
|
||||
$this->ormConfiguration,
|
||||
);
|
||||
|
||||
$emAlpha = $factory->getForTenant($tenantIdAlpha);
|
||||
$emBeta = $factory->getForTenant($tenantIdBeta);
|
||||
|
||||
self::assertNotSame($emAlpha, $emBeta);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsCorrectPoolSize(): void
|
||||
{
|
||||
$tenantId1 = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$tenantId2 = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
|
||||
$config1 = new TenantConfig(
|
||||
tenantId: $tenantId1,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
$config2 = new TenantConfig(
|
||||
tenantId: $tenantId2,
|
||||
subdomain: 'ecole-beta',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$this->registry->method('getConfig')->willReturnMap([
|
||||
[$tenantId1, $config1],
|
||||
[$tenantId2, $config2],
|
||||
]);
|
||||
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
||||
|
||||
$factory = new TenantEntityManagerFactory(
|
||||
$this->registry,
|
||||
$this->clock,
|
||||
$this->ormConfiguration,
|
||||
);
|
||||
|
||||
self::assertSame(0, $factory->getPoolSize());
|
||||
|
||||
$factory->getForTenant($tenantId1);
|
||||
self::assertSame(1, $factory->getPoolSize());
|
||||
|
||||
$factory->getForTenant($tenantId2);
|
||||
self::assertSame(2, $factory->getPoolSize());
|
||||
|
||||
// Accessing same tenant shouldn't increase pool size
|
||||
$factory->getForTenant($tenantId1);
|
||||
self::assertSame(2, $factory->getPoolSize());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itEvictsIdleConnectionsAfterTimeout(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$this->registry->method('getConfig')->with($tenantId)->willReturn($config);
|
||||
|
||||
$initialTime = new DateTimeImmutable('2026-01-30 10:00:00');
|
||||
$afterTimeout = new DateTimeImmutable('2026-01-30 10:06:00'); // 6 minutes later
|
||||
|
||||
$this->clock->method('now')->willReturnOnConsecutiveCalls(
|
||||
$initialTime, // First call - eviction check
|
||||
$initialTime, // Store lastUsed
|
||||
$afterTimeout, // Second call - eviction check (finds idle)
|
||||
$afterTimeout, // Store lastUsed for new manager
|
||||
);
|
||||
|
||||
$factory = new TenantEntityManagerFactory(
|
||||
$this->registry,
|
||||
$this->clock,
|
||||
$this->ormConfiguration,
|
||||
);
|
||||
|
||||
$em1 = $factory->getForTenant($tenantId);
|
||||
$em2 = $factory->getForTenant($tenantId);
|
||||
|
||||
// Due to idle eviction, we should have a new entity manager
|
||||
self::assertNotSame($em1, $em2);
|
||||
}
|
||||
|
||||
private function createOrmConfiguration(): Configuration
|
||||
{
|
||||
$config = new Configuration();
|
||||
$config->setProxyDir(sys_get_temp_dir() . '/doctrine_proxies_' . uniqid());
|
||||
$config->setProxyNamespace('DoctrineProxies');
|
||||
$config->setAutoGenerateProxyClasses(true);
|
||||
$config->setMetadataDriverImpl(new AttributeDriver([]));
|
||||
$config->enableNativeLazyObjects(true);
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
#[CoversClass(TenantId::class)]
|
||||
final class TenantIdTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCanBeGeneratedWithRandomUuid(): void
|
||||
{
|
||||
$tenantId = TenantId::generate();
|
||||
|
||||
self::assertTrue(Uuid::isValid((string) $tenantId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCanBeCreatedFromString(): void
|
||||
{
|
||||
$uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
$tenantId = TenantId::fromString($uuid);
|
||||
|
||||
self::assertSame($uuid, (string) $tenantId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function twoTenantIdsWithSameValueAreEqual(): void
|
||||
{
|
||||
$uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
$tenantId1 = TenantId::fromString($uuid);
|
||||
$tenantId2 = TenantId::fromString($uuid);
|
||||
|
||||
self::assertTrue($tenantId1->equals($tenantId2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function twoTenantIdsWithDifferentValuesAreNotEqual(): void
|
||||
{
|
||||
$tenantId1 = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$tenantId2 = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
|
||||
self::assertFalse($tenantId1->equals($tenantId2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCanBeConvertedToString(): void
|
||||
{
|
||||
$uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
$tenantId = TenantId::fromString($uuid);
|
||||
|
||||
self::assertSame($uuid, (string) $tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
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\TenantNotFoundException;
|
||||
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\HttpKernel\KernelEvents;
|
||||
|
||||
#[CoversClass(TenantMiddleware::class)]
|
||||
final class TenantMiddlewareTest extends TestCase
|
||||
{
|
||||
private TenantResolver $resolver;
|
||||
private TenantContext $context;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->resolver = $this->createMock(TenantResolver::class);
|
||||
$this->context = new TenantContext();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSetsTenantContextForValidTenant(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$this->resolver->method('resolve')
|
||||
->with('ecole-alpha.classeo.local')
|
||||
->willReturn($config);
|
||||
|
||||
$middleware = new TenantMiddleware($this->resolver, $this->context);
|
||||
|
||||
$request = Request::create('https://ecole-alpha.classeo.local/api/test');
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$middleware->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($this->context->hasTenant());
|
||||
self::assertTrue($tenantId->equals($this->context->getCurrentTenantId()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturns404ForNonExistentTenant(): void
|
||||
{
|
||||
$this->resolver->method('resolve')
|
||||
->with('ecole-inexistant.classeo.local')
|
||||
->willThrowException(TenantNotFoundException::withSubdomain('ecole-inexistant'));
|
||||
|
||||
$middleware = new TenantMiddleware($this->resolver, $this->context);
|
||||
|
||||
$request = Request::create('https://ecole-inexistant.classeo.local/api/test');
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$middleware->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_NOT_FOUND, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsGenericErrorMessageFor404(): void
|
||||
{
|
||||
$this->resolver->method('resolve')
|
||||
->willThrowException(TenantNotFoundException::withSubdomain('test'));
|
||||
|
||||
$middleware = new TenantMiddleware($this->resolver, $this->context);
|
||||
|
||||
$request = Request::create('https://test.classeo.local/api/test');
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$middleware->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertNotNull($response);
|
||||
|
||||
$content = json_decode((string) $response->getContent(), true);
|
||||
self::assertSame('Resource not found', $content['message']);
|
||||
self::assertArrayNotHasKey('subdomain', $content);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itClearsTenantContextOnTerminate(): void
|
||||
{
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$this->context->setCurrentTenant($config);
|
||||
self::assertTrue($this->context->hasTenant());
|
||||
|
||||
$middleware = new TenantMiddleware($this->resolver, $this->context);
|
||||
$middleware->onKernelTerminate();
|
||||
|
||||
self::assertFalse($this->context->hasTenant());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRegistersCorrectEvents(): void
|
||||
{
|
||||
$events = TenantMiddleware::getSubscribedEvents();
|
||||
|
||||
self::assertArrayHasKey(KernelEvents::REQUEST, $events);
|
||||
self::assertArrayHasKey(KernelEvents::TERMINATE, $events);
|
||||
|
||||
// Request listener should have high priority to run early
|
||||
$requestConfig = $events[KernelEvents::REQUEST];
|
||||
self::assertIsArray($requestConfig);
|
||||
self::assertSame('onKernelRequest', $requestConfig[0]);
|
||||
self::assertGreaterThan(0, $requestConfig[1]); // High priority
|
||||
}
|
||||
|
||||
private function createRequestEvent(Request $request): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new RequestEvent(
|
||||
$kernel,
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(TenantResolver::class)]
|
||||
final class TenantResolverTest extends TestCase
|
||||
{
|
||||
private TenantRegistry $registry;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->registry = $this->createMock(TenantRegistry::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itExtractsSubdomainFromHost(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$subdomain = $resolver->extractSubdomain('ecole-alpha.classeo.local');
|
||||
|
||||
self::assertSame('ecole-alpha', $subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itExtractsSubdomainFromHostWithPort(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$subdomain = $resolver->extractSubdomain('ecole-alpha.classeo.local:8080');
|
||||
|
||||
self::assertSame('ecole-alpha', $subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullForMainDomainWithoutSubdomain(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$subdomain = $resolver->extractSubdomain('classeo.local');
|
||||
|
||||
self::assertNull($subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullForWwwSubdomain(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$subdomain = $resolver->extractSubdomain('www.classeo.local');
|
||||
|
||||
self::assertNull($subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullForApiSubdomain(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$subdomain = $resolver->extractSubdomain('api.classeo.local');
|
||||
|
||||
self::assertNull($subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesValidTenantFromHost(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$this->registry->method('getBySubdomain')
|
||||
->with('ecole-alpha')
|
||||
->willReturn($config);
|
||||
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$resolved = $resolver->resolve('ecole-alpha.classeo.local');
|
||||
|
||||
self::assertTrue($tenantId->equals($resolved->tenantId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionForNonExistentTenant(): void
|
||||
{
|
||||
$this->registry->method('getBySubdomain')
|
||||
->with('ecole-inexistant')
|
||||
->willThrowException(TenantNotFoundException::withSubdomain('ecole-inexistant'));
|
||||
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
|
||||
$resolver->resolve('ecole-inexistant.classeo.local');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionWhenNoSubdomainInHost(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
|
||||
$resolver->resolve('classeo.local');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('reservedSubdomainsProvider')]
|
||||
public function itRejectsReservedSubdomains(string $subdomain): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
|
||||
$resolver->resolve("{$subdomain}.classeo.local");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function reservedSubdomainsProvider(): iterable
|
||||
{
|
||||
yield 'www' => ['www'];
|
||||
yield 'api' => ['api'];
|
||||
yield 'admin' => ['admin'];
|
||||
yield 'static' => ['static'];
|
||||
yield 'cdn' => ['cdn'];
|
||||
yield 'mail' => ['mail'];
|
||||
}
|
||||
}
|
||||
21
compose.yaml
21
compose.yaml
@@ -8,22 +8,28 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
target: dev
|
||||
container_name: classeo_php
|
||||
# FrankenPHP charge les variables d'environnement système AVANT que Symfony
|
||||
# ne parse le fichier .env. Sans env_file, les variables du .env ne seraient
|
||||
# pas disponibles au démarrage de FrankenPHP.
|
||||
# Avantage : une seule source de vérité (.env), pas de duplication.
|
||||
# Note : les variables dans 'environment:' ci-dessous écrasent celles du .env
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
APP_ENV: dev
|
||||
APP_DEBUG: 1
|
||||
# Overrides pour Docker : les hostnames des services utilisent les noms
|
||||
# des containers (db, redis, rabbitmq...) au lieu de localhost
|
||||
DATABASE_URL: postgresql://classeo:classeo@db:5432/classeo_master?serverVersion=18&charset=utf8
|
||||
REDIS_URL: redis://redis:6379
|
||||
MESSENGER_TRANSPORT_DSN: amqp://guest:guest@rabbitmq:5672/%2f/messages
|
||||
MERCURE_URL: http://mercure/.well-known/mercure
|
||||
MERCURE_PUBLIC_URL: http://localhost:3000/.well-known/mercure
|
||||
MERCURE_JWT_SECRET: mercure_publisher_secret_change_me_in_production
|
||||
MEILISEARCH_URL: http://meilisearch:7700
|
||||
MEILISEARCH_API_KEY: masterKey
|
||||
MAILER_DSN: smtp://mailpit:1025
|
||||
ports:
|
||||
- "18000:8000" # Port externe 18000 pour eviter conflit
|
||||
volumes:
|
||||
- ./backend:/app:cached
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -49,7 +55,10 @@ services:
|
||||
target: dev
|
||||
container_name: classeo_frontend
|
||||
environment:
|
||||
# URL de fallback, sera remplacée dynamiquement par le hostname en multi-tenant
|
||||
PUBLIC_API_URL: http://localhost:18000/api
|
||||
PUBLIC_API_PORT: "18000"
|
||||
PUBLIC_BASE_DOMAIN: classeo.local
|
||||
PUBLIC_MERCURE_URL: http://localhost:3000/.well-known/mercure
|
||||
ports:
|
||||
- "5174:5173" # Port externe 5174 pour eviter conflit
|
||||
@@ -197,3 +206,5 @@ volumes:
|
||||
rabbitmq_data:
|
||||
meilisearch_data:
|
||||
frontend_node_modules:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
182
docs/DEPLOYMENT.md
Normal file
182
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Déploiement en Production
|
||||
|
||||
## Architecture Multi-tenant
|
||||
|
||||
Classeo utilise une architecture multi-tenant où chaque école a son propre sous-domaine :
|
||||
- `ecole-alpha.classeo.fr`
|
||||
- `ecole-beta.classeo.fr`
|
||||
|
||||
## Différences Dev vs Prod
|
||||
|
||||
| Aspect | Dev | Prod |
|
||||
|--------|-----|------|
|
||||
| Domaine | `classeo.local` | `classeo.fr` |
|
||||
| Frontend | `:5174` | même domaine |
|
||||
| API | `:18000/api` | `/api` (même domaine) |
|
||||
| HTTPS | Non | Oui (obligatoire) |
|
||||
| Reverse proxy | Non | Oui |
|
||||
| Base de données | Une seule (SQLite/PostgreSQL) | Une par tenant |
|
||||
|
||||
## Configuration Reverse Proxy
|
||||
|
||||
En production, un reverse proxy route les requêtes sur le même domaine :
|
||||
- `ecole-alpha.classeo.fr/` → Frontend (SvelteKit)
|
||||
- `ecole-alpha.classeo.fr/api` → Backend (FrankenPHP)
|
||||
|
||||
### Option recommandée : Caddy (intégré à FrankenPHP)
|
||||
|
||||
```caddyfile
|
||||
# Caddyfile pour production
|
||||
*.classeo.fr {
|
||||
# Certificats SSL automatiques via Let's Encrypt
|
||||
tls {
|
||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
}
|
||||
|
||||
# API routes
|
||||
handle /api/* {
|
||||
reverse_proxy php:8000
|
||||
}
|
||||
|
||||
# Frontend
|
||||
handle {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Alternative : nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name ~^(?<subdomain>.+)\.classeo\.fr$;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/classeo.fr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/classeo.fr/privkey.pem;
|
||||
|
||||
location /api {
|
||||
proxy_pass http://php:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Variables d'environnement Production
|
||||
|
||||
### Backend (.env.prod)
|
||||
|
||||
```env
|
||||
APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=<générer-une-clé-sécurisée>
|
||||
|
||||
TRUSTED_HOSTS=^(.+\.)?classeo\.fr$
|
||||
TRUSTED_PROXIES=REMOTE_ADDR
|
||||
|
||||
TENANT_BASE_DOMAIN=classeo.fr
|
||||
|
||||
DATABASE_URL=postgresql://user:password@db-host:5432/classeo_master
|
||||
|
||||
REDIS_URL=redis://redis-host:6379
|
||||
MESSENGER_TRANSPORT_DSN=amqp://user:password@rabbitmq-host:5672/%2f/messages
|
||||
|
||||
# JWT
|
||||
JWT_PASSPHRASE=<générer-une-passphrase-sécurisée>
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```env
|
||||
PUBLIC_API_URL=https://classeo.fr/api
|
||||
PUBLIC_BASE_DOMAIN=classeo.fr
|
||||
PUBLIC_MERCURE_URL=https://classeo.fr/.well-known/mercure
|
||||
```
|
||||
|
||||
## Certificats SSL
|
||||
|
||||
### Wildcard avec Let's Encrypt + Cloudflare DNS
|
||||
|
||||
Pour les sous-domaines dynamiques, un certificat wildcard est nécessaire :
|
||||
|
||||
```bash
|
||||
# Avec certbot et Cloudflare DNS
|
||||
certbot certonly \
|
||||
--dns-cloudflare \
|
||||
--dns-cloudflare-credentials /etc/cloudflare.ini \
|
||||
-d classeo.fr \
|
||||
-d "*.classeo.fr"
|
||||
```
|
||||
|
||||
### Avec Caddy (automatique)
|
||||
|
||||
Caddy gère automatiquement les certificats wildcard si vous configurez un provider DNS.
|
||||
|
||||
## Base de données par tenant
|
||||
|
||||
Chaque tenant a sa propre base de données PostgreSQL :
|
||||
- `classeo_tenant_alpha`
|
||||
- `classeo_tenant_beta`
|
||||
|
||||
### Création d'un nouveau tenant
|
||||
|
||||
```bash
|
||||
# 1. Créer la base de données
|
||||
php bin/console tenant:database:create classeo_tenant_<nom>
|
||||
|
||||
# 2. Exécuter les migrations
|
||||
php bin/console tenant:migrate <subdomain>
|
||||
|
||||
# 3. Ajouter le tenant au registry (ou en base master)
|
||||
```
|
||||
|
||||
## Options de déploiement
|
||||
|
||||
### 1. VPS simple (petit volume)
|
||||
|
||||
- Un serveur avec Docker Compose
|
||||
- Convient pour < 50 écoles
|
||||
- Coût : ~20-50€/mois
|
||||
|
||||
### 2. Docker Swarm (moyen volume)
|
||||
|
||||
- Plusieurs serveurs avec orchestration
|
||||
- Scaling horizontal
|
||||
- Convient pour 50-500 écoles
|
||||
|
||||
### 3. Kubernetes (grand volume)
|
||||
|
||||
- Orchestration avancée
|
||||
- Auto-scaling
|
||||
- Convient pour 500+ écoles
|
||||
- Coût plus élevé, complexité accrue
|
||||
|
||||
## Checklist de mise en production
|
||||
|
||||
- [ ] Configurer le domaine DNS (wildcard `*.classeo.fr`)
|
||||
- [ ] Obtenir certificat SSL wildcard
|
||||
- [ ] Configurer le reverse proxy (Caddy ou nginx)
|
||||
- [ ] Configurer les variables d'environnement prod
|
||||
- [ ] Générer les clés JWT de production
|
||||
- [ ] Configurer la base de données master
|
||||
- [ ] Créer les bases de données tenant
|
||||
- [ ] Configurer les backups automatiques
|
||||
- [ ] Configurer le monitoring (logs, métriques)
|
||||
- [ ] Tester le déploiement sur un environnement staging
|
||||
- [ ] Configurer CI/CD pour les déploiements automatiques
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Créer un `compose.prod.yaml` pour la production
|
||||
- [ ] Script de création automatique de tenant
|
||||
- [ ] Interface admin pour gérer les tenants
|
||||
- [ ] Monitoring et alerting
|
||||
@@ -9,6 +9,10 @@ FROM node:22-alpine AS base
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
# Configure pnpm to use a directory inside the project (works with volume mounts)
|
||||
ENV PNPM_HOME=/app/.pnpm-store
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -17,15 +21,57 @@ WORKDIR /app
|
||||
# =============================================================================
|
||||
FROM base AS dev
|
||||
|
||||
# Create entrypoint script for dev (installs deps if needed)
|
||||
RUN echo '#!/bin/sh' > /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'set -e' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'if [ ! -d /app/node_modules ] || [ ! -f /app/node_modules/.pnpm/lock.yaml ]; then' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo ' echo "Installing pnpm dependencies..."' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo ' pnpm install' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'fi' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'exec "$@"' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
# Install gosu for proper user switching
|
||||
ENV GOSU_VERSION=1.17
|
||||
RUN set -eux; \
|
||||
apk add --no-cache --virtual .gosu-deps dpkg gnupg; \
|
||||
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||
chmod +x /usr/local/bin/gosu; \
|
||||
gosu --version; \
|
||||
gosu nobody true; \
|
||||
apk del --no-network .gosu-deps
|
||||
|
||||
# Entrypoint: detect host UID/GID and run as matching user
|
||||
# Uses gosu with UID:GID directly (no need to create user in Dockerfile)
|
||||
COPY --chmod=755 <<'EOF' /usr/local/bin/docker-entrypoint.sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Detect UID/GID from mounted /app directory
|
||||
HOST_UID=$(stat -c %u /app)
|
||||
HOST_GID=$(stat -c %g /app)
|
||||
|
||||
# If root owns /app, run as root (CI environment or volume not mounted)
|
||||
if [ "$HOST_UID" = "0" ]; then
|
||||
# Install dependencies if not present
|
||||
if [ ! -d /app/node_modules ] || [ ! -f /app/node_modules/.pnpm/lock.yaml ]; then
|
||||
echo "Installing pnpm dependencies..."
|
||||
pnpm install
|
||||
fi
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Fix node_modules volume ownership (Docker creates volumes as root)
|
||||
# This only takes time on first run when the volume is empty
|
||||
if [ -d /app/node_modules ] && [ "$(stat -c %u /app/node_modules)" = "0" ]; then
|
||||
echo "Fixing node_modules ownership..."
|
||||
chown -R "$HOST_UID:$HOST_GID" /app/node_modules
|
||||
fi
|
||||
|
||||
# Ensure pnpm store directory exists and is writable
|
||||
mkdir -p /app/.pnpm-store
|
||||
chown "$HOST_UID:$HOST_GID" /app/.pnpm-store
|
||||
|
||||
# Install pnpm dependencies if not present (as host user)
|
||||
if [ ! -d /app/node_modules/.pnpm ]; then
|
||||
echo "Installing pnpm dependencies..."
|
||||
gosu "$HOST_UID:$HOST_GID" pnpm install
|
||||
fi
|
||||
|
||||
# Run command as host user via gosu (using UID:GID directly)
|
||||
exec gosu "$HOST_UID:$HOST_GID" "$@"
|
||||
EOF
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
|
||||
62
frontend/src/lib/api/config.ts
Normal file
62
frontend/src/lib/api/config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
/**
|
||||
* Construit l'URL de base de l'API en fonction du hostname actuel.
|
||||
*
|
||||
* En multi-tenant, l'API utilise le même sous-domaine que le frontend
|
||||
* pour garantir l'isolation des données par établissement.
|
||||
*
|
||||
* Exemples :
|
||||
* - Dev: ecole-alpha.classeo.local:5174 -> ecole-alpha.classeo.local:18000/api
|
||||
* - Prod: ecole-alpha.classeo.fr -> ecole-alpha.classeo.fr/api (relative)
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
// Côté browser : toujours utiliser le hostname actuel pour préserver le tenant
|
||||
if (browser) {
|
||||
const { hostname, protocol } = window.location;
|
||||
|
||||
// En prod (pas de port API séparé), utiliser une URL relative
|
||||
// Cela préserve automatiquement le sous-domaine du tenant
|
||||
if (!env['PUBLIC_API_PORT']) {
|
||||
return '/api';
|
||||
}
|
||||
|
||||
// En dev, construire l'URL avec le hostname actuel et le port API
|
||||
const apiPort = env['PUBLIC_API_PORT'];
|
||||
return `${protocol}//${hostname}:${apiPort}/api`;
|
||||
}
|
||||
|
||||
// SSR : utiliser PUBLIC_API_URL si défini, sinon fallback interne
|
||||
if (env['PUBLIC_API_URL']) {
|
||||
return env['PUBLIC_API_URL'];
|
||||
}
|
||||
|
||||
// SSR fallback : communication interne Docker
|
||||
return 'http://php:8000/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le sous-domaine (tenant) du hostname actuel.
|
||||
*
|
||||
* Exemple : ecole-alpha.classeo.local -> ecole-alpha
|
||||
*/
|
||||
export function getCurrentTenant(): string | null {
|
||||
if (!browser) return null;
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const baseDomain = env['PUBLIC_BASE_DOMAIN'] || 'classeo.local';
|
||||
|
||||
if (!hostname.endsWith(baseDomain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subdomain = hostname.replace(`.${baseDomain}`, '');
|
||||
|
||||
// Pas de sous-domaine ou sous-domaine réservé
|
||||
if (!subdomain || subdomain === hostname || subdomain === 'www') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return subdomain;
|
||||
}
|
||||
1
frontend/src/lib/api/index.ts
Normal file
1
frontend/src/lib/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getApiBaseUrl, getCurrentTenant } from './config';
|
||||
@@ -69,6 +69,8 @@ export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
strictPort: true
|
||||
strictPort: true,
|
||||
// Autorise les sous-domaines pour le multi-tenant (dev + prod)
|
||||
allowedHosts: ['.classeo.local', '.classeo.fr', 'localhost']
|
||||
}
|
||||
});
|
||||
|
||||
94
scripts/check-tenants.sh
Executable file
94
scripts/check-tenants.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =============================================================================
|
||||
# Vérifie que les tenants de dev répondent correctement
|
||||
# Usage: ./scripts/check-tenants.sh
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
TENANTS=("ecole-alpha" "ecole-beta")
|
||||
BASE_DOMAIN="classeo.local"
|
||||
API_PORT="18000"
|
||||
FRONTEND_PORT="5174"
|
||||
|
||||
echo "🔍 Vérification des tenants de développement..."
|
||||
echo ""
|
||||
|
||||
# Vérifier /etc/hosts
|
||||
echo "📋 Vérification de /etc/hosts..."
|
||||
MISSING_HOSTS=()
|
||||
for tenant in "${TENANTS[@]}"; do
|
||||
if ! grep -q "${tenant}.${BASE_DOMAIN}" /etc/hosts 2>/dev/null; then
|
||||
MISSING_HOSTS+=("${tenant}.${BASE_DOMAIN}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#MISSING_HOSTS[@]} -ne 0 ]; then
|
||||
echo -e "${YELLOW}⚠️ Entrées manquantes dans /etc/hosts:${NC}"
|
||||
echo ""
|
||||
echo " Ajoutez cette ligne à /etc/hosts :"
|
||||
echo -e "${YELLOW} 127.0.0.1 classeo.local ${MISSING_HOSTS[*]}${NC}"
|
||||
echo ""
|
||||
echo " Commande :"
|
||||
echo " sudo sh -c 'echo \"127.0.0.1 classeo.local ${MISSING_HOSTS[*]}\" >> /etc/hosts'"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ /etc/hosts configuré correctement${NC}"
|
||||
echo ""
|
||||
|
||||
# Vérifier que les containers tournent
|
||||
echo "🐳 Vérification des containers..."
|
||||
if ! docker compose ps --status running | grep -q "classeo_php"; then
|
||||
echo -e "${RED}✗ Container PHP non démarré. Lancez 'make up'${NC}"
|
||||
exit 1
|
||||
fi
|
||||
if ! docker compose ps --status running | grep -q "classeo_frontend"; then
|
||||
echo -e "${RED}✗ Container Frontend non démarré. Lancez 'make up'${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ Containers démarrés${NC}"
|
||||
echo ""
|
||||
|
||||
# Vérifier les endpoints
|
||||
echo "🌐 Vérification des endpoints..."
|
||||
ERRORS=0
|
||||
|
||||
for tenant in "${TENANTS[@]}"; do
|
||||
# API
|
||||
API_URL="http://${tenant}.${BASE_DOMAIN}:${API_PORT}/api/docs"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$API_URL" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo -e "${GREEN}✓ API ${tenant}: ${API_URL}${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ API ${tenant}: ${API_URL} (HTTP ${HTTP_CODE})${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL="http://${tenant}.${BASE_DOMAIN}:${FRONTEND_PORT}/"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$FRONTEND_URL" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo -e "${GREEN}✓ Front ${tenant}: ${FRONTEND_URL}${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Front ${tenant}: ${FRONTEND_URL} (HTTP ${HTTP_CODE})${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [ $ERRORS -ne 0 ]; then
|
||||
echo -e "${RED}❌ ${ERRORS} erreur(s) détectée(s)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Tous les tenants répondent correctement !${NC}"
|
||||
Reference in New Issue
Block a user