Skip to content

Add multi-tenancy support with tenant isolation and access control#115

Open
pierredup wants to merge 3 commits into
mainfrom
claude/multi-tenancy-feature-R0WVM
Open

Add multi-tenancy support with tenant isolation and access control#115
pierredup wants to merge 3 commits into
mainfrom
claude/multi-tenancy-feature-R0WVM

Conversation

@pierredup

Copy link
Copy Markdown
Member

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

  • TenantContext: Request/worker-scoped service holding the current tenant with stack-based switching and event dispatching
  • TenantManager: High-level API for switching tenants and managing the Doctrine filter
  • Tenant & UserTenant Entities: New entities representing tenant boundaries and user membership
  • TenantRepository & UserTenantRepository: Repositories for tenant and membership queries

Data Isolation & Security

  • TenantFilter: Doctrine SQL filter automatically restricting queries to the current tenant
  • TenantAwareInterface & TenantAwareTrait: Mixin for entities requiring tenant isolation (adds nullable tenant_id column)
  • TenantAwareListener: Automatically populates tenant_id on entity insert from the current tenant context
  • TenantMetadataListener: Enforces indexing conventions (standalone tenant_id index + leading column in composites)
  • TenantWriteGuardListener: Prevents cross-tenant writes and validates user membership on write operations
  • TenantAccessValidationListener: Validates user membership before tenant switches

Tenant Resolution

  • TenantResolverInterface & Implementations: Three resolver strategies (domain, session, route) with configurable priority
  • DomainTenantResolver: Resolves tenant by custom request hostname
  • SessionTenantResolver: Resolves tenant from session (post-login default)
  • RouteTenantResolver: Resolves tenant from route parameter
  • TenantRequestListener: Establishes tenant context at request start

Messenger Integration

  • TenantMiddleware: Propagates tenant across message bus (on dispatch and worker handling)
  • TenantStamp: Messenger stamp carrying tenant ID
  • TenantAwareMessageInterface & TenantAwareMessageTrait: Optional message mixins for explicit tenant tracking

UI & Controllers

  • SelectTenant Controller: Allows authenticated users to switch between their assigned tenants
  • Tenant Selection Template: UI for workspace/tenant selection

Configuration

  • PlatformConfiguration: New multi_tenancy config section with options for:
    • Enabling/disabling multi-tenancy
    • Session key and route parameter names
    • Resolver strategy selection and priority
    • User access validation on entry and write

Testing & Documentation

  • Comprehensive test suite covering all tenant components (filters, listeners, resolvers, context, manager)
  • Test fixtures and base test case for tenant-aware ORM testing
  • Multi-tenancy documentation with usage examples and best practices
  • Helper trait for test interactions with tenants

Schema & Configuration Updates

  • Updated platform-schema.json with multi-tenancy configuration structure
  • Updated service configuration to exclude tenant-related classes from auto-discovery when disabled
  • Added documentation index entry for multi-tenancy guide

Implementation Details

  • Security-First Design: Tenant filtering is automatic and mandatory when enabled; users cannot bypass isolation
  • Opt-In: Multi-tenancy is disabled by default; no overhead when not in use
  • Flexible Resolution: Multiple resolver strategies allow different deployment patterns (domain-based SaaS, session-based, route-based)
  • Messenger Support: Tenant context is preserved across async message handling
  • Index Optimization: Automatic index management ensures tenant-scoped queries are always indexed efficiently
  • Event-Driven: Tenant switches dispatch events allowing listeners to veto changes before state mutation

https://claude.ai/code/session_01SvG5Yz3nHh9P9V9ZAHDhyr

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
Copilot AI review requested due to automatic review settings June 1, 2026 13:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +53 to +58
public function onFlush(): void
{
$tenantId = $this->tenantContext->getTenantId();
if (!$tenantId instanceof Ulid) {
return;
}
Comment on lines +69 to +73
$entityTenantId = $entity->getTenantId();

if (! $entityTenantId instanceof Ulid || ! $entityTenantId->equals($tenantId)) {
throw CrossTenantOperationException::forEntity($entity::class);
}
Comment on lines +92 to +96
$userId = $user->getId();

if (!$userId instanceof Ulid) {
return;
}
Comment on lines +98 to +101
private function hasStandaloneIndex(array $indexes, string $column): bool
{
return array_any($indexes, fn($definition): bool => ($definition['columns'] ?? []) === [$column]);
}
Comment on lines +53 to +57
$tenantId = $this->tenantContext->getTenantId();

if (!$tenantId instanceof Ulid) {
return;
}
Comment on lines +67 to +73
private function select(Request $request): RedirectResponse
{
$submitted = $request->request->get('tenant');

if (! is_string($submitted)) {
throw $this->createNotFoundException();
}
Comment on lines +17 to +23
<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>
Comment on lines +46 to +50
$tenantId = $this->tenantContext->getTenantId();

if (!$tenantId instanceof Ulid) {
return;
}
Comment on lines +54 to +58
$userId = $user->getId();

if (!$userId instanceof Ulid) {
return false;
}
Comment on lines +67 to +73
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

Copy link
Copy Markdown
Member Author

Thanks for the review. Summary of how I've addressed it (fixes pushed in 58eb1fa):

Fixed

  • Unit (PHP 8.3) fatal — array_any() in TenantMetadataListener: this PHP 8.4-only function was introduced by the repo's Rector (PHP 8.4) auto-fix and fatals on 8.3. Replaced with array_map + in_array. This was the actual cause of the PHP 8.3 job's exit-255.
  • PHP 8.3 fatal — "new without parentheses" in the metadata test (new X()->method(), another Rector 8.4 rewrite): split into two statements so it parses on 8.3.
  • CSRF on tenant selection (good catch): added a _token field to the template and server-side isCsrfTokenValid() validation in the controller before the tenant is persisted to the session.

Not changing — false positives: the !$x instanceof Y "operator precedence" comments. In PHP, instanceof binds tighter than !, so !$x instanceof Y already evaluates as !($x instanceof Y) — the intended behaviour. This exact form was produced by the repo's own Rector config, so adding parentheses would be reverted by rector --dry-run.

onFlush() / ArgumentCountError: not an issue — PHP silently ignores extra arguments, so Doctrine calling onFlush($eventArgs) on the no-arg method does not error (exercised by the passing TenantWriteGuardListenerTest). The UnitOfWork comes from the injected EntityManagerInterface, avoiding the deprecated OnFlushEventArgs::getEntityManager().

Pre-existing CI failures (not introduced by this PR, reproduce on main):

  • PHPStan aborts on a stale baseline entry pointing at tests/Bundle/Saas/Twig/Runtime/FeatureRuntimeTest.php, which was moved to tests/Bundle/PlatformBundle/Twig/Runtime/ in an earlier commit; the baseline was never updated.
  • URLTypeTest and LemonSqueezyPayloadConverterTest fail due to resolved dependency versions (DBAL/serializer), in files this PR doesn't touch.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants