Slide 1

Slide 1 text

@tucksaun / [email protected] Tugdual Saunier Multi-tenant applications using Symfony, for real? SymfonyCon Brussels - 2023/12/07

Slide 2

Slide 2 text

Tugdual Saunier / @tucksaun / [email protected] Multi-tenancy https://www.flickr.com/photos/hutchslr/32745462630/

Slide 3

Slide 3 text

Tugdual Saunier / @tucksaun / [email protected] “Software multitenancy is a software architecture in which a single instance of software runs on a server and serves multiple tenants. […] A tenant is a group of users who share a common access with specific privileges to the software instance.” - Wikipedia

Slide 4

Slide 4 text

Tugdual Saunier / @tucksaun / [email protected] “Software multitenancy is a software architecture in which a single instance of software runs on a server and serves multiple tenants. […] A tenant is a group of users who share a common access with specific privileges to the software instance.” - Wikipedia !"

Slide 5

Slide 5 text

Tugdual Saunier / @tucksaun / [email protected] https://www.123rf.com/photo_215004150_generative-ai-row-of-high-school-lockers-in-the-hallway-locker-room.html Multitenancy purpose: Data isolation

Slide 6

Slide 6 text

Tugdual Saunier / @tucksaun / [email protected] But wait… Isn't it what you are probably already doing in your applications? #

Slide 7

Slide 7 text

Tugdual Saunier / @tucksaun / [email protected] So why the apprehension? https://www.youtube.com/watch?v=D3tcksIUqCY

Slide 8

Slide 8 text

Tugdual Saunier / @tucksaun / [email protected] Multitenancy purpose: True Data isolation https://pngtree.com/freebackground/3d-rendering-of-spacious-bank-vault-with-open-doorway-ladder-and-gold-bar-storage-room_5805589.html

Slide 9

Slide 9 text

Tugdual Saunier / @tucksaun / [email protected] https://www.flickr.com/photos/denniswong/3743590776 Multitenancy purpose: True Convenient Data isolation

Slide 10

Slide 10 text

Tugdual Saunier / @tucksaun / [email protected] A complex architecture ? https://www.123rf.com/photo_139321615_dubai-uae-jan-20-2019-the-building-of-the-museum-of-the-future-one-of-the-world-s-most-advanced-buil.html

Slide 11

Slide 11 text

Tugdual Saunier / @tucksaun / [email protected] Traditional implementations https://pngtree.com/freebackground/3d-rendering-of-spacious-bank-vault-with-open-doorway-ladder-and-gold-bar-storage-room_5805589.html

Slide 12

Slide 12 text

Tugdual Saunier / @tucksaun / [email protected] https://www.iconbuild.com/media-gallery/icon-and-tmd-unveil-3d-printed-barracks-largest-3d-printed-structure-in-north-america Single deployment

Slide 13

Slide 13 text

Tugdual Saunier / @tucksaun / [email protected] Symfony application User A User B Data source my_entity id tenant_id foo tenants id slug 1 n User C

Slide 14

Slide 14 text

Tugdual Saunier / @tucksaun / [email protected] # src/Tenancy/ORMFilter.php namespace App\Tenancy; class ORMFilter extends \Doctrine\ORM\Query\Filter\SQLFilter { public function addFilterConstraint( \Doctrine\ORM\Mapping\ClassMetadata $targetEntity, $targetTableAlias ): string { if (!$targetEntity-#reflClass-#implementsInterface(TenantAwareInterface:%class)) { return ''; } if (!$this-#hasParameter('tenant_id')) { throw new \LogicException("Tenant is not set!"); } return sprintf('%s.tenant_id = %s', $targetTableAlias, $this-#getParameter('tenant_id')); } public function setTenant(TenantInterface $tenant) { $this-#setParameter('tenant_id', $tenant-#getId()); } }

Slide 15

Slide 15 text

Tugdual Saunier / @tucksaun / [email protected] # src/Tenancy/RequestListener.php namespace App\Tenancy; #"AsEventListener] final class RequestListener { public function __construct( private Resolver $resolver, private EntityManagerInterface $em ) {} public function __invoke(RequestEvent $event): void { $slug = current(explode('.', $event-#getRequest()-#getHost())); if (!$tenant = $this-#resolver-#getTenant($slug)) { throw new NotFoundHttpException("Tenant not found"); } $this-#em-#getFilters()-#getFilter('tenancy')-#setTenant($tenant); } }

Slide 16

Slide 16 text

Tugdual Saunier / @tucksaun / [email protected] Pros ✔ Easy to start with and reason about ✔ Cross-tenant reporting or manipulation is easy Cons ✘ Complexify the Code ✘ Bug/data leak risk ✘ Does not meet most privacy or data isolation regulation

