Le blog

Construire une application multi-tenant avec API Platform, Symfony et Doctrine (partie 2)

Publié le 11 juin 2024

Dans cet article nous allons voir comment nous avons implémenté une architecture multi-tenant dans une application utilisant Symfony, Doctrine et API Platform. Si vous l'avez manquée, la première partie de cet article se trouve ici. Nous vous recommandons d’aller la consulter afin de comprendre le contexte et les objectifs fixés.

#

Rappel

Notre mission est de proposer à nos agences la possibilité de gérer leurs stocks et garder des traces des livres tout en offrant le cloisonnement des données. Nous allons exploiter les vues ainsi que les tables étrangères offertes par PostgreSQL pour cocher tous nos objectifs :

  • Base de données relationnelle en utilisant PostgreSQL comme SGBD.
  • Isoler les livres des agences dans une base de données propre à chacune. 
  • Garantir le même état structurel de toutes les bases de données en respectant les propriétés ACID sur nos migrations.
  • Utiliser Doctrine pour générer et jouer les migrations des entités de notre application.
  • En tant que chef d’agence identifié par le rôle CLIENT_ADMIN nous pouvons ajouter des sous-utilisateurs qui sont liés à notre base de données.
  • En tant que super utilisateur, nous avons accès à toutes les données de toutes les agences.
  • Utiliser API Platform (avec la couche Symfony).
  • Cloisonner les utilisateurs lors de la connexion à l’application.
#

Théorie générale

A l’instar des serveurs privés virtuels, nous avons cherché une solution qui permettrait de séparer les données de manière virtuelle plutôt que physique.

Schema des différents serveurs privés" class="wp-image-9169" style="aspect-ratio:3/2;object-fit:cover

Dans notre cas, le serveur physique est notre base de données, l’hyperviseur est notre application Symfony et les serveurs virtuels sont nos agences.

Le principal avantage de cette solution c’est que toutes les données se trouvent au même endroit, c’est notre application Symfony qui se chargera de donner aux utilisateurs les ressources lui étant attribuées.

Afin de faciliter le découpage des données, nous avons créé une vue pour chaque table, contenant uniquement les données de l'utilisateur. Les vues nous permettent de créer des “alias” de requêtes, par exemple : 

CREATE VIEW livres_u1 AS SELECT * FROM livres WHERE owner = 'u1');

Maintenant, à chaque fois que j’exécuterai la requête SELECT * FROM livres_u1, ça sera la requête ci-dessus qui sera exécutée sur la table livres et me retourne uniquement les ressources de l’utilisateur u1.

Attention : cette utilisation implique que chaque entité de notre application doit posséder un attribut permettant d’en identifier son propriétaire (modélisé ici par une colonne owner étant l’identifiant de l’agence lié à la ressource).

Sous forme de diagramme, voilà ce que cela représenterait :

Diagramme des tables" class="wp-image-9171" style="aspect-ratio:3/2;object-fit:cover

À présent, il nous faut un moyen de rendre les vues accessibles par notre programme. En effet, il faut garder en tête qu’un appel à $livresRepository->findAll() s'exécutera sur notre table livres et non livres_u1 ou livres_u2.

L’une de nos contraintes étant d’avoir une base de données pour chaque agence, nous avons utilisé l’extension postgres_fdw qui permet d’accéder aux données d’une autre base, en l'occurrence ici, notre base "commune". 

L’extension va alors nous permettre de créer des tables étrangères dans les bases de nos agences qui seront en fait, une référence vers nos vues.

Diagramme des vues" class="wp-image-9172" style="aspect-ratio:3/2;object-fit:cover

Lors de la connexion d’un utilisateur, on utilisera la méthode changeDatabase que nous avons détaillé dans le premier article afin d’utiliser la base de données lui étant liée. Dans le cas de l’utilisateur u1, Doctrine effectuera ses requêtes sur la base de données client_u1.
Ainsi, un appel à $livresRepository->findAll() me retournera alors les données présentes dans la table étrangère ci-dessus.

#

Mise en pratique

