Add multi-tenancy support with tenant isolation and access control#115
Add multi-tenancy support with tenant isolation and access control#115pierredup wants to merge 3 commits into
Conversation
Introduce a reusable, security-focused multi-tenancy layer that scopes data per tenant. When enabled, every query against a tenant-aware entity is automatically filtered to the tenant in scope, writes cannot cross tenant boundaries, and an authenticated user cannot enter a tenant they are not a member of. Highlights: - Tenant / UserTenant entities (ULID, scalar tenant_id column via TenantAwareTrait) - Doctrine SQLFilter (TenantFilter) synchronised from a request/worker-scoped TenantContext; filter parameter is pushed on TenantSwitchedEvent - Access validation listener (high priority) vetoes a switch before the context commits or the filter is enabled; uniform membership check for all resolvers - prePersist auto-set + onFlush cross-tenant write guard (optional user check) - loadClassMetadata listener auto-indexes tenant_id and forces it to lead any composite index / unique constraint - Resolver chain: domain (custom host) > session > route (no header resolver) - TenantManager facade (runWithoutFilter / runAs) + repository bypass trait - TENANT_ACCESS voter and a tenant-selection page (controller + Tabler template) - Optional Messenger integration (TenantStamp + middleware) to carry the tenant across the bus - Config under platform.multi_tenancy (disabled by default); services removed when disabled; regenerated platform-schema.json; docs under docs/multi-tenancy - Tests covering the filter (functional SQLite), listeners, resolvers, voter, manager and middleware Also pins symfony/security-* to ^7.4 (the codebase targets Symfony 7.x) and adds symfony/messenger to require-dev for the optional integration and its tests. https://claude.ai/code/session_01SvG5Yz3nHh9P9V9ZAHDhyr
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in multi-tenancy layer to PlatformBundle, including tenant resolution, tenant-scoped ORM filtering, write-time tenant isolation checks, membership-based access control, a tenant selection UI, and supporting documentation/tests.
Changes:
- Introduces core tenant-scoping services (context/manager), tenant resolver chain, and automatic Doctrine tenant filtering.
- Adds access-control enforcement (tenant membership validation on switch + voter) and write-guard protections against cross-tenant writes.
- Adds tenant selection UI, documentation, schema updates, and a comprehensive test suite for tenancy behavior.
Reviewed changes
Copilot reviewed 54 out of 54 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Bundle/PlatformBundle/Tenant/TenantManagerTest.php | Tests tenant manager filter enable/disable and scoped helpers. |
| tests/Bundle/PlatformBundle/Tenant/TenantContextTest.php | Tests tenant context state, stack behavior, and switch event semantics. |
| tests/Bundle/PlatformBundle/Tenant/TenantAccessValidationListenerTest.php | Tests membership validation on tenant switch event. |
| tests/Bundle/PlatformBundle/Tenant/Resolver/SessionTenantResolverTest.php | Tests session-based tenant resolution. |
| tests/Bundle/PlatformBundle/Tenant/Resolver/RouteTenantResolverTest.php | Tests route-param tenant resolution. |
| tests/Bundle/PlatformBundle/Tenant/Resolver/DomainTenantResolverTest.php | Tests domain-based tenant resolution via repository lookup. |
| tests/Bundle/PlatformBundle/Security/Voter/TenantVoterTest.php | Tests voter granting/denying access based on membership. |
| tests/Bundle/PlatformBundle/Repository/TenantFilterAwareTraitTest.php | Tests repository helper to suspend/restore tenant filter. |
| tests/Bundle/PlatformBundle/Messenger/TenantMiddlewareTest.php | Tests tenant propagation across Messenger dispatch/handling. |
| tests/Bundle/PlatformBundle/Fixtures/TenantOrmTestCase.php | Provides in-memory ORM setup for tenant filter functional tests. |
| tests/Bundle/PlatformBundle/Fixtures/Message/TenantAwareMessage.php | Fixture message implementing tenant-aware message contract. |
| tests/Bundle/PlatformBundle/Fixtures/Entity/TenantAwareItem.php | Fixture tenant-aware entity used for ORM filter/listener tests. |
| tests/Bundle/PlatformBundle/Doctrine/Filter/TenantFilterTest.php | Functional tests for tenant SQL filter scoping behavior. |
| tests/Bundle/PlatformBundle/Doctrine/EventListener/TenantWriteGuardListenerTest.php | Tests cross-tenant write blocking and optional access checks. |
| tests/Bundle/PlatformBundle/Doctrine/EventListener/TenantMetadataListenerTest.php | Tests automatic tenant index enforcement on metadata. |
| tests/Bundle/PlatformBundle/Doctrine/EventListener/TenantAwareListenerTest.php | Tests auto-stamping tenant id on persist. |
| src/Test/Traits/InteractsWithTenantsTrait.php | Adds test helpers for creating/setting/switching tenants. |
| src/Bundle/Ui/templates/Tenant/select.html.twig | Adds UI for selecting a tenant/workspace. |
| src/Bundle/Platform/Tenant/TenantRequestListener.php | Establishes tenant context at request start via resolver chain. |
| src/Bundle/Platform/Tenant/TenantManager.php | Adds high-level API for switching tenants and managing filter state. |
| src/Bundle/Platform/Tenant/TenantFilterSynchronizer.php | Syncs Doctrine filter state with tenant switch events. |
| src/Bundle/Platform/Tenant/TenantContext.php | Implements tenant-in-scope storage, stack switching, and switch events. |
| src/Bundle/Platform/Tenant/TenantAwareTrait.php | Provides tenant_id mapping/accessors for tenant-aware entities. |
| src/Bundle/Platform/Tenant/TenantAwareInterface.php | Contract for tenant-aware entities. |
| src/Bundle/Platform/Tenant/TenantAccessValidationListener.php | Enforces membership validation before tenant switches commit. |
| src/Bundle/Platform/Tenant/Resolver/TenantResolverInterface.php | Defines request tenant resolver contract. |
| src/Bundle/Platform/Tenant/Resolver/SessionTenantResolver.php | Adds session-based tenant resolver. |
| src/Bundle/Platform/Tenant/Resolver/RouteTenantResolver.php | Adds route-param tenant resolver. |
| src/Bundle/Platform/Tenant/Resolver/DomainTenantResolver.php | Adds domain-based tenant resolver. |
| src/Bundle/Platform/Tenant/Event/TenantSwitchedEvent.php | Adds event dispatched before tenant switch commits. |
| src/Bundle/Platform/Security/Voter/TenantVoter.php | Adds voter for per-tenant membership authorization. |
| src/Bundle/Platform/Resources/config/services.php | Adjusts service auto-discovery exclusions for tenancy-related dirs. |
| src/Bundle/Platform/Repository/UserTenantRepository.php | Adds membership repository (hasAccess + tenant list per user). |
| src/Bundle/Platform/Repository/TenantRepository.php | Adds tenant repository (domain lookup). |
| src/Bundle/Platform/Repository/TenantFilterAwareTrait.php | Adds repository helper to run with tenant filter suspended. |
| src/Bundle/Platform/Messenger/TenantStamp.php | Adds Messenger stamp carrying tenant id. |
| src/Bundle/Platform/Messenger/TenantMiddleware.php | Adds middleware to propagate/restore tenant context across Messenger. |
| src/Bundle/Platform/Messenger/TenantAwareMessageTrait.php | Adds trait to persist tenant id in message payload. |
| src/Bundle/Platform/Messenger/TenantAwareMessageInterface.php | Adds interface for tenant-aware messages. |
| src/Bundle/Platform/Exception/TenantAccessDeniedException.php | Adds exception type for tenant membership denial. |
| src/Bundle/Platform/Exception/CrossTenantOperationException.php | Adds exception for blocked cross-tenant writes. |
| src/Bundle/Platform/Entity/UserTenant.php | Adds membership entity linking user id to tenant. |
| src/Bundle/Platform/Entity/Tenant.php | Adds tenant boundary entity. |
| src/Bundle/Platform/Doctrine/Filter/TenantFilter.php | Adds Doctrine SQL filter to scope tenant-aware entities. |
| src/Bundle/Platform/Doctrine/EventListener/TenantWriteGuardListener.php | Adds write-time guard against cross-tenant inserts/updates. |
| src/Bundle/Platform/Doctrine/EventListener/TenantMetadataListener.php | Enforces tenant_id indexing conventions at metadata load. |
| src/Bundle/Platform/Doctrine/EventListener/TenantAwareListener.php | Auto-stamps tenant id on persist from current context. |
| src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php | Wires multi-tenancy config, conditional mappings, and optional middleware registration. |
| src/Bundle/Platform/Controller/Tenant/SelectTenant.php | Adds controller to select a tenant and store it in session. |
| src/Bundle/Platform/Config/PlatformConfiguration.php | Adds multi_tenancy configuration section. |
| platform-schema.json | Updates JSON schema with multi-tenancy configuration structure/defaults. |
| docs/multi-tenancy/index.md | Adds multi-tenancy documentation and usage guide. |
| docs/index.md | Adds documentation index entry for multi-tenancy. |
| composer.json | Adds Symfony security components and Messenger (dev) dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public function onFlush(): void | ||
| { | ||
| $tenantId = $this->tenantContext->getTenantId(); | ||
| if (!$tenantId instanceof Ulid) { | ||
| return; | ||
| } |
| $entityTenantId = $entity->getTenantId(); | ||
|
|
||
| if (! $entityTenantId instanceof Ulid || ! $entityTenantId->equals($tenantId)) { | ||
| throw CrossTenantOperationException::forEntity($entity::class); | ||
| } |
| $userId = $user->getId(); | ||
|
|
||
| if (!$userId instanceof Ulid) { | ||
| return; | ||
| } |
| private function hasStandaloneIndex(array $indexes, string $column): bool | ||
| { | ||
| return array_any($indexes, fn($definition): bool => ($definition['columns'] ?? []) === [$column]); | ||
| } |
| $tenantId = $this->tenantContext->getTenantId(); | ||
|
|
||
| if (!$tenantId instanceof Ulid) { | ||
| return; | ||
| } |
| private function select(Request $request): RedirectResponse | ||
| { | ||
| $submitted = $request->request->get('tenant'); | ||
|
|
||
| if (! is_string($submitted)) { | ||
| throw $this->createNotFoundException(); | ||
| } |
| <form method="post" action="{{ path('solidworx_platform_tenant_select') }}"> | ||
| <input type="hidden" name="tenant" value="{{ tenant.id }}" /> | ||
| <button type="submit" class="list-group-item list-group-item-action d-flex align-items-center"> | ||
| <span class="avatar avatar-sm me-3">{{ tenant.name|slice(0, 1)|upper }}</span> | ||
| <span class="flex-fill">{{ tenant.name }}</span> | ||
| {{ ux_icon('tabler:chevron-right', {class: 'text-secondary'}) }} | ||
| </button> |
| $tenantId = $this->tenantContext->getTenantId(); | ||
|
|
||
| if (!$tenantId instanceof Ulid) { | ||
| return; | ||
| } |
| $userId = $user->getId(); | ||
|
|
||
| if (!$userId instanceof Ulid) { | ||
| return false; | ||
| } |
| private function select(Request $request): RedirectResponse | ||
| { | ||
| $submitted = $request->request->get('tenant'); | ||
|
|
||
| if (! is_string($submitted)) { | ||
| throw $this->createNotFoundException(); | ||
| } |
- Replace array_any() (PHP 8.4-only, introduced by Rector) with array_map + in_array in TenantMetadataListener so it does not fatal on PHP 8.3 - Split the `(new TenantMetadataListener())->loadClassMetadata()` one-liner in the metadata test into two statements; Rector had rewritten it to the PHP 8.4-only "new without parentheses" form, a parse error on PHP 8.3 - Add CSRF protection to the tenant-selection POST: a token field in the template and server-side validation in the controller https://claude.ai/code/session_01SvG5Yz3nHh9P9V9ZAHDhyr
|
Thanks for the review. Summary of how I've addressed it (fixes pushed in 58eb1fa): Fixed
Not changing — false positives: the
Pre-existing CI failures (not introduced by this PR, reproduce on
Happy to fold the baseline refresh into this PR if you'd prefer it handled here rather than separately. Generated by Claude Code |
Refactor the tenant entities to mirror the User model convention so consumers
can add their own fields:
- Add abstract #[ORM\MappedSuperclass] bases Model\Tenant and Model\UserTenant
plus TenantInterface / UserTenantInterface contracts
- Ship default concrete entities (Entity\Tenant, Entity\UserTenant) that extend
the bases, so multi-tenancy works out of the box
- Add platform.multi_tenancy.models.{tenant,user_tenant} config (validated with
is_subclass_of) and wire the interfaces with Doctrine resolve_target_entities
- TenantModelMappingListener demotes a superseded default entity to a mapped
superclass when overridden, so a custom entity does not create a duplicate table
- Repositories bind to the configured class; context/manager/voter/associations
type against the interfaces
- Regenerate platform-schema.json; document how to extend the tenant entity
https://claude.ai/code/session_01SvG5Yz3nHh9P9V9ZAHDhyr
Summary
This PR introduces a comprehensive multi-tenancy layer to the PlatformBundle, enabling secure data isolation per tenant. Multi-tenancy is opt-in and disabled by default. When enabled, all queries against tenant-aware entities are automatically filtered to the current tenant, writes are guarded against cross-tenant operations, and authenticated users cannot access tenants they're not members of.
Key Changes
Core Tenant Infrastructure
Data Isolation & Security
tenant_idcolumn)tenant_idon entity insert from the current tenant contexttenant_idindex + leading column in composites)Tenant Resolution
Messenger Integration
UI & Controllers
Configuration
multi_tenancyconfig section with options for:Testing & Documentation
Schema & Configuration Updates
platform-schema.jsonwith multi-tenancy configuration structureImplementation Details
https://claude.ai/code/session_01SvG5Yz3nHh9P9V9ZAHDhyr