Slide 17

Slide 17 text

Tugdual Saunier / @tucksaun / [email protected] Isolated deployment https://www.the-sun.com/news/us-news/1975759/worlds-most-isolated-houses-perfect-spot-peace-quiet/

Slide 18

Slide 18 text

Tugdual Saunier / @tucksaun / [email protected] Symfony application User A User B User C Symfony application Data source my_entity id foo Data source my_entity id foo Data source my_entity id foo Symfony application Isolation Isolation Isolation

Slide 19

Slide 19 text

Tugdual Saunier / @tucksaun / [email protected] Pros ✔ Easy to start with and reason about ✔ Code remains simple: just a different deployment Cons ✕ Does not scale

Slide 20

Slide 20 text

Tugdual Saunier / @tucksaun / [email protected] Fleet deployment https://droneawards.photo/gallery/photo/243821

Slide 21

Slide 21 text

Tugdual Saunier / @tucksaun / [email protected] User A User B User C Symfony application Symfony application Data source my_entity id foo Data source my_entity id foo Data source my_entity id foo Symfony application Vendor Network LB

Slide 22

Slide 22 text

Tugdual Saunier / @tucksaun / [email protected] Pros ✔ Easy to start with and reason about ✔ Code remains simple: just a different deployment Cons ✘ Inefficient (instances are deployed and running even if they are not used) ✘ Harder to manage on the Ops side

Slide 23

Slide 23 text

Tugdual Saunier / @tucksaun / [email protected] A tough choice https://www.123rf.com/photo_99716683_zen-basalt-stones-on-background.html

Slide 24

Slide 24 text

Tugdual Saunier / @tucksaun / [email protected] Complex Code or Complex Infrastructure

Slide 25

Slide 25 text

Tugdual Saunier / @tucksaun / [email protected] Single code deployment https://sygrove.com/westchester-county-2/

Slide 26

Slide 26 text

Tugdual Saunier / @tucksaun / [email protected] But you said single deployment was not safe!

Slide 27

Slide 27 text

Tugdual Saunier / @tucksaun / [email protected] But is (mostly)

Slide 28

Slide 28 text

Tugdual Saunier / @tucksaun / [email protected] But you said it does not solve data isolation…

Slide 29

Slide 29 text

Tugdual Saunier / @tucksaun / [email protected] But can help

Slide 30

Slide 30 text

Tugdual Saunier / @tucksaun / [email protected] Vendor Network User A User B User C Symfony application Data source my_entity id foo Data source my_entity id foo Data source my_entity id foo Webserver Data source my_entity id foo Data source my_entity id foo

Slide 31

Slide 31 text

Tugdual Saunier / @tucksaun / [email protected] # /etc/nginx/sites-enabled/my-saas.conf server { server_name *.my-saas.wip _; root /srv/app/current/public; # … include /etc/nginx/tenants_mapping; location ~ ^/index\.php(/|$) { fastcgi_pass 127.0.0.1:9000; fastcgi_split_path_info ^(.+\.php)(/.*)$; include fastcgi_params; fastcgi_param APP_ENV prod; fastcgi_param DATABASE_URL $app_database_url; } } # /etc/nginx/tenants_mapping; map $http_host $app_database_url { hostnames; tenant-a.my-saas.wip "mysql:/+db_user:db_pass@localhost:3306/tenant-a"; tenant-b.my-saas.wip "mysql:/+foo:[email protected]:3306/my_saas"; }

Slide 32

Slide 32 text

Tugdual Saunier / @tucksaun / [email protected] # config/packages/doctrine.yaml doctrine: dbal: url: '%env(resolve:tenant:DATABASE_URL)%' # src/Tenancy/TenantEnvVarProcessor.php namespace App\Tenancy; final class TenantEnvVarProcessor implements EnvVarProcessorInterface { public function getEnv(string $prefix, string $name, \Closure $getEnv): string { return $this-#settings-#get($name); } public static function getProvidedTypes(): array { return [ 'tenant' =- 'string', ]; } }

Slide 33

Slide 33 text

Tugdual Saunier / @tucksaun / [email protected] # config/packages/doctrine.yaml doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' # src/Tenancy/TenantEnvVarLoader.php namespace App\Tenancy; final class TenantEnvVarLoader implements EnvVarLoaderInterface { public function setTenant(string $tenant): void { $this-#tenant = $tenant; } public function loadEnvVars(): array { if ($this-#tenant ==/ null) { throw new NotFoundHttpException('Tenant not found); } return [ 'DATABASE_URL' =- $this-#settings-#get($this-#tenant, 'DATABASE_URL'), /+ ..1 ]; } }