Nous avons une belle théorie, cependant, tant que nous n’avons pas implémenté la solution : comment savoir si ça fonctionne et en identifier les limites ? Le projet utilisé pour la suite des explications est disponible sur notre Github.

#
Gestion des utilisateurs

Dans notre application nous avons 3 types d’utilisateurs identifié par leur rôle : 

  • SUPER_ADMIN 
  • CLIENT_ADMIN (agence / chef d’agence)
  • USER (employé d’une agence)

Lorsqu’un utilisateur anonyme s’enregistre depuis le formulaire d'inscription, il aura par défaut le rôle CLIENT_ADMIN et servira d’agence. Si ce même utilisateur crée un autre utilisateur depuis son espace client, il aura le rôle USER (employé) et lui sera rattaché.

Comme vu précédemment, nous avons besoin de créer une base de données pour chaque agence, ses employés ayant accès aux mêmes ressources, ils utiliseront la même base. Pour savoir sur quelle base de données se connecter quand un utilisateur demande des ressources, on va stocker cette information à la création de notre utilisateur : 

#[AsEntityListener(event: Events::postPersist, method: 'postPersist', entity: User::class)]
class UserPostPersistListener
{
	public function postPersist(User $user, PostPersistEventArgs $event): void
	{
    	$sql_id = str_replace('-', '_', $user->getOwner()->getId() ?: $user->getId());
    	$user->setSqlUserName("user_{$sql_id}");
    	$user->setSqlDbName("client_{$sql_id}");

    	$event->getObjectManager()->flush();
	}	
}

Si l’utilisateur enregistré est lié à une agence (présence d’un owner), on utilise l’identifiant de son parent, sinon, on utilise la sienne (il est donc lui-même une agence). Maintenant que nous avons géré l’inscription, nous devons changer la base de données utilisée par Doctrine en fonction de l’utilisateur qui se connecte. 

Dans notre cas précis, l’utilisation du protocole HTTP de manière stateless nous sera d’une grande aide. En effet, nous pouvons nous brancher à l’événement kernel.request afin de modifier la base de données utilisée pour la suite des opérations de celle-ci.

#[AsEventListener(event: 'kernel.request', method: 'onKernelRequest', priority: 999)]
class KernelRequestEvent
{
	public function __construct(private readonly TokenStorageInterface $tokenStorage, private readonly ManagerRegistry $registry, private readonly Security $security)
	{
	}

	public function onKernelRequest(RequestEvent $event): void
	{
    	$token = $this->tokenStorage->getToken();
    	$user = $token->getUser();

    	if(!$user) { return };
    	if($this->security->isGranted('ROLE_SUPER_ADMIN')) { return };

    	$doctrineConnection = $this->registry->getConnection('default');
    	$doctrineConnection->changeDatabase([
        	'dbname' => $user->getSqlDbName(),
        	'user' => $user->getSqlUserName(),
        	'password' => 'monsupermotdepasse',
    	]);
	}
}

Pour mieux comprendre ce qu’il se passe, voici un schéma illustrant le fonctionnement de ce bout de code : 

schema d'explication de code" class="wp-image-9175

La connexion étant modifiée avant son arrivée au contrôleur, les requêtes effectuées par notre application se feront sur la base de données de notre utilisateur interrogeant de ce fait les tables étrangères que nous avons vu ci-dessus. 

Voici le cheminement fait par notre controlleur utilisant $livresRepository->findAll() après une requête envoyé par notre utilisateur u3 (employé de u1) préalablement connecté : 

" class="wp-image-9176#
Création des bases de données

Maintenant que nous pouvons créer des utilisateurs et gérer la base de données utilisée par Doctrine dans l’application, nous devons créer ces bases ! Tous les utilisateurs ayant besoin des mêmes entités, nous nous ne poserons pas la question de savoir quelles tables dupliquer mais plutôt quelles lignes dans ces tables.

Nous créerons les bases selon cette procédure : 

  • Construire une vue par table contenant les données de l’agence sur la base common
  • Installer l’extension postgres_fdw sur la base client et enregistrer une connexion vers common
  • Importer les vues de l’utilisateur présentes dans common vers sa base de données.

