mhpdigital / cross-tenant-security-bundle
Symfony bundle for role-based, row-level tenant isolation in Doctrine repositories
Package info
github.com/mhpdigital/cross-tenant-security-bundle
Type:symfony-bundle
pkg:composer/mhpdigital/cross-tenant-security-bundle
Requires
- php: >=8.1
- doctrine/doctrine-bundle: ^2.0 || ^3.0
- doctrine/orm: ^2.17 || ^3.0
- symfony/framework-bundle: ^6.4 || ^7.0
- symfony/security-bundle: ^6.4 || ^7.0
Requires (Dev)
- phpunit/phpunit: ^11.0
- symfony/phpunit-bridge: ^7.0
This package is auto-updated.
Last update: 2026-06-04 20:32:48 UTC
README
Role-based, row-level tenant isolation for Doctrine repositories in Symfony.
Pick one of three traits per repository to declare its access policy. Filtering is applied
automatically in createQueryBuilder() (and find(), findBy(), findOneBy(), findAll()),
so controllers and services never pass the current user around — the security context is read
from the token.
Install & register
composer require mhpdigital/cross-tenant-security-bundle
Wire the factory as Doctrine's repository factory:
# config/packages/doctrine.yaml doctrine: orm: repository_factory: 'Mhpdigital\CrossTenantSecurity\Repository\CrossTenantRepositoryFactory'
The factory injects token_storage, role_hierarchy and request_stack into every repository
that uses one of the traits.
Access matrix
| Trait | console / worker | authenticated web | token-less web |
|---|---|---|---|
CrossTenantRepository |
all rows | your tenant filter (override) | none (1=0) |
AdminOnlyAccessRepository |
all rows | ROLE_SUPER_ADMIN → all, else none |
none |
OpenAccessRepository |
all rows | all rows | all rows (public) |
"console / worker" = any process with no HTTP request in flight (a console command, a Messenger/queue worker, a cron run). These are trusted local processes and get full access automatically — see Console context.
Examples
1. Tenant-scoped — CrossTenantRepository
Each user sees only their own rows; ROLE_SUPER_ADMIN sees all. Use the trait-alias pattern:
call the library's secured builder first (you inherit the unauthenticated 1=0 and the
console-context bypass), then add your own filter.
use Mhpdigital\CrossTenantSecurity\Repository\CrossTenantRepository; class PostRepository extends ServiceEntityRepository { use CrossTenantRepository { CrossTenantRepository::createQueryBuilder as secureQueryBuilder; } public function createQueryBuilder($alias, $indexBy = null): QueryBuilder { $qb = $this->secureQueryBuilder($alias, $indexBy); // Authenticated non-super-admins are scoped to their own rows. // (No current user ⇒ console context ⇒ full access, so this is skipped.) if ($this->getCurrentUser() !== null && $this->getHighestRole() !== 'ROLE_SUPER_ADMIN') { $qb->andWhere("$alias.author = :_author") ->setParameter('_author', $this->getUserId()); } return $qb; } }
2. Public lookup — OpenAccessRepository
Genuinely public reference data — everyone, including unauthenticated requests, sees all
rows. Use for sex, country, currency, status, category, tag. No override needed.
use Mhpdigital\CrossTenantSecurity\Repository\OpenAccessRepository; class SexRepository extends ServiceEntityRepository { use OpenAccessRepository; }
If a lookup must require login, do not use this trait — use
CrossTenantRepository(authenticated → all rows, token-less web → none).
3. Admin-only — AdminOnlyAccessRepository
Only ROLE_SUPER_ADMIN (and trusted console/worker runs) see rows; everyone else sees none.
use Mhpdigital\CrossTenantSecurity\Repository\AdminOnlyAccessRepository; class AuditLogRepository extends ServiceEntityRepository { use AdminOnlyAccessRepository; }
4. Content filter + access gate (custom createQueryBuilder)
When a repository has an always-on content filter (e.g. hide soft-deleted or unpublished
rows) as well as an access gate, reimplement createQueryBuilder() and place
isConsoleContext() between them — the content filter must apply in every context, but the
access gate is bypassed for console/worker:
public function createQueryBuilder($alias, $indexBy = null): QueryBuilder { $qb = $em->createQueryBuilder()->select($alias)->from(/* … */); $qb->andWhere("$alias.deleted IS NULL"); // (1) content filter — ALWAYS applies if ($this->isConsoleContext()) { // CLI/worker bypasses ONLY the gate below return $qb; } // (2) access gate — bypassed in console if (!\in_array($this->getHighestRole(), self::READER_ROLES, true)) { $qb->andWhere('1=0'); } return $qb; }
A console index build then sees all non-deleted rows without
createUnrestrictedQueryBuilder(), while deleted rows stay hidden everywhere.
Console / worker context
A console command, Messenger/queue worker or cron run has no HTTP request and carries no
security token. The bundle detects this (isConsoleContext() — request-presence, so functional
tests that issue a sub-request keep full web semantics) and grants full access through the
secured createQueryBuilder(). CLI/background code no longer needs
createUnrestrictedQueryBuilder().
If request_stack is not wired, the bundle assumes a web context and fails closed.
Explicit, context-independent bypass
$qb = $repo->createUnrestrictedQueryBuilder('e'); // raw Doctrine builder, no filtering at all
Use only when you must bypass filtering regardless of context (e.g. an authenticated admin maintenance action). For ordinary CLI/background work the console-context detection already grants full access, so you should rarely need this.
See also
The example/ app is a runnable Symfony project exercising every trait —
example/src/Repository/* and example/tests/Integration/BundleIntegrationTest.php are the
executable documentation, including the console-context tests.