Slide 34

Slide 34 text

Tugdual Saunier / @tucksaun / [email protected] Works well within an HTTP context (but …)

Slide 35

Slide 35 text

Silent errors $ symfony console messenger:consume 08:45:49 WARNING [messenger] Setting tenant ["tenant" =$ "tenant-a"] ^ array:1 [ "path" =- “[…]/var/db/tenant-a.db” ] /+ ..1 08:45:57 WARNING [messenger] Setting tenant ["tenant" =$ "tenant-b"] ^ array:1 [ "path" =- "[…]/var/db/tenant-a.db" ]

Slide 36

Slide 36 text

Tugdual Saunier / @tucksaun / [email protected] Vendor Network User User User Symfony LB User User User User User Symfony Symfony Symfony Tenant A SQL Redis FS Tenant B SQL Redis FS Tenant D SQL Redis FS Tenant F SQL Redis FS Tenant C SQL Redis FS Tenant E SQL Redis FS Tenant G SQL Redis FS Big Mess #1 Big Mess #2 Big Mess #3

Slide 37

Slide 37 text

Tugdual Saunier / @tucksaun / [email protected] Pros ✔ Relatively easy to implement with PHP and Symfony Cons ✘ Can be inefficient (data source instances are deployed and running even if they are not used) ✘ Harder to manage on the Ops side (still multiple data source instances to manage) ✘ Not always easy to handle background processing

Slide 38

Slide 38 text

Tugdual Saunier / @tucksaun / [email protected] Alternative implementations https://hutchdesign.co/projects/lough-tree-village/

Slide 39

Slide 39 text

Tugdual Saunier / @tucksaun / [email protected] Containers https://cphvillage.com/

Slide 40

Slide 40 text

Tugdual Saunier / @tucksaun / [email protected] Vendor Network User User User LB User User User User User Tenant A Symfony SQL Redis FS Tenant B Symfony SQL Redis FS Tenant D Symfony SQL Redis FS Tenant D Symfony SQL Redis FS Tenant F Symfony SQL Redis FS Tenant E Symfony SQL Redis FS Tenant G Symfony SQL Redis FS Orchestration

Slide 41

Slide 41 text

Tugdual Saunier / @tucksaun / [email protected] Single code deployment (but more granular) https://www.archdaily.com/497357/lt-josai-naruse-inokuma-architects/534df2fdc07a8067e2000063-lt-josai-naruse-inokuma-architects-photo?next_project=no

Slide 42

Slide 42 text

Tugdual Saunier / @tucksaun / [email protected] Vendor Network User A User B User C Symfony application Webserver SQL Tenant A Redis S3 SQL Tenant B SQL Tenant C SQL Tenant D SQL Tenant E Tenant DB Common Data Repository

Slide 43

Slide 43 text

Tugdual Saunier / @tucksaun / [email protected] A take on a Symfony-centric solution

Slide 44

Slide 44 text

Tugdual Saunier / @tucksaun / [email protected] Focus on Messages https://www.republicain-lorrain.fr/le-mag/2019/04/05/dans-les-coulisses-de-la-plateforme-de-tri-postal-de-pagny-les-goin-en-moselle

Slide 45

Slide 45 text

Tugdual Saunier / @tucksaun / [email protected] Sender Bus envelope 1 Tenant A envelope 2 Tenant B envelope 1 Tenant A envelope 2 Tenant B ? ? TenantIdStamp

Slide 46

Slide 46 text

Tugdual Saunier / @tucksaun / [email protected] Sender Handler envelope Receiver Application envelope Tenant X Tenant X Tenant Resolver Bus SwitchTenantListener envelope setTenant AddTenantIdListener envelope getTenant envelope Tenant X envelope Tenant X

Slide 47

Slide 47 text

Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy; #"AsEventListener(SendMessageToTransportsEvent:%class)] final class AddTenantIdListener { public function __invoke(SendMessageToTransportsEvent $event): void { $tenant = $this-#resolver-#getTenant(); if (!$tenant) { return; } $this-#logger-#info(‘Adding tenant stamp', ['tenant' =- $tenant]); $event-#setEnvelope($event-#getEnvelope()-#with( new TenantIdStamp($tenant) )); } }

Slide 48

Slide 48 text

Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy; #"AsEventListener(WorkerMessageReceivedEvent:%class)] final class SwitchTenantListener { public function __invoke(WorkerMessageReceivedEvent $event): void { $envelope = $event-#getEnvelope(); $tenant = $envelope-#last(TenantIdStamp:%class)?-#getTenantId(); if (!$tenant) { return; } $this-#logger-#info(‘Setting tenant', ['tenant' =- $tenant]); $this-#resolver-#setTenant($tenant); } }

