diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..7061fcda --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Testing +- `composer test` - Run tests without coverage using Docker +- `./t 'test name'` - Run a specific test + +### Code Quality +- `composer phpstan` - Run PHPStan static analysis (level 8) +- `composer cs` - Fix code style using PHP CS Fixer + +### Docker Development +- `composer docker-up` - Start Docker environment +- `composer docker-down` - Stop Docker environment +- `composer docker-restart` - Restart Docker environment + +## Architecture Overview + +**Tenancy for Laravel** is a multi-tenancy package that automatically handles tenant isolation without requiring changes to application code. + +### Core Components + +**Central Classes:** +- `Tenancy` - Main orchestrator class managing tenant context and lifecycle +- `TenancyServiceProvider` (NOT the stub) - Registers services, commands, and bootstrappers +- `Tenant` (model) - Represents individual tenants with domains and databases +- `Domain` (model) - Maps domains/subdomains to tenants + +**Tenant Identification:** +- **Resolvers** (`src/Resolvers/`) - Identify tenants by domain, path, or request data - this data comes from middleware +- **Middleware** (`src/Middleware/`) - Middleware that calls resolvers and tries to initialize tenancy based on information from a request +- **Cached resolvers** - Cached wrapper around resolvers to avoid querying the central database + +**Tenancy Bootstrappers (`src/Bootstrappers/`):** +- `DatabaseTenancyBootstrapper` - Switches database connections +- `CacheTenancyBootstrapper` - Isolates cache by tenant +- `FilesystemTenancyBootstrapper` - Manages tenant-specific storage +- `QueueTenancyBootstrapper` - Ensures queued jobs run in correct tenant context +- `RedisTenancyBootstrapper` - Prefixes Redis keys by tenant + +**Database Management:** +- **DatabaseManager** - Creates/deletes tenant databases and users +- **TenantDatabaseManagers** - Database-specific implementations (MySQL, PostgreSQL, SQLite, SQL Server) +- **Row Level Security (RLS)** - PostgreSQL-based tenant isolation using policies + +**Advanced Features:** +- **Resource Syncing** - Sync central models to tenant databases +- **User Impersonation** - Admin access to tenant contexts +- **Cross-domain redirects** - Handle multi-domain tenant setups +- **Telescope integration** - Tag entries by tenant + +### Key Patterns + +**Tenant Context Management:** +```php +tenancy()->initialize($tenant); // Switch to tenant +tenancy()->run($tenant, $callback); // Atomic tenant execution +tenancy()->runForMultiple($tenants, $callback); // Batch operations +tenancy()->central($callback); // Run in central context +``` + +**Tenant Identification Flow:** +1. Middleware identifies tenant from request (domain/subdomain/path) +2. Resolver fetches tenant model from identification data +3. Tenancy initializes and bootstrappers configure tenant context +4. Application runs with tenant-specific database/cache/storage + +**Route Middleware Groups:** +All of these work as flags, i.e. middleware groups that are empty arrays with a purely semantic use. +- `tenant` - Routes requiring tenant context +- `central` - Routes for central/admin functionality +- `universal` - Routes working in both contexts +- `clone` - Tells route cloning logic to clone the route + +### Testing Environment + +Tests use Docker with MySQL/PostgreSQL/Redis. The `./test` script runs Pest tests inside containers with proper database isolation. + +`./t 'test name'` is equivalent to `./test --filter 'test name'` + +**Key test patterns:** +- Database preparation and cleanup between tests +- Multi-database scenarios (central + tenant databases) +- Middleware and identification testing +- Resource syncing validation + +### Configuration + +Central config in `config/tenancy.php` controls: +- Tenant/domain model classes +- Database connection settings +- Enabled bootstrappers and features +- Identification middleware and resolvers +- Cache and storage prefixes diff --git a/src/Database/Concerns/PendingScope.php b/src/Database/Concerns/PendingScope.php index b5b6b9cb..d83a37dd 100644 --- a/src/Database/Concerns/PendingScope.php +++ b/src/Database/Concerns/PendingScope.php @@ -20,7 +20,7 @@ class PendingScope implements Scope /** * Apply the scope to a given Eloquent query builder. * - * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder + * @param Builder $builder * * @return void */ diff --git a/src/RLS/PolicyManagers/TableRLSManager.php b/src/RLS/PolicyManagers/TableRLSManager.php index 61c62f94..8e941b31 100644 --- a/src/RLS/PolicyManagers/TableRLSManager.php +++ b/src/RLS/PolicyManagers/TableRLSManager.php @@ -108,6 +108,12 @@ class TableRLSManager implements RLSPolicyManager protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void { + // If the foreign key has a comment of 'no-rls', we skip it + // Also skip the foreign key if implicit scoping is off and the foreign key has no comment + if ($foreign['comment'] === 'no-rls' || (! static::$scopeByDefault && $foreign['comment'] === null)) { + return; + } + if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) { throw new RecursiveRelationshipException; } @@ -115,15 +121,7 @@ class TableRLSManager implements RLSPolicyManager $currentPath[] = $foreign; if ($foreign['foreignTable'] === tenancy()->model()->getTable()) { - $comments = array_column($currentPath, 'comment'); - $pathCanUseRls = static::$scopeByDefault ? - ! in_array('no-rls', $comments) : - ! in_array('no-rls', $comments) && ! in_array(null, $comments); - - if ($pathCanUseRls) { - // If the foreign table is the tenants table, add the current path to $paths - $paths[] = $currentPath; - } + $paths[] = $currentPath; } else { // If not, recursively generate paths for the foreign table foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) { diff --git a/tests/RLS/TableManagerTest.php b/tests/RLS/TableManagerTest.php index 4a8ac058..fd9c6f44 100644 --- a/tests/RLS/TableManagerTest.php +++ b/tests/RLS/TableManagerTest.php @@ -503,7 +503,7 @@ test('table rls manager generates relationship trees with tables related to the // Add non-nullable comment_id foreign key Schema::table('ratings', function (Blueprint $table) { - $table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->comment('rls')->constrained('comments'); + $table->foreignId('comment_id')->comment('rls')->constrained('comments')->onUpdate('cascade')->onDelete('cascade'); }); // Non-nullable paths are preferred over nullable paths @@ -640,16 +640,29 @@ test('table rls manager generates queries correctly', function() { test('table manager throws an exception when encountering a recursive relationship', function() { Schema::create('recursive_posts', function (Blueprint $table) { $table->id(); - $table->foreignId('highlighted_comment_id')->constrained('comments')->nullable()->comment('rls'); + $table->foreignId('highlighted_comment_id')->nullable()->comment('rls')->constrained('comments'); }); Schema::table('comments', function (Blueprint $table) { - $table->foreignId('recursive_post_id')->constrained('recursive_posts')->comment('rls'); + $table->foreignId('recursive_post_id')->comment('rls')->constrained('recursive_posts'); }); expect(fn () => app(TableRLSManager::class)->generateTrees())->toThrow(RecursiveRelationshipException::class); }); +test('table manager ignores recursive relationship if the foreign key responsible for the recursion has no-rls comment', function() { + Schema::create('recursive_posts', function (Blueprint $table) { + $table->id(); + $table->foreignId('highlighted_comment_id')->nullable()->comment('no-rls')->constrained('comments'); + }); + + Schema::table('comments', function (Blueprint $table) { + $table->foreignId('recursive_post_id')->comment('rls')->constrained('recursive_posts'); + }); + + expect(fn () => app(TableRLSManager::class)->generateTrees())->not()->toThrow(RecursiveRelationshipException::class); +}); + class Post extends Model { protected $guarded = [];