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
|
# Default target
|
||||||
help:
|
help:
|
||||||
@@ -7,6 +7,8 @@ help:
|
|||||||
@echo "Docker:"
|
@echo "Docker:"
|
||||||
@echo " make up - Lancer tous les services"
|
@echo " make up - Lancer tous les services"
|
||||||
@echo " make down - Arreter 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 build - Reconstruire les images"
|
||||||
@echo " make logs - Voir les logs (Ctrl+C pour quitter)"
|
@echo " make logs - Voir les logs (Ctrl+C pour quitter)"
|
||||||
@echo " make ps - Statut des services"
|
@echo " make ps - Statut des services"
|
||||||
@@ -14,8 +16,10 @@ help:
|
|||||||
@echo ""
|
@echo ""
|
||||||
@echo "Backend:"
|
@echo "Backend:"
|
||||||
@echo " make phpstan - Analyse statique PHPStan"
|
@echo " make phpstan - Analyse statique PHPStan"
|
||||||
|
@echo " make arch - Tests d'architecture (PHPat)"
|
||||||
@echo " make cs-fix - Correction code style PHP"
|
@echo " make cs-fix - Correction code style PHP"
|
||||||
@echo " make test-php - Tests PHPUnit"
|
@echo " make test-php - Tests PHPUnit"
|
||||||
|
@echo " make warmup - Warmup du cache Symfony"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Frontend:"
|
@echo "Frontend:"
|
||||||
@echo " make lint - ESLint frontend"
|
@echo " make lint - ESLint frontend"
|
||||||
@@ -36,6 +40,15 @@ up:
|
|||||||
down:
|
down:
|
||||||
docker compose 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:
|
build:
|
||||||
docker compose build --no-cache
|
docker compose build --no-cache
|
||||||
|
|
||||||
@@ -55,6 +68,9 @@ clean:
|
|||||||
phpstan:
|
phpstan:
|
||||||
docker compose exec php composer phpstan
|
docker compose exec php composer phpstan
|
||||||
|
|
||||||
|
arch:
|
||||||
|
docker compose exec php composer arch
|
||||||
|
|
||||||
cs-fix:
|
cs-fix:
|
||||||
docker compose exec php composer cs-fix
|
docker compose exec php composer cs-fix
|
||||||
|
|
||||||
@@ -64,6 +80,9 @@ cs-check:
|
|||||||
test-php:
|
test-php:
|
||||||
docker compose exec php composer test
|
docker compose exec php composer test
|
||||||
|
|
||||||
|
warmup:
|
||||||
|
docker compose exec php php bin/console cache:warmup
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Frontend
|
# Frontend
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -97,3 +116,6 @@ check-bc:
|
|||||||
|
|
||||||
check-naming:
|
check-naming:
|
||||||
./scripts/check-naming.sh
|
./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+
|
- Docker Desktop 24+ avec Docker Compose 2.20+
|
||||||
- Git
|
- 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
|
### Lancement
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -21,15 +29,27 @@ docker compose up -d
|
|||||||
|
|
||||||
# Verifier le statut
|
# Verifier le statut
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
|
# Verifier que les tenants répondent
|
||||||
|
make check-tenants
|
||||||
```
|
```
|
||||||
|
|
||||||
### URLs
|
### 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 |
|
| 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) |
|
| RabbitMQ | http://localhost:15672 | Admin (guest/guest) |
|
||||||
| Meilisearch | http://localhost:7700 | Dashboard recherche |
|
| Meilisearch | http://localhost:7700 | Dashboard recherche |
|
||||||
| Mailpit | http://localhost:8025 | Emails de test |
|
| 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_ENV=dev
|
||||||
APP_SECRET=change_me_in_production_12345678
|
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_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 ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
###> doctrine/doctrine-bundle ###
|
###> doctrine/doctrine-bundle ###
|
||||||
@@ -52,3 +52,14 @@ MEILISEARCH_API_KEY=masterKey
|
|||||||
###> symfony/mailer ###
|
###> symfony/mailer ###
|
||||||
MAILER_DSN=smtp://mailpit:1025
|
MAILER_DSN=smtp://mailpit:1025
|
||||||
###< symfony/mailer ###
|
###< 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/
|
/public/bundles/
|
||||||
|
|
||||||
# Fichiers auto-générés par Symfony
|
# Fichiers auto-générés par Symfony
|
||||||
/config/bundles.php
|
|
||||||
/config/preload.php
|
/config/preload.php
|
||||||
/config/reference.php
|
/config/reference.php
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ phpstan.neon.dist
|
|||||||
# Composer
|
# Composer
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
composer.phar
|
composer.phar
|
||||||
composer.lock
|
|
||||||
|
|
||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
/.env.local
|
/.env.local
|
||||||
@@ -70,3 +68,14 @@ composer.lock
|
|||||||
/phpunit.xml
|
/phpunit.xml
|
||||||
/.phpunit.cache/
|
/.phpunit.cache/
|
||||||
###< phpunit/phpunit ###
|
###< 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
|
// Exclusions spécifiques
|
||||||
->notPath('src/Shared/Domain/AggregateRoot.php')
|
->notPath('src/Shared/Domain/AggregateRoot.php')
|
||||||
->notPath('src/Shared/Domain/EntityId.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())
|
return (new PhpCsFixer\Config())
|
||||||
@@ -25,7 +27,7 @@ return (new PhpCsFixer\Config())
|
|||||||
'array_syntax' => ['syntax' => 'short'],
|
'array_syntax' => ['syntax' => 'short'],
|
||||||
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
||||||
'no_unused_imports' => true,
|
'no_unused_imports' => true,
|
||||||
'not_operator_with_successor_space' => true,
|
'not_operator_with_successor_space' => false,
|
||||||
'trailing_comma_in_multiline' => true,
|
'trailing_comma_in_multiline' => true,
|
||||||
'phpdoc_order' => true,
|
'phpdoc_order' => true,
|
||||||
'phpdoc_separation' => true,
|
'phpdoc_separation' => true,
|
||||||
@@ -52,6 +52,17 @@ RUN echo "opcache.enable=1" >> "$PHP_INI_DIR/conf.d/opcache.ini" \
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS dev
|
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)
|
# 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"
|
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 SERVER_NAME=:8000
|
||||||
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
|
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
|
||||||
|
|
||||||
# Create entrypoint script for dev (installs deps if needed)
|
# Entrypoint: detect host UID/GID and run as matching user
|
||||||
RUN echo '#!/bin/sh' > /usr/local/bin/docker-entrypoint.sh && \
|
# Uses gosu with UID:GID directly (no need to create user in Dockerfile)
|
||||||
echo 'set -e' >> /usr/local/bin/docker-entrypoint.sh && \
|
COPY --chmod=755 <<'EOF' /usr/local/bin/docker-entrypoint.sh
|
||||||
echo 'if [ ! -f /app/vendor/autoload.php ]; then' >> /usr/local/bin/docker-entrypoint.sh && \
|
#!/bin/sh
|
||||||
echo ' echo "Installing Composer dependencies..."' >> /usr/local/bin/docker-entrypoint.sh && \
|
set -e
|
||||||
echo ' composer install --prefer-dist --no-progress --no-interaction' >> /usr/local/bin/docker-entrypoint.sh && \
|
|
||||||
echo 'fi' >> /usr/local/bin/docker-entrypoint.sh && \
|
# Detect UID/GID from mounted /app directory
|
||||||
echo 'mkdir -p var/cache var/log && chmod -R 777 var' >> /usr/local/bin/docker-entrypoint.sh && \
|
HOST_UID=$(stat -c %u /app)
|
||||||
echo 'exec "$@"' >> /usr/local/bin/docker-entrypoint.sh && \
|
HOST_GID=$(stat -c %g /app)
|
||||||
chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
||||||
|
# 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
|
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": {
|
"require-dev": {
|
||||||
"doctrine/doctrine-fixtures-bundle": "^4.0",
|
"doctrine/doctrine-fixtures-bundle": "^4.0",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.65",
|
||||||
|
"phpat/phpat": "*",
|
||||||
"phpstan/phpstan": "^2.0",
|
"phpstan/phpstan": "^2.0",
|
||||||
"phpstan/phpstan-doctrine": "^2.0",
|
"phpstan/phpstan-doctrine": "^2.0",
|
||||||
"phpstan/phpstan-symfony": "^2.0",
|
"phpstan/phpstan-symfony": "^2.0",
|
||||||
@@ -48,8 +50,7 @@
|
|||||||
"symfony/maker-bundle": "^1.62",
|
"symfony/maker-bundle": "^1.62",
|
||||||
"symfony/phpunit-bridge": "^8.0",
|
"symfony/phpunit-bridge": "^8.0",
|
||||||
"symfony/stopwatch": "^8.0",
|
"symfony/stopwatch": "^8.0",
|
||||||
"symfony/web-profiler-bundle": "^8.0",
|
"symfony/web-profiler-bundle": "^8.0"
|
||||||
"friendsofphp/php-cs-fixer": "^3.65"
|
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
@@ -57,7 +58,8 @@
|
|||||||
"symfony/flex": true,
|
"symfony/flex": true,
|
||||||
"symfony/runtime": true
|
"symfony/runtime": true
|
||||||
},
|
},
|
||||||
"sort-packages": true
|
"sort-packages": true,
|
||||||
|
"process-timeout": 600
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@@ -84,6 +86,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"auto-scripts": {
|
"auto-scripts": {
|
||||||
"cache:clear": "symfony-cmd",
|
"cache:clear": "symfony-cmd",
|
||||||
|
"cache:warmup": "symfony-cmd",
|
||||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||||
},
|
},
|
||||||
"post-install-cmd": [
|
"post-install-cmd": [
|
||||||
@@ -94,8 +97,10 @@
|
|||||||
],
|
],
|
||||||
"test": "phpunit",
|
"test": "phpunit",
|
||||||
"phpstan": "phpstan analyse --memory-limit=512M",
|
"phpstan": "phpstan analyse --memory-limit=512M",
|
||||||
|
"arch": "phpstan analyse tests/Architecture --configuration=phpstan.neon --level=1",
|
||||||
"cs-fix": "php-cs-fixer fix",
|
"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": {
|
"conflict": {
|
||||||
"symfony/symfony": "*"
|
"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
|
# 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
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
|
tenant.base_domain: '%env(TENANT_BASE_DOMAIN)%'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# default configuration for services in this file
|
# default configuration for services in this file
|
||||||
@@ -25,3 +26,20 @@ services:
|
|||||||
# Domain services need to be registered explicitly to avoid framework coupling
|
# Domain services need to be registered explicitly to avoid framework coupling
|
||||||
# Example: App\Administration\Application\Command\:
|
# Example: App\Administration\Application\Command\:
|
||||||
# resource: '../src/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
|
level: 9
|
||||||
paths:
|
paths:
|
||||||
- src
|
- src
|
||||||
|
- tests/Architecture
|
||||||
excludePaths:
|
excludePaths:
|
||||||
- src/Kernel.php
|
- src/Kernel.php
|
||||||
treatPhpDocTypesAsCertain: false
|
treatPhpDocTypesAsCertain: false
|
||||||
@@ -10,3 +11,4 @@ parameters:
|
|||||||
includes:
|
includes:
|
||||||
- vendor/phpstan/phpstan-doctrine/extension.neon
|
- vendor/phpstan/phpstan-doctrine/extension.neon
|
||||||
- vendor/phpstan/phpstan-symfony/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
|
dockerfile: Dockerfile
|
||||||
target: dev
|
target: dev
|
||||||
container_name: classeo_php
|
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:
|
environment:
|
||||||
APP_ENV: dev
|
# Overrides pour Docker : les hostnames des services utilisent les noms
|
||||||
APP_DEBUG: 1
|
# des containers (db, redis, rabbitmq...) au lieu de localhost
|
||||||
DATABASE_URL: postgresql://classeo:classeo@db:5432/classeo_master?serverVersion=18&charset=utf8
|
DATABASE_URL: postgresql://classeo:classeo@db:5432/classeo_master?serverVersion=18&charset=utf8
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
MESSENGER_TRANSPORT_DSN: amqp://guest:guest@rabbitmq:5672/%2f/messages
|
MESSENGER_TRANSPORT_DSN: amqp://guest:guest@rabbitmq:5672/%2f/messages
|
||||||
MERCURE_URL: http://mercure/.well-known/mercure
|
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_URL: http://meilisearch:7700
|
||||||
MEILISEARCH_API_KEY: masterKey
|
|
||||||
MAILER_DSN: smtp://mailpit:1025
|
MAILER_DSN: smtp://mailpit:1025
|
||||||
ports:
|
ports:
|
||||||
- "18000:8000" # Port externe 18000 pour eviter conflit
|
- "18000:8000" # Port externe 18000 pour eviter conflit
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app:cached
|
- ./backend:/app:cached
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -49,7 +55,10 @@ services:
|
|||||||
target: dev
|
target: dev
|
||||||
container_name: classeo_frontend
|
container_name: classeo_frontend
|
||||||
environment:
|
environment:
|
||||||
|
# URL de fallback, sera remplacée dynamiquement par le hostname en multi-tenant
|
||||||
PUBLIC_API_URL: http://localhost:18000/api
|
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
|
PUBLIC_MERCURE_URL: http://localhost:3000/.well-known/mercure
|
||||||
ports:
|
ports:
|
||||||
- "5174:5173" # Port externe 5174 pour eviter conflit
|
- "5174:5173" # Port externe 5174 pour eviter conflit
|
||||||
@@ -197,3 +206,5 @@ volumes:
|
|||||||
rabbitmq_data:
|
rabbitmq_data:
|
||||||
meilisearch_data:
|
meilisearch_data:
|
||||||
frontend_node_modules:
|
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
|
# Install pnpm
|
||||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
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
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -17,15 +21,57 @@ WORKDIR /app
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS dev
|
FROM base AS dev
|
||||||
|
|
||||||
# Create entrypoint script for dev (installs deps if needed)
|
# Install gosu for proper user switching
|
||||||
RUN echo '#!/bin/sh' > /usr/local/bin/docker-entrypoint.sh && \
|
ENV GOSU_VERSION=1.17
|
||||||
echo 'set -e' >> /usr/local/bin/docker-entrypoint.sh && \
|
RUN set -eux; \
|
||||||
echo 'if [ ! -d /app/node_modules ] || [ ! -f /app/node_modules/.pnpm/lock.yaml ]; then' >> /usr/local/bin/docker-entrypoint.sh && \
|
apk add --no-cache --virtual .gosu-deps dpkg gnupg; \
|
||||||
echo ' echo "Installing pnpm dependencies..."' >> /usr/local/bin/docker-entrypoint.sh && \
|
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||||
echo ' pnpm install' >> /usr/local/bin/docker-entrypoint.sh && \
|
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||||
echo 'fi' >> /usr/local/bin/docker-entrypoint.sh && \
|
chmod +x /usr/local/bin/gosu; \
|
||||||
echo 'exec "$@"' >> /usr/local/bin/docker-entrypoint.sh && \
|
gosu --version; \
|
||||||
chmod +x /usr/local/bin/docker-entrypoint.sh
|
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
|
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: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
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