Cette procédure se lancera dans deux cas différents : 

  • À la création d’une nouvelle agence (CLIENT_ADMIN)
  • À la fin de l’exécution d’une migration (si elle est réussie)

Pour faciliter ces opérations, nous allons définir un service afin de pouvoir réutiliser cette logique facilement à plusieurs endroits.

function createViewsForUser(User $user): bool
{
	$this->connection->changeDatabase(['dbname' => 'app']);
	$username = $user->getSqlUserName();
	$id = $user->getId();
	
// $this->tables est un tableau contenant le nom des tables récupéré depuis le mapping de notre EntityManager
	foreach ($this->tables as $name => $options) {
    	$view_name = "{$name}_" . $this->generatePsqlIdForUser($user);

    	$this->connection->executeStatement("
            	DROP VIEW IF EXISTS {$view_name};
            	CREATE VIEW {$view_name} AS SELECT * FROM {$name} WHERE owner_id = '{$id}';
        	");
	}
}
function createForeignTablesForUser(User $user): bool
{
	$this->createViewsForUser($user);
	$username = $user->getSqlUserName();
$this->connection->changeDatabase(['dbname' => $user->getSqlDbName()]);

	$this->connection->executeStatement("
        	CREATE EXTENSION IF NOT EXISTS postgres_fdw;
        	CREATE SERVER IF NOT EXISTS app_fdw FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host '127.0.0.1', port '5432', dbname 'app');
    	");

	foreach ($this->tables as $name => $options) {
    	$view_name = "{$name}_" . $this->generatePsqlIdForUser($user);
    	$this->connection->executeStatement("
            	IMPORT FOREIGN SCHEMA public LIMIT TO ({$view_name})
            	FROM SERVER app_fdw INTO public;
            	ALTER TABLE {$view_name} RENAME TO {$name};
        ");
	}
}

Schématiquement, voici ce qu’il se passe à la création d'une agence (appartenant à une personne que nous nommerons Alice) :

schema de la création d'une agence" class="wp-image-9177

On peut maintenant créer une commande personnalisée qui permettra de lancer cette procédure : 

class GenerateForeignsCommand extends Command
{
	public function __construct(
    		private readonly UserRepository $userRepository,
    		private readonly multi-tenantDatabaseHandler $tenancyHandler,
	) {
    		parent::__construct();
	}

	#[\Override]
	protected function execute(InputInterface $input, OutputInterface $output): int
	{
    	$users = $this->userRepository->findClientsAdmin();
    	foreach ($users as $user) {
        	$this->tenancyHandler->createForeignTablesForUser($user);
    	}
	}
}

Et enfin le branchement à la commande de migration afin de mettre à jour les tables étrangères de nos agences lors d’une modification du schéma : 

class MigrationCommandListener
{
	public function __construct(private KernelInterface $kernel) {}
	public function __invoke(ConsoleTerminateEvent $event): void
	{
    	if ('doctrine:migrations:migrate' == $event->getCommand()->getName() && 0 == $event->getExitCode()) {
        	$application = new Application($this->kernel);
        	$input = new ArrayInput(['command' => 'app:database:foreigns']);
        	$application->run($input, new NullOutput());
    	}
	}
}

🎊 Youpi ! Nous avons une implémentation d’architecture multi-tenant compatible nativement avec notre application Symfony et API Platform !

Récapitulons ce que nous avons mis en place : 

  • Centralisation des ressources sur une base de données commune avec un cloisonnement virtuel en utilisant des vues SQL.
  • Gestion de la création d’un utilisateur : création de ses identifiants SQL, génération de ses vues ainsi que de sa base de données avec l’import des vues/tables étrangères.
  • Gestion des migrations permettant de modifier la structure de notre base commune avec re-génération des bases clients.

Dans une troisième et dernière partie disponible prochainement, nous testerons et analyserons l’implémentation de cette solution.

Le blog

Pour aller plus loin