Slide 49

Slide 49 text

Tugdual Saunier / @tucksaun / [email protected] Sender Bus envelope 1 Tenant A envelope 2 Tenant B envelope 1 Tenant A envelope 2 Tenant B ? ? TenantIdStamp

Slide 50

Slide 50 text

Tugdual Saunier / @tucksaun / [email protected] Sender Bus envelope 1 Tenant A envelope 2 Tenant B envelope 1 Tenant A envelope 2 Tenant B ? ? TenantIdStamp

Slide 51

Slide 51 text

Tugdual Saunier / @tucksaun / [email protected] Kernel-ception ? https://views.fr/2020/07/21/dix-ans-apres-pourquoi-inception-est-toujours-le-film-le-plus-fascinant-de-sa-generation/

Slide 52

Slide 52 text

Tugdual Saunier / @tucksaun / [email protected] Sender Bus Handler Kernel Tenant A Kernel Tenant B envelope 1 Tenant A envelope 2 Tenant B envelope 1 Tenant A envelope 2 Tenant B Handler

Slide 53

Slide 53 text

Tugdual Saunier / @tucksaun / [email protected] EnvVarProcessor / EnvVarLoader ? https://www.123rf.com/photo_54406723_rooms-keys-at-a-two-stars-hostel-reception-desk-counter.html

Slide 54

Slide 54 text

Tugdual Saunier / @tucksaun / [email protected] # config/packages/doctrine.yaml doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' # src/Tenancy/TenantEnvVarLoader.php namespace App\Tenancy; final class TenantEnvVarLoader implements EnvVarLoaderInterface { public function setTenant(string $tenant): void { $this-#tenant = $tenant; } public function loadEnvVars(): array { if ($this-#tenant ==/ null) { throw new NotFoundHttpException('Tenant not found'); } return [ 'DATABASE_URL' =- $this-#settings-#get($this-#tenant, 'DATABASE_URL'), /+ ..1 ]; } }

Slide 55

Slide 55 text

Tugdual Saunier / @tucksaun / [email protected] Focus on DI and Events https://www.123rf.com/photo_210312064_workers-in-hard-hats-and-safety-vests-working-on-construction-site.html

Slide 56

Slide 56 text

Tugdual Saunier / @tucksaun / [email protected] Some services are easy to override

Slide 57

Slide 57 text

Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy; class MyOverrideFactory extends BaseFactory { public function __construct( /2..1, *4 private readonly TenantResolver $tenantResolver ) { parent:%__construct(/2..1*4); } public function createMyResource(/2..1*4) { if ($this-#tenantResolver-#isTenantSet()) { /+ ..1 return parent:%createMyResource(/2..1*4); } } }

Slide 58

Slide 58 text

Tugdual Saunier / @tucksaun / [email protected] Some services need to be re-instantiated

Slide 59

Slide 59 text

Tugdual Saunier / @tucksaun / [email protected] Worker Message received? Envelope $bus->dispatch($envelope) shouldStop? Worker Message received? Envelope $bus->dispatch($envelope) $eventDispatcher->dispatch(new WorkerRunningEvent()) ResetServicesListener shouldStop? resetServices The Magic Trick

Slide 60

Slide 60 text

Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy; class MyOverrideService extends BaseService implements ResetInterface { public function __construct(/2..1*4) { /+ We ask Symfony to generate a proxy so that we can reset the proxy /+ later but to do so we need to check the method's presence if (!$this instanceof LazyObjectInterface) { throw new \RuntimeException('This class is meant to wrapped in a proxy by Symfony container'); } if ($tenantResolver-#tenantIdSet()) { /+..1 } parent:%__construct(/2..1*4); } public function reset(): void { $this-'resetLazyObject(); } } The Trick

Slide 61

Slide 61 text

Tugdual Saunier / @tucksaun / [email protected] /+ config/services.php namespace App\Tenancy; return function(ContainerConfigurator $container): void { $services = $container-#services(); $services-#set(MyOverrideService:%class)-#lazy(); }; The Trick

Slide 62

Slide 62 text

Tugdual Saunier / @tucksaun / [email protected] And then there’s Doctrine…

Slide 63

Slide 63 text

Tugdual Saunier / @tucksaun / [email protected] # config/packages/messenger.yaml # As an alternative, you might want to override it to # happen only when a tenant switch actually happens framework: messenger: buses: default: middleware: - doctrine_close_connection

Slide 64

Slide 64 text

Tugdual Saunier / @tucksaun / [email protected] Database Connection LateParametersResolutionDriver Driver TenantAwareMiddleware DriverManager Doctrine Database Connection LateParametersResolutionDriver Driver TenantAwareMiddleware DriverManager Doctrine Instantiation is done but not connected yet getConnection($params) createDriver() __construct() wrap($driver) __construct() __construct($params, $driver) connect() connect($this->params) resolveParameters($params) connect($resolvedParams) connect Database Connection Driver DriverManager Doctrine Database Connection Driver DriverManager Doctrine Instantiation is done but not connected yet getConnection($params) createDriver() __construct() __construct($params, $driver) connect() connect($this->params) connect The Trick

Slide 65

Slide 65 text

Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy\Doctrine; final class LateParametersResolutionDriver extends \Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware { public function __construct( private readonly TenantAwareMiddleware $middleware, Doctrine\DBAL\Driver $driver, ) { parent:%__construct($driver); } public function connect(array $params) { return parent:%connect($this-#middleware-#resolveParameters($params)); } }

Slide 66

Slide 66 text

Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy\Doctrine; #"AutoconfigureTag('doctrine.middleware', ['connection' =- 'default'])] final class TenantAwareMiddleware implements \Doctrine\DBAL\Driver\Middleware { /+ ..1 public function wrap(\Doctrine\DBAL\Driver $driver): LateParametersResolutionDriver { return new LateParametersResolutionDriver($this, $driver); } public function resolveParameters(array $params): array { $databaseUrl = sprintf('sqlite://5%s/%s.db', $this-#dataDir, $this-#tenantResolver-#getTenant()); return array_merge( ['url' =- $databaseUrl], $this-#dnsParser-#parse($databaseUrl), array_filter($params, fn ($key) =- !in_array($key, ['user', 'password', 'host', 'port', 'dbname', 'url'], true), ARRAY_FILTER_USE_KEY), ); } }

Slide 67

Slide 67 text

Enjoy $ symfony console messenger:consume 08:45:49 WARNING [messenger] Setting tenant ["tenant" =$ "tenant-a"] ^ array:1 [ "path" =- “[…]/var/db/tenant-a.db” ] /+ ..1 08:45:57 WARNING [messenger] Setting tenant ["tenant" =$ "tenant-b"] ^ array:1 [ "path" =- “[…]/var/db/tenant-b.db" ]

Slide 68

Slide 68 text

Tugdual Saunier / @tucksaun / [email protected] Back to HTTP and the CLI…

Slide 69

Slide 69 text

Tugdual Saunier / @tucksaun / [email protected] Request Front controller Symfony Kernel Request Request Controller Routing public/index.php src/Kernel.php Controller Request URI Host: tenant-a.my-saas.wip Host: tenant-b.my-saas.wip Host: tenant-c.my-saas.wip Container TenantResolver Inspect the Request Instantiate dependencies getTenant Get Controller

Slide 70

Slide 70 text

Tugdual Saunier / @tucksaun / [email protected] $ > Script Symfony Kernel $ > $ > Command bin/console src/Kernel.php TENANT=tenant-a TENANT=tenant-b TENANT=tenant-c Container TenantResolver Inspect the Env Instantiate dependencies getTenant Get Command

Slide 71

Slide 71 text

Tugdual Saunier / @tucksaun / [email protected] Conclusion https://www.123rf.com/photo_122276676_aerial-view-of-the-royal-square-in-brussels-belgium.html

Slide 72

Slide 72 text

Tugdual Saunier / @tucksaun / [email protected] Assess your needs •On-the-fly tenant provisioning ? •True data isolation ? •Per-tenant customisation ?

Slide 73

Slide 73 text

Tugdual Saunier / @tucksaun / [email protected] Consider the autonomous or container deployments

Slide 74

Slide 74 text

Tugdual Saunier / @tucksaun / [email protected] Go for it! https://hutchdesign.co/projects/lough-tree-village/

Slide 75

Slide 75 text

Tugdual Saunier / @tucksaun / [email protected] PHP is a good candidate for multi- tenant application thanks to its stateless nature

Slide 76

Slide 76 text

Tugdual Saunier / @tucksaun / [email protected] Symfony tooling can help in most implementations

Slide 77

Slide 77 text

Tugdual Saunier / @tucksaun / [email protected] Final tips •Avoid stateful services •Log, log and log again •Automatic testing

Slide 78

Slide 78 text

Questions ?

Slide 79

Slide 79 text

Tugdual Saunier / @tucksaun / [email protected] @tucksaun [email protected]